diff --git a/.formatter.exs b/.formatter.exs index 7d2640f..f838e58 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -9,6 +9,8 @@ locals_without_parens = [ resource: 4, skill: 2, skill: 3, + agent: 2, + agent: 3, embed_skills: 1, input_schema: 1, field: 2, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d374d1..a4e4783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,8 @@ jobs: - name: Run MCP conformance suite run: mix test --only conformance + + - uses: astral-sh/setup-uv@v6 + + - name: Run A2A conformance suite (ACTS) + run: mix test --only acts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f35e3..9493221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## Unreleased + +- New: the `:extensions` option on `use Phantom.Router` adds extensions your + router supports to `capabilities.extensions` in `initialize` and + `server/discover`, such as `io.modelcontextprotocol/oauth-client-credentials`. +- New: the [OAuth Client Credentials guide](guides/oauth_client_credentials.md), + for machine clients that authenticate with the `client_credentials` grant. +- New: serve [A2A](https://a2a-protocol.org) v1.0 agents. Declare them in a + router with `agent "research", MyApp.Agents.Research`, implement them with + `Phantom.A2A.Agent`, and serve them with `Phantom.A2A.Plug` over the + JSON-RPC and HTTP+JSON bindings, streaming over SSE with a pubsub. Agents store their own tasks and report + progress with `Phantom.A2A.publish/2`, numbering events with + `Phantom.A2A.Events.apply/2` so a client that subscribes mid-task gets no + duplicates. `Phantom.Session.allow_agents/2` + limits agents per session, and `Phantom.A2A.Test` drives agents in tests. + Routes with path params, such as `agent ":org/:agent_key", Mod, :resolve`, + serve agents found at runtime. Agents with `push_notifications: true` + push each event to clients' webhooks, which Phantom checks against private + networks. Agents can offer an extended card (`extended_card: true`) and + declare protocol extensions, and requests and push deliveries emit + `[:phantom, :a2a, :dispatch]` and `[:phantom, :a2a, :push]` telemetry spans. + Phantom passes the A2A conformance suite (ACTS) on both bindings, apart from + its client tests. +- New: the [A2A guide](guides/a2a.md), with storing tasks in Ecto, running + them with Oban, and retrying push deliveries. +- New: `Phantom.A2A.Client` calls A2A agents over either binding, with the + optional `{:req, "~> 0.5"}` dependency. With it, Phantom passes every ACTS + test on both bindings. +- New: `c:Phantom.Router.list_agents/2` lists the agents a session may use. +- New: `Phantom.A2A.Bridge` serves a router's MCP tools as an A2A agent. + Mount it with `agent "tools", Phantom.A2A.Bridge`, and clients call a tool + with a data part naming it. Tools that answer with a `Phantom.Tasks` become + A2A tasks backed by the router's task callbacks, and A2A clients waiting on + them get each `Phantom.Tracker.notify_task_updated/1`. +- New: `Phantom.Router.call_tool/4` takes `tasks: true` to let a tool answer + with a `Phantom.Tasks`. +- New: `c:Phantom.A2A.Agent.card/2` adjusts an agent's card for its route. +- New: `Phantom.Router.call_tool/4` calls a router's tool from your own code, + such as an A2A agent answering with the tools the router already has. + ## 0.7.3 (2026-10-18) - Fixup canceling work due to inactivity diff --git a/README.md b/README.md index f982b9b..c852fe6 100644 --- a/README.md +++ b/README.md @@ -734,6 +734,154 @@ end See `Phantom.Elicit` for what each protocol version sends, and for waiting inline with `Phantom.Session.elicit/3`. +## Serving A2A agents + +Phantom also serves [A2A](https://a2a-protocol.org) agents, for clients that +delegate a whole job to an agent rather than call its tools. Declare agents in +the same router, so they share `connect/2`: + +```elixir +defmodule MyApp.MCP.Router do + use Phantom.Router, + name: "MyApp", + vsn: "1.0", + a2a: [well_known: "research"] + + agent "research", MyApp.Agents.Research + agent "billing", MyApp.Agents.Billing +end +``` + +Each agent is a module that answers messages with a message, or with a task +it stores and runs: + +```elixir +defmodule MyApp.Agents.Research do + use Phantom.A2A.Agent, + name: "MyApp Research", + description: "Designs and reviews research studies" + + alias Phantom.A2A.{Message, Task} + + @description "Review a study and suggest missing questions" + skill :study_review, tags: ["research"] + + def handle_message(message, ctx) do + {:ok, _job} = MyApp.Reviews.start(ctx.task_ref, message, ctx.assigns.user) + {:reply, Task.submitted(ctx), ctx} + end + + def get_task(task_id, ctx), do: MyApp.Reviews.get_a2a_task(task_id, ctx.assigns.user) +end +``` + +Agents found at runtime, such as ones each organization configures, take a +route with path params and a function that resolves them. It runs after +`connect/2`, so it can scope agents to the session, and returns the agent's +card and any state its handler needs: + +```elixir +# In the router: /a2a/acme/triage, /a2a/acme/benchmarks, ... +agent ":org/:agent_key", MyApp.Agents.Hosted, :resolve + +defmodule MyApp.Agents.Hosted do + use Phantom.A2A.Agent, name: "Hosted", description: "Agents configured per organization" + + def resolve(%{"org" => org, "agent_key" => key}, session) do + case MyApp.Agents.get(session.assigns.user, org, key) do + nil -> + {:error, :not_found} + + agent -> + {:ok, + %Phantom.A2A.Card{ + name: agent.name, + description: agent.description, + default_input_modes: ["text/plain"], + default_output_modes: ["text/plain"] + }, agent} + end + end + + # ctx.agent.params and ctx.agent.state hold the params and the resolved agent. + def handle_message(message, ctx) do + {:ok, run} = MyApp.Agents.start_run(ctx.agent.state, ctx.task_ref, message) + {:reply, Phantom.A2A.Task.submitted(ctx), ctx} + end +end +``` + +Wherever the work runs, save each change with `Phantom.A2A.Events.apply/2`, +then report it with `Phantom.A2A.publish/2`: + +```elixir +event = Phantom.A2A.Task.status(:working, "Reading study…") +{:ok, task, event} = Phantom.A2A.Events.apply(stored_task, event) +MyApp.Reviews.save!(task) +Phantom.A2A.publish(task_ref, event) +``` + +Serve the agents with `Phantom.A2A.Plug`, without `Plug.Parsers`, so invalid +JSON gets the JSON-RPC error A2A requires. With a `pubsub`, agents stream +over SSE: + +```elixir +get "/.well-known/agent-card.json", Phantom.A2A.Plug, + router: MyApp.MCP.Router, + mount: "/a2a" + +scope "/a2a" do + forward "/", Phantom.A2A.Plug, router: MyApp.MCP.Router, pubsub: MyApp.PubSub +end +``` + +Requests go through `connect/2` as MCP requests do, with the JSON-RPC request +in `conn.body_params`. A client may name a `tenant`, such as an organization, +in each request; it is only the client's claim, so check it there and assign +what the session may use: + +```elixir +def connect(session, conn) do + tenant = get_in(conn.body_params, ["params", "tenant"]) + + with {:ok, user} <- MyApp.Auth.verify(conn), + {:ok, org} <- MyApp.Orgs.for_user(user, tenant) do + {:ok, Phantom.Session.assign(session, user: user, org: org)} + else + _ -> {:forbidden, "Not allowed"} + end +end +``` + +With `push_notifications: true` on an agent, clients can register webhooks +for a task, and Phantom posts each event you publish to them. The agent stores +the configs; Phantom checks each webhook URL so it can't reach your private +network. See `Phantom.A2A.Push`. + +To serve your MCP tools as an A2A agent without writing one, mount +`Phantom.A2A.Bridge`. A client calls a tool with a data part naming it, and +the card lists each tool with its input schema: + +```elixir +agent "tools", Phantom.A2A.Bridge, tools: ~w[search_studies create_question] +``` + +To call other agents, use `Phantom.A2A.Client`, which needs the optional +`{:req, "~> 0.5"}` dependency: + +```elixir +{:ok, client} = Phantom.A2A.Client.new("https://agents.example") +{:ok, reply} = Phantom.A2A.Client.send_message(client, "Hello") +``` + +Phantom serves the JSON-RPC and HTTP+JSON bindings: `SendMessage`, +`SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, +`SubscribeToTask`, `GetExtendedAgentCard`, and the push notification config +methods. Agents can +declare protocol extensions, and each request is a +`[:phantom, :a2a, :dispatch]` telemetry span carrying the client's W3C trace +context. See the [A2A guide](guides/a2a.md). + ## What PhantomMCP supports Phantom will implement these MCP requests on your behalf: @@ -824,6 +972,9 @@ defmodule MyApp.MCP.Router do end ``` +For machine clients that authenticate with the OAuth `client_credentials` grant, such as CI jobs +and other services, see the [OAuth Client Credentials guide](guides/oauth_client_credentials.md). + ## Optional callbacks There are several optional callbacks to help you hook into the lifecycle of the connections. diff --git a/guides/a2a.md b/guides/a2a.md new file mode 100644 index 0000000..f1caa0f --- /dev/null +++ b/guides/a2a.md @@ -0,0 +1,520 @@ +# A2A agents + +[A2A](https://a2a-protocol.org) (Agent2Agent) lets a client delegate a whole +job to an agent and follow it as a task, where MCP lets a client's model call +your tools one at a time. Phantom serves A2A v1.0 agents from the same router +as your MCP tools, over the JSON-RPC and HTTP+JSON bindings, with streaming +over SSE and push notifications to webhooks. + +| Phantom | You | +|---|---| +| Serves agent cards and the A2A methods, and validates requests | Implement each agent's `handle_message/2` | +| Calls `connect/2` on every request, as for MCP | Authenticate, and assign what the session may use | +| Streams the events you publish to clients, on any node | Store tasks, run the work, and publish each change | +| Checks webhook URLs and delivers push notifications | Store push configs, and deliver from a job queue if deliveries must survive a restart | + +## Quick start + +Declare agents in your router: + +```elixir +defmodule MyApp.MCP.Router do + use Phantom.Router, + name: "MyApp", + vsn: "1.0", + a2a: [well_known: "research"] + + agent "research", MyApp.Agents.Research +end +``` + +Implement the agent. The simplest answers every message with a message: + +```elixir +defmodule MyApp.Agents.Research do + use Phantom.A2A.Agent, + name: "MyApp Research", + description: "Designs and reviews research studies" + + @description "Answer questions about study design" + skill :study_design, tags: ["research"] + + def handle_message(message, ctx) do + {:reply, Phantom.A2A.Message.agent(MyApp.Research.answer(message)), ctx} + end +end +``` + +Serve it. Mount `Phantom.A2A.Plug` without `Plug.Parsers`, so a request with +invalid JSON gets the error A2A requires: + +```elixir +# lib/my_app_web/router.ex +get "/.well-known/agent-card.json", Phantom.A2A.Plug, + router: MyApp.MCP.Router, + mount: "/a2a" + +scope "/a2a" do + forward "/", Phantom.A2A.Plug, router: MyApp.MCP.Router, pubsub: MyApp.PubSub +end +``` + +The card is at `/a2a/research/agent-card.json`. The agent answers JSON-RPC at +`POST /a2a/research` and HTTP+JSON below it, such as +`POST /a2a/research/message:send`. Clients must send `A2A-Version: 1.0`. Try it +with the [A2A Inspector](https://github.com/a2aproject/a2a-inspector). + +## Tasks + +Answer with a `Phantom.A2A.Task` when the work takes a while. Phantom doesn't +store tasks; you do, and you report each change with `Phantom.A2A.publish/2`: + +```elixir +def handle_message(message, ctx) do + task = Phantom.A2A.Task.submitted(ctx) + MyApp.A2ATasks.start!(task, message, ctx) + {:reply, task, ctx} +end + +def get_task(task_id, ctx), do: MyApp.A2ATasks.get(task_id, ctx.assigns.user) +``` + +A new task must use `ctx.task_ref.task_id` as its ID, which +`Phantom.A2A.Task.submitted/2` does. The `Phantom.A2A.TaskRef` in `ctx.task_ref` +addresses the task from anywhere, so pass it to the work. Save every change +before you publish it: clients that ask for the task afterwards get it from +`c:Phantom.A2A.Agent.get_task/2`. + +What a client sees depends on the method it called: + +- `SendMessage` waits until the task finishes or needs input, then answers + with the task. With `returnImmediately: true` it answers at once. Without a + `:pubsub` on the plug, it polls `get_task/2` instead of waiting on events. It + stops waiting after `:blocking_timeout` and answers with the task as it is. +- `SendStreamingMessage` and `SubscribeToTask` stream the task, then each + event you publish, until it finishes or needs input. They stay open through + `auth_required`, so an agent that receives the credential out of band can + carry on in the same stream. `SendMessage` still returns at `auth_required`, + so the client can authenticate. +- `GetTask` and `ListTasks` read your store. + +`get_task/2` is also where you check access: return `{:error, :not_found}` +for a task the session may not see. + +### Example: Ecto and Oban + +This stores each task as an Erlang term, next to the columns `ListTasks` +filters on, and runs the work in an [Oban](https://hexdocs.pm/oban) job. +`Phantom.A2A.Events.apply/2` applies each event to the stored task with the +rules A2A defines, and numbers it. Publish the numbered event: a client that +subscribes while you publish gets the stored task, and Phantom skips the +events already in it. Number events in the same transaction that locks the +task, so two writers can't number events the same. + +```elixir +defmodule MyApp.Repo.Migrations.CreateA2ATasks do + use Ecto.Migration + + def change do + create table(:a2a_tasks, primary_key: false) do + add :id, :string, primary_key: true + add :user_id, references(:users, on_delete: :delete_all), null: false + add :agent_id, :string, null: false + add :context_id, :string, null: false + add :state, :string, null: false + add :task, :binary, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:a2a_tasks, [:user_id, :context_id]) + create index(:a2a_tasks, [:user_id, :updated_at]) + end +end +``` + +```elixir +defmodule MyApp.A2ATask do + use Ecto.Schema + + @primary_key {:id, :string, autogenerate: false} + @timestamps_opts [type: :utc_datetime_usec] + + schema "a2a_tasks" do + belongs_to :user, MyApp.Accounts.User + field :agent_id, :string + field :context_id, :string + field :state, :string + field :task, :binary + + timestamps() + end +end +``` + +```elixir +defmodule MyApp.A2ATasks do + import Ecto.Query + + alias MyApp.{A2ATask, Repo} + alias Phantom.A2A.{Events, Task, TaskRef} + + def start!(%Task{} = task, message, ctx) do + Repo.transaction(fn -> + Repo.insert!(%A2ATask{ + id: task.id, + user_id: ctx.assigns.user.id, + agent_id: ctx.task_ref.agent_id, + context_id: task.context_id, + state: Atom.to_string(task.status.state), + task: :erlang.term_to_binary(task) + }) + + text = Enum.map_join(message.parts, " ", &(&1.text || "")) + %{"task_ref" => TaskRef.encode(ctx.task_ref), "text" => text} + |> MyApp.Workers.Research.new() + |> Oban.insert!() + end) + end + + def get(task_id, user) do + case Repo.get_by(A2ATask, id: task_id, user_id: user.id) do + nil -> {:error, :not_found} + record -> {:ok, to_task(record)} + end + end + + @doc "Apply an event to the stored task, then publish the numbered event." + def emit!(%TaskRef{} = ref, event) do + {:ok, event} = + Repo.transaction(fn -> + record = A2ATask |> lock("FOR UPDATE") |> Repo.get!(ref.task_id) + {:ok, task, event} = Events.apply(to_task(record), event) + + record + |> Ecto.Changeset.change( + state: Atom.to_string(task.status.state), + task: :erlang.term_to_binary(task) + ) + |> Repo.update!() + + event + end) + + Phantom.A2A.publish(ref, event) + end + + defp to_task(record), do: :erlang.binary_to_term(record.task, [:safe]) +end +``` + +The worker reports progress through the same function: + +```elixir +defmodule MyApp.Workers.Research do + use Oban.Worker, queue: :a2a + + alias Phantom.A2A.{Artifact, Part, Task, TaskRef} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"task_ref" => encoded, "text" => text}}) do + {:ok, ref} = TaskRef.decode(encoded) + + MyApp.A2ATasks.emit!(ref, Task.status(:working, "Reading the study")) + + # The first chunk adds the artifact; the rest append to it. + text + |> MyApp.Research.review() + |> Enum.with_index() + |> Enum.each(fn {chunk, i} -> + MyApp.A2ATasks.emit!(ref, Artifact.update("review", [Part.text(chunk)], append: i > 0)) + end) + + MyApp.A2ATasks.emit!(ref, Task.status(:completed, "Review ready")) + end +end +``` + +### Asking the client for more + +To continue a task, a client sends a message with its `taskId`. Phantom +fetches the task with `get_task/2`, refuses one that has finished, and calls +`handle_message/2` again with the same `ctx.task_ref`: + +```elixir +# The work asked a question: +MyApp.A2ATasks.emit!(ref, Task.status(:input_required, "Include demographic questions?")) + +# The client's answer arrives as a new message for the same task: +def handle_message(%Phantom.A2A.Message{task_id: task_id} = message, ctx) + when is_binary(task_id) do + {:ok, task} = MyApp.A2ATasks.get(task_id, ctx.assigns.user) + MyApp.A2ATasks.resume!(task, message, ctx) + {:reply, task, ctx} +end +``` + +### Canceling and listing + +Implement `c:Phantom.A2A.Agent.cancel_task/2` to stop the work and return the +canceled task. Phantom refuses to cancel a finished task, and returns an +already canceled one as it is. Without the callback, tasks can't be canceled. + +Implement `c:Phantom.A2A.Agent.list_tasks/2` for `ListTasks`. Phantom parses +the filter and trims each task's history and artifacts as the client asked: + +```elixir +def list_tasks(filter, ctx) do + query = + from t in MyApp.A2ATask, + where: t.user_id == ^ctx.assigns.user.id, + order_by: [desc: t.updated_at] + + query = if filter.context_id, do: where(query, context_id: ^filter.context_id), else: query + query = if filter.status, do: where(query, state: ^Atom.to_string(filter.status)), else: query + + offset = String.to_integer(filter.page_token || "0") + records = query |> limit(^(filter.page_size + 1)) |> offset(^offset) |> MyApp.Repo.all() + {page, more} = Enum.split(records, filter.page_size) + + {:ok, + %{ + tasks: Enum.map(page, &:erlang.binary_to_term(&1.task, [:safe])), + next_page_token: if(more != [], do: Integer.to_string(offset + filter.page_size)) + }} +end +``` + +## Authentication and tenants + +Each request calls `c:Phantom.Router.connect/2` with the conn, whose +`body_params` hold the request: the JSON-RPC request, or the same shape for +HTTP+JSON. Return `{:unauthorized, info}` for a +401 with `WWW-Authenticate`, or `{:forbidden, message}` for a 403. Agent cards +stay public. + +A client may name a `tenant`, such as an organization, in each request. It is +the client's claim, so check it in `connect/2` and assign what the session may +use; handlers then read `ctx.assigns`, not `ctx.tenant`: + +```elixir +def connect(session, conn) do + tenant = get_in(conn.body_params, ["params", "tenant"]) + + with {:ok, user} <- MyApp.Auth.verify(conn), + {:ok, org} <- MyApp.Orgs.for_user(user, tenant) do + {:ok, + session + |> Phantom.Session.assign(user: user, org: org) + |> Phantom.Session.allow_agents(MyApp.Agents.allowed_for(org))} + end +end +``` + +`Phantom.Session.allow_agents/2` takes agent paths, or the paths of routes +with params. A disallowed agent is not found. + +## Agents found at runtime + +A route with path params serves agents you store, such as ones each +organization configures. Phantom calls the resolver after `connect/2`, with the +params and the session: + +```elixir +agent ":org/:agent_key", MyApp.Agents.Hosted, :resolve +``` + +```elixir +defmodule MyApp.Agents.Hosted do + use Phantom.A2A.Agent, name: "Hosted", description: "Agents configured per organization" + + def resolve(%{"org" => org, "agent_key" => key}, session) do + case MyApp.Agents.get(session.assigns.user, org, key) do + nil -> + {:error, :not_found} + + agent -> + card = %Phantom.A2A.Card{ + name: agent.name, + description: agent.description, + default_input_modes: ["text/plain"], + default_output_modes: ["text/plain"] + } + + {:ok, card, agent} + end + end + + def handle_message(message, ctx) do + task = Phantom.A2A.Task.submitted(ctx) + MyApp.Agents.start_run!(ctx.agent.state, task, message, ctx) + {:reply, task, ctx} + end +end +``` + +The handler finds the params in `ctx.agent.params` and what the resolver +returned in `ctx.agent.state`. A resolved agent's card needs the session, so +it isn't public. Routes without params match first. + +## Push notifications + +With `push_notifications: true`, clients can register a webhook for a task, +and Phantom posts each event you publish to it. Store the configs: + +```elixir +use Phantom.A2A.Agent, + name: "MyApp Research", + description: "Designs and reviews research studies", + push_notifications: true + +def create_push_config(config, ctx), do: MyApp.PushConfigs.create(config, ctx.assigns.user) +def get_push_config(task_id, id, _ctx), do: MyApp.PushConfigs.get(task_id, id) +def list_push_configs(task_id, _ctx), do: {:ok, MyApp.PushConfigs.list(task_id)} +def delete_push_config(task_id, id, _ctx), do: MyApp.PushConfigs.delete(task_id, id) + +# Called by publish/2, in the process that publishes, which has no session +def push_configs(task_ref), do: MyApp.PushConfigs.list(task_ref.task_id) +``` + +Phantom checks the task with `get_task/2` before each config method, and +checks the webhook URL: it must use `https` and resolve only to public +addresses. Delivery checks the URL again, connects to the address it checked, +and doesn't follow redirects. The credentials in a config are the client's +secrets for its own webhook, so store them encrypted. + +By default each webhook gets a task's events in order, from a supervised +process. Up to 100 events wait for each webhook in memory, so they end with +the node. For delivery that survives a restart, +override `c:Phantom.A2A.Agent.deliver_push/3` and deliver from a job: + +```elixir +def deliver_push(config, event, _task_ref) do + %{"config_id" => config.id, "event" => Base.encode64(:erlang.term_to_binary(event))} + |> MyApp.Workers.Push.new() + |> Oban.insert!() + + :ok +end +``` + +```elixir +defmodule MyApp.Workers.Push do + use Oban.Worker, queue: :a2a_push, max_attempts: 10 + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"config_id" => id, "event" => event}}) do + config = MyApp.PushConfigs.get!(id) + event = event |> Base.decode64!() |> :erlang.binary_to_term([:safe]) + Phantom.A2A.Push.deliver(config, event) + end +end +``` + +Set `a2a: [allow_private_push_urls: true]` on the router to allow `http` and +private addresses, such as in development. + +## Extended cards and extensions + +With `extended_card: true`, authenticated clients can fetch a fuller card. +Return it from `c:Phantom.A2A.Agent.extended_card/2`, such as with skills only +members may see: + +```elixir +def extended_card(card, ctx) do + {:ok, %{card | skills: card.skills ++ MyApp.Agents.member_skills(ctx.assigns.user)}} +end +``` + +Declare protocol extensions with the `:extensions` option. Phantom refuses a +request that doesn't name a `required: true` extension in its +`A2A-Extensions` header, and gives the handler the ones it named in +`ctx.extensions`. + +## Using your MCP tools + +To serve your tools as an A2A agent as they are, mount `Phantom.A2A.Bridge`: + +```elixir +agent "tools", Phantom.A2A.Bridge, tools: ~w[search_studies create_question] +``` + +Its card lists each tool as a skill, and an extension with the tools' input +schemas. A client calls a tool with a message whose data part names it, +`{"tool": "search_studies", "arguments": {"query": "churn"}}`, and gets a +message with the tool's result. Messages that name no tool go to the +`:fallback` agent, if you give one. + +When your router implements the MCP Tasks callbacks, a tool that answers with +a `Phantom.Tasks` becomes an A2A task: `GetTask`, `CancelTask`, and answers to +its input requests go to your router, and each +`Phantom.Tracker.notify_task_updated/1` reaches A2A clients waiting on it. See +`Phantom.A2A.Bridge`. + +For an agent that decides what to do itself, call tools from its handler. +`Phantom.Router.call_tool/4` runs a tool with the session's assigns and +allow-lists, and waits for its result: + +```elixir +def handle_message(message, ctx) do + {:ok, %{content: [%{text: text} | _]}} = + Phantom.Router.call_tool(ctx.session, :search_studies, query: query_from(message)) + + {:reply, Phantom.A2A.Message.agent(text), ctx} +end +``` + +## Calling other agents + +`Phantom.A2A.Client` calls any A2A agent, Phantom's or not, over JSON-RPC or +HTTP+JSON. Add the optional `{:req, "~> 0.5"}` dependency, then: + +```elixir +alias Phantom.A2A.Client + +{:ok, client} = Client.new("https://agents.example", headers: [{"authorization", "Bearer " <> token}]) + +{:ok, task} = Client.send_message(client, "Review study 42", return_immediately: true) + +{:ok, stream} = Client.subscribe(client, task.id) +Enum.each(stream, &IO.inspect/1) +``` + +`Client.new/2` fetches the card and picks a binding it lists. The client +refuses to stream, use push notifications, or fetch an extended card when the +card doesn't advertise them. + +## Listing your agents + +A2A has no method to list an agent's peers, so clients find agents by their +cards. To build a directory page or a test of your own agents, use +`c:Phantom.Router.list_agents/2`. By default it lists every `agent` route +without path params the session may use; override it to add the agents your +routes with path params resolve. + +## Testing + +`Phantom.A2A.Test` sends requests through `Phantom.A2A.Plug` in the test +process, so `connect/2` and validation run, and returns the decoded JSON: + +```elixir +import Phantom.A2A.Test + +test "answers with a task" do + assert {:task, %{"status" => %{"state" => "TASK_STATE_SUBMITTED"}}} = + send_message(MyApp.MCP.Router, "research", "Review study 42", + headers: [{"authorization", "Bearer " <> token}] + ) +end +``` + +Phantom itself runs the official A2A conformance suite (ACTS) in CI against a +test agent; see `test/acts/README.md` in the repository. + +## Telemetry + +Each request is a `[:phantom, :a2a, :dispatch]` span, and each push +delivery a `[:phantom, :a2a, :push]` span. The dispatch metadata carries the +W3C trace context the client sent in its `traceparent`, `tracestate`, and +`baggage` headers, for your tracer. See `Phantom.A2A.Plug` and +`Phantom.A2A.Push` for the metadata. diff --git a/guides/oauth_client_credentials.md b/guides/oauth_client_credentials.md new file mode 100644 index 0000000..06ea22d --- /dev/null +++ b/guides/oauth_client_credentials.md @@ -0,0 +1,141 @@ +# OAuth Client Credentials + +The [OAuth Client Credentials extension](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) +(`io.modelcontextprotocol/oauth-client-credentials`) lets clients with no user +present, such as CI jobs, scheduled syncs and other services, call your MCP +server. The client gets an access token from your authorization server with +the OAuth `client_credentials` grant, then sends it as a bearer token like +any other MCP client. + +Phantom doesn't issue or verify tokens. Most of the extension is between the +client and your authorization server; your router's part is checking the token +in `c:Phantom.Router.connect/2`: + +| Who | Does | +|---|---| +| Your authorization server | Registers machine clients out of band and issues tokens for the `client_credentials` grant | +| Your authorization server | Lists `private_key_jwt` and/or `client_secret_basic` in `token_endpoint_auth_methods_supported`, and `token_endpoint_auth_signing_alg_values_supported` when it accepts JWTs | +| Your app | Serves the protected resource metadata that names the authorization server | +| Your router | Answers requests without a valid token with `401` and `WWW-Authenticate` | +| Your router | Verifies the token, its audience and its scopes, then limits what a machine client may use | +| Phantom | Advertises the extension when you list it in `:extensions` | + +## Advertising the extension + +The specification recommends advertising support in `server/discover`. List +the extension when you use `Phantom.Router`; Phantom adds it to +`capabilities.extensions` in `server/discover` and `initialize`: + +```elixir +defmodule MyApp.MCP.Router do + use Phantom.Router, + name: "MyApp", + vsn: "1.0", + extensions: %{"io.modelcontextprotocol/oauth-client-credentials" => %{}} +end +``` + +## Pointing clients at your authorization server + +A client without a token gets a `401`. The `WWW-Authenticate` header names +your [protected resource metadata](https://datatracker.ietf.org/doc/html/rfc9728), +which names your authorization server. Serve the metadata outside +`Phantom.Plug`, for example in a Phoenix controller: + +```elixir +# router.ex +get "/.well-known/oauth-protected-resource", MyAppWeb.OAuthMetadataController, :show + +# oauth_metadata_controller.ex +def show(conn, _params) do + json(conn, %{ + resource: "https://mcp.myapp.example/mcp", + authorization_servers: ["https://auth.myapp.example"], + scopes_supported: ["mcp:read", "mcp:write"] + }) +end +``` + +The client then reads your authorization server's metadata to learn how to +authenticate at its token endpoint. Configure that in your authorization +server, not in Phantom. + +## Checking the token in `connect/2` + +A client credentials token is a bearer token like any other. Verify it in +`c:Phantom.Router.connect/2` with your OAuth library: its signature, expiry, +issuer, and an audience that is this MCP server. + +A token issued with the `client_credentials` grant has no user. In +[RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068#section-2.2) access +tokens, `sub` is then the client's ID; other authorization servers mark it +their own way. Treat that client as its own principal, not as a user, and +allow it only what its scopes cover: + +```elixir +def connect(session, conn) do + with ["Bearer " <> token] <- Plug.Conn.get_req_header(conn, "authorization"), + {:ok, claims} <- MyApp.Auth.verify_access_token(token) do + authorize(session, claims) + else + _ -> + {:unauthorized, + %{ + method: "Bearer", + resource_metadata: "https://mcp.myapp.example/.well-known/oauth-protected-resource", + scope: "mcp:read" + }} + end +end + +# A user signed in through the authorization code flow. +defp authorize(session, %{"sub" => sub, "client_id" => client_id}) + when sub != client_id do + {:ok, Phantom.Session.assign(session, user: MyApp.Accounts.get_user!(sub))} +end + +# A machine client: no user, and only the tools its scopes allow. +defp authorize(session, %{"client_id" => client_id, "scope" => scope}) do + scopes = String.split(scope) + + if "mcp:read" in scopes do + {:ok, + session + |> Phantom.Session.assign(client_id: client_id) + |> Phantom.Session.allow_tools(MyApp.MCP.tools_for_scopes(scopes))} + else + {:forbidden, "This client may not use the MCP server"} + end +end + +defp authorize(_session, _claims), do: {:forbidden, "Unrecognized access token"} +``` + +`scope` in the `401` tells the client which scopes to ask your authorization +server for. `MyApp.MCP.tools_for_scopes/1` stands for your own mapping from +scopes to tool names; `Phantom.Session.allow_tools/2` hides every other tool +from `tools/list` and refuses calls to it. + +## Testing a machine client's tools + +Build a session the way `connect/2` would for a machine client, then call a +tool it isn't allowed. See `Phantom.Test` for starting Phantom in your tests: + +```elixir +import Phantom.Test + +test "a read-only machine client can't create questions" do + session = + build_session(MyApp.MCP.Router, + assigns: %{client_id: "ci-job"}, + allowed_tools: MyApp.MCP.tools_for_scopes(["mcp:read"]) + ) + + session + |> call_tool(:create_question, %{study_id: 1, label: "Q", description: "D"}) + |> assert_jsonrpc_error(code: -32602) +end +``` + +Phantom calls `connect/2` on every request, so an expired or revoked token +stops working on the next request. Clients refresh their tokens themselves. diff --git a/lib/phantom/a2a.ex b/lib/phantom/a2a.ex new file mode 100644 index 0000000..3fa3d0c --- /dev/null +++ b/lib/phantom/a2a.ex @@ -0,0 +1,96 @@ +defmodule Phantom.A2A do + @moduledoc """ + Serve [A2A](https://a2a-protocol.org) agents from a `Phantom.Router`. + + Declare agents in the router, and implement each with `Phantom.A2A.Agent`: + + defmodule MyApp.MCP.Router do + use Phantom.Router, name: "MyApp", a2a: [well_known: "research"] + + agent "research", MyApp.Agents.Research + end + + Then serve them with `Phantom.A2A.Plug`: + + get "/.well-known/agent-card.json", Phantom.A2A.Plug, router: MyApp.MCP.Router + + scope "/a2a" do + forward "/", Phantom.A2A.Plug, router: MyApp.MCP.Router, pubsub: MyApp.PubSub + end + """ + + alias Phantom.A2A.{ArtifactUpdate, Message, StatusUpdate, Task, TaskRef} + + @type event :: Task.t() | StatusUpdate.t() | ArtifactUpdate.t() | Message.t() + + @doc """ + Send an event about a task to the clients waiting on it, on any node, and + push it to the task's webhooks when its agent supports push notifications. + + Save the change first, with `Phantom.A2A.Events.apply/2`, and publish the + event it returns. Phantom doesn't store events, and a client that asks for + the task next gets it from `c:Phantom.A2A.Agent.get_task/2`. The event's + number lets a stream skip what that task already includes. Phantom fills in + the event's task and context IDs from the ref. Without a `:pubsub` + on `Phantom.A2A.Plug`, nothing is streamed. + """ + def publish(%TaskRef{} = ref, event) do + event = fill_ids(event, ref) + + if ref.pubsub, + do: + Phoenix.PubSub.broadcast(ref.pubsub, topic(ref), {:phantom_a2a_event, ref.task_id, event}) + + push(ref, event) + end + + defp fill_ids(%struct{} = event, ref) when struct in [StatusUpdate, ArtifactUpdate], + do: %{ + event + | task_id: event.task_id || ref.task_id, + context_id: event.context_id || ref.context_id + } + + defp fill_ids(event, _ref), do: event + + defp push(ref, event) do + with {:ok, %{handler: handler}, _params} <- + Phantom.Router.A2A.match(ref.router, String.split(ref.agent_id, "/")), + true <- handler.__phantom_a2a__(:push_notifications) do + Enum.each(handler.push_configs(ref), &handler.deliver_push(&1, event, ref)) + end + + :ok + end + + @doc false + def subscribe(%TaskRef{pubsub: nil}), do: :ok + def subscribe(%TaskRef{} = ref), do: Phoenix.PubSub.subscribe(ref.pubsub, topic(ref)) + + @doc false + def unsubscribe(%TaskRef{pubsub: nil}), do: :ok + def unsubscribe(%TaskRef{} = ref), do: Phoenix.PubSub.unsubscribe(ref.pubsub, topic(ref)) + + defp topic(ref), do: "phantom:a2a:#{inspect(ref.router)}:#{ref.agent_id}:#{ref.task_id}" + + @doc "An unguessable ID for a task or context" + def generate_id, do: 24 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + + @doc false + def event_from_json(%{"task" => json}), do: Task.from_json(json) + def event_from_json(%{"message" => json}), do: Message.from_json(json) + def event_from_json(%{"statusUpdate" => json}), do: StatusUpdate.from_json(json) + def event_from_json(%{"artifactUpdate" => json}), do: ArtifactUpdate.from_json(json) + def event_from_json(_json), do: {:error, "unknown event"} + + @doc false + def event_to_json(event, opts \\ []) + def event_to_json(%Task{} = task, opts), do: %{"task" => Task.to_json(task, opts)} + def event_to_json(%Message{} = message, _opts), do: %{"message" => Message.to_json(message)} + + def event_to_json(%StatusUpdate{} = update, _opts), + do: %{"statusUpdate" => StatusUpdate.to_json(update)} + + def event_to_json(%ArtifactUpdate{} = update, _opts), + do: %{"artifactUpdate" => ArtifactUpdate.to_json(update)} +end diff --git a/lib/phantom/a2a/agent.ex b/lib/phantom/a2a/agent.ex new file mode 100644 index 0000000..98cfbb3 --- /dev/null +++ b/lib/phantom/a2a/agent.ex @@ -0,0 +1,296 @@ +defmodule Phantom.A2A.Agent do + @moduledoc """ + Implement an A2A agent, served from a `Phantom.Router` with `agent`. + + defmodule MyApp.Agents.Research do + use Phantom.A2A.Agent, + name: "MyApp Research", + description: "Designs and reviews research studies", + input_modes: ~w[text/plain application/json], + output_modes: ~w[text/markdown] + + alias Phantom.A2A.{Message, Task} + + @description "Review a study and suggest missing questions" + skill :study_review, tags: ~w[research], examples: ["Review study 42"] + + def handle_message(message, ctx) do + {:ok, _job} = MyApp.Reviews.start(ctx.task_ref, message, ctx.assigns.user) + {:reply, Task.submitted(ctx), ctx} + end + + def get_task(task_id, ctx), do: MyApp.Reviews.get_a2a_task(task_id, ctx.assigns.user) + end + + ## Options + + * `:name` - the agent's name (required) + * `:description` - what the agent does (required) + * `:version` - the agent's version (default: the router's `:vsn`) + * `:input_modes` - media types the agent accepts (default `["text/plain"]`), + any MIME type, such as `"application/json"`, `"image/png"`, or + `"application/pdf"`. Phantom rejects a message with a part whose + `media_type` isn't listed; parts without a media type are accepted. + * `:output_modes` - media types the agent produces (default + `["text/plain"]`). Only advertised: a client may ask for some of them + with `acceptedOutputModes`, in `ctx.configuration`. + * `:documentation_url`, `:icon_url` - shown on the card + * `:push_notifications` - whether the agent pushes task updates to + clients' webhooks (default `false`). See "Push notifications". + * `:extensions` - protocol extensions the agent supports, as maps with + `:uri`, and optionally `:description`, `:required`, and `:params`. + Phantom refuses requests that don't name a required extension in their + `A2A-Extensions` header, and gives the handler the requested ones in + `ctx.extensions`. + * `:extended_card` - whether authenticated clients can fetch a fuller + card with `GetExtendedAgentCard` (default `false`). See + `c:extended_card/2`. + + ## Skills + + `skill` lists something the agent can do on its card. It reads + `@description`, or takes `:description`, plus `:name`, `:tags`, `:examples`, + `:input_modes`, and `:output_modes`. Skills only describe the agent: every + message goes to `c:handle_message/2`. + + ## Push notifications + + With `push_notifications: true`, clients can register webhooks for a task, + and Phantom pushes each event you publish to them. Store configs with + `c:create_push_config/2` and its siblings, and return a task's configs from + `c:push_configs/1`. See `Phantom.A2A.Push` for how webhooks are checked and + called. + + ## Tasks + + Phantom doesn't store tasks. Implement `c:get_task/2` to return them; it is + also where access is checked, so return `{:error, :not_found}` for tasks the + session may not see. A client continues a task by sending a message with its + `taskId`; Phantom checks it with `c:get_task/2` first. Report progress from + wherever the work runs: save each event with `Phantom.A2A.Events.apply/2`, + then send the event it returns with `Phantom.A2A.publish/2`. Phantom forwards each + event to `SendStreamingMessage` and `SubscribeToTask` streams, and closes + them when the task finishes or needs input. Streams stay open through + `auth_required`, so publish the next event once the credential arrives. + """ + + alias Phantom.A2A.{Card, Context, Error, Message, PushConfig, Task, TaskRef} + + @doc """ + Answer a client's message with a `Phantom.A2A.Message`, or with a + `Phantom.A2A.Task` whose ID is `ctx.task_ref.task_id`. + """ + @callback handle_message(Message.t(), Context.t()) :: + {:reply, Message.t() | Task.t(), Context.t()} | {:error, Error.t(), Context.t()} + + @doc """ + Fetch a task the session may see. The default finds no tasks, for an agent + that only answers with messages. + """ + @callback get_task(task_id :: String.t(), Context.t()) :: {:ok, Task.t()} | {:error, :not_found} + + @doc "Cancel a task. By default, tasks can't be canceled." + @callback cancel_task(Task.t(), Context.t()) :: {:ok, Task.t()} | {:error, :not_cancelable} + + @typedoc """ + The `ListTasks` filter. `page_size` is from 1 to 100 (default 50), and the + others are `nil` when the client didn't send them. + """ + @type list_filter :: %{ + context_id: String.t() | nil, + status: Phantom.A2A.TaskStatus.state() | nil, + page_size: pos_integer(), + page_token: String.t() | nil, + status_timestamp_after: DateTime.t() | nil + } + + @doc """ + List the tasks the session may see that match the filter, newest first. + By default, `ListTasks` is not supported. + + Return at most `page_size` tasks, and a `next_page_token` for the next page, + or `nil` on the last one. Phantom trims history and artifacts as the client + asked. `total_size` counts the tasks across all pages, and is required unless + this is the last page and the only one. + """ + @callback list_tasks(list_filter(), Context.t()) :: + {:ok, + %{ + required(:tasks) => [Task.t()], + optional(:next_page_token) => String.t() | nil, + optional(:total_size) => non_neg_integer() + }} + | {:error, Error.t()} + + @doc """ + Save a push notification config for a task the session may see. Phantom + has checked the task with `c:get_task/2` and the URL, and given the config + an ID. + """ + @callback create_push_config(PushConfig.t(), Context.t()) :: + {:ok, PushConfig.t()} | {:error, Error.t()} + + @doc "Fetch a task's push notification config" + @callback get_push_config(task_id :: String.t(), id :: String.t(), Context.t()) :: + {:ok, PushConfig.t()} | {:error, :not_found | Error.t()} + + @doc "List a task's push notification configs" + @callback list_push_configs(task_id :: String.t(), Context.t()) :: + {:ok, [PushConfig.t()]} | {:error, Error.t()} + + @doc "Delete a task's push notification config. Deleting a missing one is `:ok`." + @callback delete_push_config(task_id :: String.t(), id :: String.t(), Context.t()) :: + :ok | {:error, Error.t()} + + @doc """ + The configs to push an event to. `Phantom.A2A.publish/2` calls it, in the + process that publishes, which has no session. Defaults to none. + """ + @callback push_configs(TaskRef.t()) :: [PushConfig.t()] + + @doc """ + Deliver an event to a webhook. The default sends it in a supervised process + with `Phantom.A2A.Push.deliver/3`, retrying with a backoff held in memory. + Override it to deliver from a durable job queue, such as Oban. + """ + @callback deliver_push(PushConfig.t(), Phantom.A2A.event(), TaskRef.t()) :: :ok + + @doc """ + Adjust the agent's card for its route, given the card from `use` and the + route's router and options. Defaults to the card as it is. + + # agent "support-es", MyApp.Agents.Support, locale: "es" + def card(card, %{opts: opts}), do: %{card | name: "Support (\#{opts[:locale]})"} + """ + @callback card(Card.t(), %{router: module(), opts: keyword()}) :: Card.t() + + @doc """ + The card for `GetExtendedAgentCard`, given the public card, such as with + skills only members may see. The request has passed + `c:Phantom.Router.connect/2`, which the router must implement: A2A requires + authentication for extended cards, so a router with the default `connect/2` + answers `ExtendedAgentCardNotConfiguredError`. Return an error to refuse the + request. Defaults to the public card. + """ + @callback extended_card(Card.t(), Context.t()) :: {:ok, Card.t()} | {:error, Error.t()} + + @doc """ + Whether the agent's tasks are MCP tasks (`Phantom.Tasks`), updated with + `Phantom.Tracker.notify_task_updated/1`. While a request follows one of its + tasks, Phantom listens for those notifications through `Phantom.Tracker` + and sends the task from `c:get_task/2` after each. `Phantom.A2A.Bridge` + does. Defaults to `false`. + """ + @callback follows_mcp_tasks?() :: boolean() + + defmacro __using__(opts) do + quote location: :keep, generated: true do + @behaviour Phantom.A2A.Agent + import Phantom.A2A.Agent, only: [skill: 1, skill: 2] + + @impl Phantom.A2A.Agent + def get_task(_task_id, _ctx), do: {:error, :not_found} + + @impl Phantom.A2A.Agent + def cancel_task(_task, _ctx), do: {:error, :not_cancelable} + + @impl Phantom.A2A.Agent + def list_tasks(_filter, _ctx), + do: {:error, Phantom.A2A.Error.unsupported_operation("Listing tasks is not supported")} + + @impl Phantom.A2A.Agent + def create_push_config(_config, _ctx), + do: {:error, Phantom.A2A.Error.push_notification_not_supported()} + + @impl Phantom.A2A.Agent + def get_push_config(_task_id, _id, _ctx), + do: {:error, Phantom.A2A.Error.push_notification_not_supported()} + + @impl Phantom.A2A.Agent + def list_push_configs(_task_id, _ctx), + do: {:error, Phantom.A2A.Error.push_notification_not_supported()} + + @impl Phantom.A2A.Agent + def delete_push_config(_task_id, _id, _ctx), + do: {:error, Phantom.A2A.Error.push_notification_not_supported()} + + @impl Phantom.A2A.Agent + def push_configs(_task_ref), do: [] + + @impl Phantom.A2A.Agent + def deliver_push(config, event, task_ref), + do: Phantom.A2A.Push.deliver_async(config, event, task_ref) + + @impl Phantom.A2A.Agent + def extended_card(card, _ctx), do: {:ok, card} + + @impl Phantom.A2A.Agent + def card(card, _route), do: card + + @impl Phantom.A2A.Agent + def follows_mcp_tasks?, do: false + + defoverridable card: 2, + follows_mcp_tasks?: 0, + extended_card: 2, + get_task: 2, + cancel_task: 2, + list_tasks: 2, + create_push_config: 2, + get_push_config: 3, + list_push_configs: 2, + delete_push_config: 3, + push_configs: 1, + deliver_push: 3 + + @before_compile Phantom.A2A.Agent + @phantom_a2a_opts unquote(opts) + Module.register_attribute(__MODULE__, :phantom_a2a_skills, accumulate: true) + end + end + + @doc "List a skill on the agent's card. See the moduledoc." + defmacro skill(id, opts \\ []) do + quote generated: true do + description = Module.delete_attribute(__MODULE__, :description) + + @phantom_a2a_skills Phantom.A2A.AgentSkill.build( + unquote(id), + Keyword.put_new(unquote(opts), :description, description) + ) + end + end + + defmacro __before_compile__(env) do + opts = Module.get_attribute(env.module, :phantom_a2a_opts) + + for key <- [:name, :description], not is_binary(opts[key]) do + raise ArgumentError, "use Phantom.A2A.Agent requires a #{inspect(key)} string" + end + + quote generated: true do + @doc false + def __phantom_a2a__(:push_notifications), + do: Keyword.get(@phantom_a2a_opts, :push_notifications, false) + + def __phantom_a2a__(:extended_card), + do: Keyword.get(@phantom_a2a_opts, :extended_card, false) + + def __phantom_a2a__(:card) do + opts = @phantom_a2a_opts + + %Phantom.A2A.Card{ + name: opts[:name], + description: opts[:description], + version: opts[:version], + documentation_url: opts[:documentation_url], + icon_url: opts[:icon_url], + default_input_modes: Keyword.get(opts, :input_modes, ["text/plain"]), + default_output_modes: Keyword.get(opts, :output_modes, ["text/plain"]), + skills: Enum.reverse(@phantom_a2a_skills), + extensions: Keyword.get(opts, :extensions, []) + } + end + end + end +end diff --git a/lib/phantom/a2a/agent_skill.ex b/lib/phantom/a2a/agent_skill.ex new file mode 100644 index 0000000..77eba54 --- /dev/null +++ b/lib/phantom/a2a/agent_skill.ex @@ -0,0 +1,73 @@ +defmodule Phantom.A2A.AgentSkill do + @moduledoc """ + Something an A2A agent can do, listed on its card. Declare skills with the + `skill` macro in a `Phantom.A2A.Agent`. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + @enforce_keys [:id, :name, :description] + defstruct [:id, :name, :description, :input_modes, :output_modes, tags: [], examples: []] + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + description: String.t(), + input_modes: [String.t()] | nil, + output_modes: [String.t()] | nil, + tags: [String.t()], + examples: [String.t()] + } + + @doc false + def build(id, attrs) do + id = to_string(id) + attrs = Map.new(attrs) + + if not is_binary(attrs[:description]) do + raise ArgumentError, + "skill #{inspect(id)} needs a description, from @description or the :description option" + end + + %__MODULE__{ + id: id, + name: attrs[:name] || id |> String.replace("_", " ") |> String.capitalize(), + description: String.trim(attrs.description), + tags: Map.get(attrs, :tags, []), + examples: Map.get(attrs, :examples, []), + input_modes: attrs[:input_modes], + output_modes: attrs[:output_modes] + } + end + + @doc false + def from_json(%{"id" => id, "name" => name, "description" => description} = json) + when is_binary(id) do + {:ok, + %__MODULE__{ + id: id, + name: name, + description: description, + tags: json["tags"] || [], + examples: json["examples"] || [], + input_modes: json["inputModes"], + output_modes: json["outputModes"] + }} + end + + def from_json(_json), do: {:error, "skill needs an id, a name, and a description"} + + @doc false + def to_json(%__MODULE__{} = skill) do + remove_nils(%{ + "id" => skill.id, + "name" => skill.name, + "description" => skill.description, + # The field is required and can't be empty, so an untagged skill has its ID. + "tags" => if(skill.tags == [], do: [skill.id], else: skill.tags), + "examples" => if(skill.examples != [], do: skill.examples), + "inputModes" => skill.input_modes, + "outputModes" => skill.output_modes + }) + end +end diff --git a/lib/phantom/a2a/artifact.ex b/lib/phantom/a2a/artifact.ex new file mode 100644 index 0000000..6535d96 --- /dev/null +++ b/lib/phantom/a2a/artifact.ex @@ -0,0 +1,84 @@ +defmodule Phantom.A2A.Artifact do + @moduledoc """ + Output an A2A task produces, such as a report or a file. + + Send artifacts while a task runs with `update/3` and `Phantom.A2A.publish/2`, + or include them in the `Phantom.A2A.Task` the agent returns. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.{ArtifactUpdate, Part} + + @enforce_keys [:artifact_id] + defstruct [:artifact_id, :name, :description, :metadata, parts: [], extensions: []] + + @type t :: %__MODULE__{ + artifact_id: String.t(), + name: String.t() | nil, + description: String.t() | nil, + metadata: map() | nil, + parts: [Part.t()], + extensions: [String.t()] + } + + @doc """ + An artifact with the given ID and parts. `opts` sets `:name`, + `:description`, `:metadata`, or `:extensions`. + """ + def new(artifact_id, parts, opts \\ []) when is_binary(artifact_id) and is_list(parts) do + struct!(__MODULE__, [artifact_id: artifact_id, parts: parts] ++ opts) + end + + @doc """ + An event that adds or extends an artifact. + + With `append: true`, the parts are added to the artifact already sent with + this ID. Otherwise the artifact is added, or replaces one with the same ID. + + ## Options + + * `:append` - add the parts to an existing artifact (default `false`) + * `:last_chunk` - this is the artifact's final chunk (default `false`) + * any `new/3` option + """ + def update(artifact_id, parts, opts \\ []) do + {update_opts, artifact_opts} = Keyword.split(opts, [:append, :last_chunk, :metadata]) + + %ArtifactUpdate{ + artifact: new(artifact_id, parts, artifact_opts), + append: Keyword.get(update_opts, :append, false), + last_chunk: Keyword.get(update_opts, :last_chunk, false), + metadata: update_opts[:metadata] + } + end + + @doc false + def from_json(%{"artifactId" => id, "parts" => [_ | _] = parts} = json) when is_binary(id) do + with {:ok, parts} <- Part.list_from_json(parts) do + {:ok, + %__MODULE__{ + artifact_id: id, + name: json["name"], + description: json["description"], + metadata: json["metadata"], + parts: parts, + extensions: json["extensions"] || [] + }} + end + end + + def from_json(_json), do: {:error, "artifact needs an artifactId and parts"} + + @doc false + def to_json(%__MODULE__{} = artifact) do + remove_nils(%{ + "artifactId" => artifact.artifact_id, + "name" => artifact.name, + "description" => artifact.description, + "metadata" => artifact.metadata, + "parts" => Enum.map(artifact.parts, &Part.to_json/1), + "extensions" => if(artifact.extensions != [], do: artifact.extensions) + }) + end +end diff --git a/lib/phantom/a2a/artifact_update.ex b/lib/phantom/a2a/artifact_update.ex new file mode 100644 index 0000000..01b71c3 --- /dev/null +++ b/lib/phantom/a2a/artifact_update.ex @@ -0,0 +1,60 @@ +defmodule Phantom.A2A.ArtifactUpdate do + @moduledoc """ + An event that adds or extends a task's artifact. Build one with + `Phantom.A2A.Artifact.update/3` and send it with `Phantom.A2A.publish/2`. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.Artifact + + @enforce_keys [:artifact] + defstruct [ + :task_id, + :context_id, + :artifact, + :metadata, + :sequence, + append: false, + last_chunk: false + ] + + @type t :: %__MODULE__{ + task_id: String.t() | nil, + context_id: String.t() | nil, + artifact: Artifact.t(), + metadata: map() | nil, + sequence: pos_integer() | nil, + append: boolean(), + last_chunk: boolean() + } + + @doc false + def from_json(%{"artifact" => artifact} = json) do + with {:ok, artifact} <- Artifact.from_json(artifact) do + {:ok, + %__MODULE__{ + task_id: json["taskId"], + context_id: json["contextId"], + artifact: artifact, + metadata: json["metadata"], + append: json["append"] == true, + last_chunk: json["lastChunk"] == true + }} + end + end + + def from_json(_json), do: {:error, "artifact update needs an artifact"} + + @doc false + def to_json(%__MODULE__{} = update) do + remove_nils(%{ + "taskId" => update.task_id, + "contextId" => update.context_id, + "artifact" => Artifact.to_json(update.artifact), + "append" => update.append, + "lastChunk" => update.last_chunk, + "metadata" => update.metadata + }) + end +end diff --git a/lib/phantom/a2a/bridge.ex b/lib/phantom/a2a/bridge.ex new file mode 100644 index 0000000..a1d720a --- /dev/null +++ b/lib/phantom/a2a/bridge.ex @@ -0,0 +1,387 @@ +defmodule Phantom.A2A.Bridge do + @moduledoc """ + Serve a router's MCP tools as an A2A agent. + + agent "tools", Phantom.A2A.Bridge, tools: ~w[search_studies create_question] + + The agent's card lists each tool as a skill, and an extension with the + tools' input schemas: + + https://github.com/dbernheisel/phantom_mcp/a2a/extensions/mcp-tools/v1 + + A client calls a tool with a message whose data part names it: + + {"data": {"tool": "search_studies", "arguments": {"query": "churn"}}} + + The tool runs with `Phantom.Router.call_tool/4`, with the session's + assigns and allow-list, and its arguments are validated as for `tools/call`. + The agent answers with a message of the tool's result: text as text parts, + structured content as a data part, images and audio as bytes, and resource + links as URLs. A tool error sets `"mcp/isError": true` in the message's + metadata. + + A message that names no tool goes to the `:fallback` agent, or gets a reply + that lists the tools. Tasks the fallback answers with are fetched, canceled, + and continued through it. + + The card is public and has no session, so it lists every tool `:tools` allows, + whatever `Phantom.Session.allow_tools/2` would later permit. Set `:tools` to + what the public may see. + + ## Options + + * `:tools` - the names of the tools to serve (default: every tool) + * `:name`, `:description` - for the card (default: the router's name and + instructions) + * `:fallback` - a `Phantom.A2A.Agent` for messages that name no tool + + ## Long-running tools + + When the router implements `c:Phantom.Router.get_task/2`, the bridge calls + tools with the MCP Tasks extension, so a tool may answer with a + `Phantom.Tasks`. The client gets an A2A task with the same ID, in a context + of its own. MCP tasks can't keep a client's context, so a tool call that names + a `contextId` is refused on such a router: + + * the task's status, `status_message`, and `error` become the A2A status + and its message, and a completed `result` becomes a `"result"` artifact; + * `input_requests` become an `input_required` status whose message has a + data part `{"inputRequests": ...}`. The client answers with a message on + the task with a data part `{"inputResponses": {key: response}}`, which + goes to `c:Phantom.Router.update_task/3`; + * `GetTask` and `CancelTask` go to `c:Phantom.Router.get_task/2` and + `c:Phantom.Router.cancel_task/2`; + * after each `Phantom.Tracker.notify_task_updated/1` for the task, a + waiting `SendMessage` or a stream gets the task again. This needs + `Phantom.Tracker` in your supervision tree. + + Tools that answer from another process (`{:noreply, session}`) block the + request until they respond. A tool that elicits without a task gets + `:not_supported`, as there is no MCP client to ask. + """ + + use Phantom.A2A.Agent, + name: "MCP tools", + description: "Calls MCP tools", + input_modes: ~w[application/json text/plain], + output_modes: ~w[application/json text/plain] + + require Logger + + alias Phantom.A2A.{AgentSkill, Artifact, Error, Message, Part, Task, TaskStatus} + + @extension "https://github.com/dbernheisel/phantom_mcp/a2a/extensions/mcp-tools/v1" + + @doc "The URI of the extension that lists the tools' schemas" + def extension, do: @extension + + @impl Phantom.A2A.Agent + def card(card, %{router: router, opts: opts}) do + info = router.__phantom__(:info) + tools = tools(router, nil, opts) + instructions = if info.instructions in [nil, ""], do: nil, else: info.instructions + + %{ + card + | name: opts[:name] || info.name, + description: String.trim(opts[:description] || instructions || "Tools of #{info.name}"), + version: info.version, + skills: Enum.map(tools, &to_skill/1), + extensions: [ + %{ + uri: @extension, + description: ~s(Call a tool with a data part: {"tool": name, "arguments": {...}}), + params: %{"tools" => Enum.map(tools, &tool_json/1)} + } + ] + } + end + + @impl Phantom.A2A.Agent + def handle_message(%Message{task_id: task_id} = message, ctx) when is_binary(task_id) do + case {mcp_task(task_id, ctx), ctx.agent.opts[:fallback]} do + {:error, fallback} when not is_nil(fallback) -> fallback.handle_message(message, ctx) + _mcp_task -> continue_task(task_id, message, ctx) + end + end + + def handle_message(message, ctx) do + case tool_call(message) do + {name, arguments} -> call(name, arguments, ctx) + nil -> no_tool(message, ctx) + end + end + + defp tool_call(%Message{parts: parts}) do + Enum.find_value(parts, fn + %Part{data: %{"tool" => name} = data} when is_binary(name) -> + {name, data["arguments"] || %{}} + + _part -> + nil + end) + end + + defp call(_name, arguments, ctx) when not is_map(arguments), + do: {:error, Error.invalid_params("arguments must be an object"), ctx} + + defp call(name, arguments, ctx) do + router = ctx.session.router + tasks? = Phantom.Router.tasks_enabled?(router) + + cond do + not Enum.any?(tools(router, ctx.session, ctx.agent.opts), &(&1.name == name)) -> + {:error, Error.invalid_params("Unknown tool: #{name}"), ctx} + + tasks? and is_binary(ctx.requested_context_id) -> + {:error, Error.invalid_params("This agent can't use a contextId from the client"), ctx} + + true -> + run_tool(name, arguments, tasks?, ctx) + end + end + + defp run_tool(name, arguments, tasks?, ctx) do + case Phantom.Router.call_tool(ctx.session, name, arguments, tasks: tasks?) do + {:ok, %Phantom.Tasks{} = task} -> + {:reply, to_a2a(task), ctx} + + {:ok, result} -> + {:reply, reply(result), ctx} + + {:error, :timeout} -> + {:error, %Error{code: -32603, message: "Tool #{name} timed out"}, ctx} + + {:error, error} -> + {:error, error(error), ctx} + end + end + + ## MCP tasks + + @impl Phantom.A2A.Agent + def follows_mcp_tasks?, do: true + + @impl Phantom.A2A.Agent + def get_task(task_id, ctx) do + case mcp_task(task_id, ctx) do + {:ok, task} -> {:ok, to_a2a(task)} + :error -> fallback_task(task_id, ctx) + end + end + + defp fallback_task(task_id, ctx) do + case ctx.agent.opts[:fallback] do + nil -> {:error, :not_found} + fallback -> fallback.get_task(task_id, ctx) + end + end + + @impl Phantom.A2A.Agent + def cancel_task(%Task{id: task_id} = task, ctx) do + case {mcp_task(task_id, ctx), ctx.agent.opts[:fallback]} do + {:error, fallback} when not is_nil(fallback) -> fallback.cancel_task(task, ctx) + _mcp_task -> cancel_mcp_task(task_id, ctx) + end + end + + defp cancel_mcp_task(task_id, ctx) do + router = ctx.session.router + + with {:ok, task} <- mcp_task(task_id, ctx), + true <- function_exported?(router, :cancel_task, 2), + :ok <- router.cancel_task(task, ctx.session) do + get_task(task_id, ctx) + else + _not_cancelable -> {:error, :not_cancelable} + end + end + + defp mcp_task(task_id, ctx) do + router = ctx.session.router + + with true <- Phantom.Router.tasks_enabled?(router), + {:ok, %Phantom.Tasks{} = task} <- router.get_task(task_id, ctx.session) do + {:ok, task} + else + _not_found -> :error + end + end + + # The client answers a task's input requests with a data part: + # {"inputResponses": {key: response}}. + defp continue_task(task_id, message, ctx) do + router = ctx.session.router + + responses = + Enum.find_value(message.parts, %{}, fn + %Part{data: %{"inputResponses" => %{} = responses}} -> responses + _part -> nil + end) + + with {:ok, task} <- mcp_task(task_id, ctx), + outstanding when map_size(outstanding) > 0 <- + Map.take(responses, Map.keys(task.input_requests)), + true <- function_exported?(router, :update_task, 3), + :ok <- router.update_task(task, outstanding, ctx.session), + {:ok, task} <- mcp_task(task_id, ctx) do + {:reply, to_a2a(task), ctx} + else + {:error, error} -> + {:error, error(error), ctx} + + _nothing_to_answer -> + keys = + case mcp_task(task_id, ctx) do + {:ok, task} -> Map.keys(task.input_requests) + :error -> [] + end + + message = + ~s(Answer the task's input requests with a data part {"inputResponses": {...}}: ) <> + Enum.join(keys, ", ") + + {:error, Error.invalid_params(message), ctx} + end + end + + @states %{ + working: :working, + input_required: :input_required, + completed: :completed, + failed: :failed, + cancelled: :canceled + } + + # An MCP task as an A2A task. MCP tasks have no context, so each is its own. + defp to_a2a(%Phantom.Tasks{} = task) do + %Task{ + id: task.id, + context_id: task.id, + status: %TaskStatus{ + state: Map.fetch!(@states, task.status), + message: status_message(task), + timestamp: task.last_updated_at + }, + artifacts: artifacts(task) + } + end + + defp status_message(task) do + error = if task.error, do: json(task.error)["message"] + + input_requests = + if task.input_requests != %{}, + do: [Part.data(%{"inputRequests" => json(task.input_requests)})], + else: [] + + texts = for text <- [task.status_message, error], is_binary(text), do: Part.text(text) + + case texts ++ input_requests do + [] -> nil + parts -> Message.agent(parts, task_id: task.id, context_id: task.id) + end + end + + defp artifacts(%Phantom.Tasks{status: :completed, result: %{} = result}) do + {parts, _error?} = result_parts(result) + [Artifact.new("result", parts)] + end + + defp artifacts(_task), do: [] + + defp no_tool(message, ctx) do + case ctx.agent.opts[:fallback] do + nil -> + names = + Enum.map_join(tools(ctx.session.router, ctx.session, ctx.agent.opts), ", ", & &1.name) + + text = + ~s(Send a data part naming a tool, such as {"tool": "name", "arguments": {}}. ) <> + "Tools: #{names}" + + {:reply, Message.agent(text), ctx} + + fallback -> + fallback.handle_message(message, ctx) + end + end + + defp tools(router, session, opts) do + if not Phantom.Cache.initialized?(router), do: Phantom.Cache.register(router) + available = Phantom.Cache.list(session, router, :tools) + + case opts[:tools] do + nil -> available + names -> Enum.filter(available, &(&1.name in names)) + end + end + + defp to_skill(tool) do + %AgentSkill{ + id: tool.name, + name: tool.name |> String.replace("_", " ") |> String.capitalize(), + description: tool.description || tool.name, + tags: ["mcp-tool"] + } + end + + # The tool as `tools/list` lists it, with string keys. + defp tool_json(tool), do: tool |> Phantom.Tool.to_json() |> JSON.encode!() |> JSON.decode!() + + defp reply(result) do + {parts, error?} = result_parts(result) + Message.agent(parts, metadata: if(error?, do: %{"mcp/isError" => true})) + end + + # A `CallToolResult` as parts, and whether it is an error. + defp result_parts(result) do + result = json(result) + content = Enum.flat_map(result["content"] || [], &part/1) + + structured = + if Map.has_key?(result, "structuredContent"), + do: [Part.data(result["structuredContent"])], + else: [] + + # A message or artifact must have a part, even if the tool said nothing. + parts = + case content ++ structured do + [] -> [Part.text("")] + parts -> parts + end + + {parts, result["isError"] == true} + end + + defp json(term), do: term |> JSON.encode!() |> JSON.decode!() + + defp part(%{"type" => "text", "text" => text}), do: [Part.text(text)] + + defp part(%{"type" => type, "data" => data} = content) when type in ["image", "audio"], + do: [Part.raw(Base.decode64!(data), media_type: content["mimeType"])] + + defp part(%{"type" => "resource_link", "uri" => uri} = link), + do: [Part.url(uri, filename: link["name"], media_type: link["mimeType"])] + + defp part(%{"type" => "resource", "resource" => %{"text" => text} = resource}), + do: [Part.text(text, media_type: resource["mimeType"], metadata: %{"uri" => resource["uri"]})] + + defp part(%{"type" => "resource", "resource" => %{"blob" => blob} = resource}), + do: [ + Part.raw(Base.decode64!(blob), + media_type: resource["mimeType"], + metadata: %{"uri" => resource["uri"]} + ) + ] + + defp part(_content), do: [] + + defp error(%{code: -32602, message: message}), do: Error.invalid_params(message) + defp error(%{code: code, message: message}), do: %Error{code: code, message: message} + + defp error(other) do + Logger.warning("A2A bridge tool call failed: #{inspect(other)}") + Error.internal_error() + end +end diff --git a/lib/phantom/a2a/card.ex b/lib/phantom/a2a/card.ex new file mode 100644 index 0000000..73229b0 --- /dev/null +++ b/lib/phantom/a2a/card.ex @@ -0,0 +1,137 @@ +defmodule Phantom.A2A.Card do + @moduledoc """ + An A2A Agent Card: what an agent is, where to reach it, and what it can do. + + Phantom builds each agent's card from its `Phantom.A2A.Agent` module and the + router's `:a2a` options, and fills in the URLs from the request. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.AgentSkill + + defstruct [ + :name, + :description, + :version, + :provider, + :documentation_url, + :icon_url, + :security_schemes, + :security_requirements, + supported_interfaces: [], + capabilities: %{}, + default_input_modes: [], + default_output_modes: [], + skills: [], + extensions: [] + ] + + @type t :: %__MODULE__{ + name: String.t(), + description: String.t(), + version: String.t(), + provider: map() | nil, + documentation_url: String.t() | nil, + icon_url: String.t() | nil, + security_schemes: map() | nil, + security_requirements: [map()] | nil, + supported_interfaces: [map()], + capabilities: map(), + default_input_modes: [String.t()], + default_output_modes: [String.t()], + skills: [AgentSkill.t()], + extensions: [extension()] + } + + @typedoc "A protocol extension the agent supports" + @type extension :: %{ + required(:uri) => String.t(), + optional(:description) => String.t(), + optional(:required) => boolean(), + optional(:params) => map() + } + + @doc false + def from_json(%{"name" => name} = json) when is_binary(name) do + capabilities = json["capabilities"] || %{} + + with :ok <- check_shape(json, capabilities), + {:ok, skills} <- + Phantom.A2A.Part.list_from_json(json["skills"] || [], &AgentSkill.from_json/1) do + {:ok, + %__MODULE__{ + name: name, + description: json["description"], + version: json["version"], + provider: json["provider"], + documentation_url: json["documentationUrl"], + icon_url: json["iconUrl"], + security_schemes: json["securitySchemes"], + security_requirements: json["securityRequirements"], + supported_interfaces: json["supportedInterfaces"] || [], + capabilities: capabilities, + default_input_modes: json["defaultInputModes"] || [], + default_output_modes: json["defaultOutputModes"] || [], + skills: skills, + extensions: Enum.map(capabilities["extensions"] || [], &extension_from_json/1) + }} + end + end + + def from_json(_json), do: {:error, "agent card needs a name"} + + defp check_shape(json, capabilities) do + valid? = + is_list(json["supportedInterfaces"] || []) and is_map(capabilities) and + is_list(capabilities["extensions"] || []) and + Enum.all?(capabilities["extensions"] || [], &is_map/1) + + if valid?, do: :ok, else: {:error, "agent card has a field of the wrong type"} + end + + defp extension_from_json(json) do + remove_nils(%{ + uri: json["uri"], + description: json["description"], + required: if(json["required"], do: true), + params: json["params"] + }) + end + + @doc false + def extension_to_json(extension) do + remove_nils(%{ + "uri" => extension.uri, + "description" => extension[:description], + "required" => if(extension[:required], do: true), + "params" => extension[:params] + }) + end + + @doc false + def to_json(%__MODULE__{} = card) do + remove_nils(%{ + "name" => card.name, + "description" => card.description, + "version" => card.version, + "provider" => card.provider, + "documentationUrl" => card.documentation_url, + "iconUrl" => card.icon_url, + "securitySchemes" => card.security_schemes, + "securityRequirements" => card.security_requirements, + "supportedInterfaces" => card.supported_interfaces, + "capabilities" => card.capabilities, + "defaultInputModes" => card.default_input_modes, + "defaultOutputModes" => card.default_output_modes, + "skills" => Enum.map(skills(card), &AgentSkill.to_json/1) + }) + end + + # A card must list a skill, so an agent that declares none offers itself. + defp skills(%__MODULE__{skills: []} = card) do + [%AgentSkill{id: "default", name: card.name, description: card.description}] + end + + defp skills(%__MODULE__{skills: skills}), do: skills +end diff --git a/lib/phantom/a2a/client.ex b/lib/phantom/a2a/client.ex new file mode 100644 index 0000000..b22f801 --- /dev/null +++ b/lib/phantom/a2a/client.ex @@ -0,0 +1,603 @@ +defmodule Phantom.A2A.Client do + @moduledoc """ + Call A2A agents: fetch an agent's card, send messages, follow tasks, and + manage push notifications, over the JSON-RPC or HTTP+JSON binding. + + Requires the optional `{:req, "~> 0.5"}` dependency. + + {:ok, client} = Phantom.A2A.Client.new("https://agents.example", headers: [{"authorization", "Bearer " <> token}]) + + {:ok, %Phantom.A2A.Message{} = reply} = Phantom.A2A.Client.send_message(client, "Hello") + + {:ok, stream} = Phantom.A2A.Client.stream_message(client, "Review study 42") + for event <- stream, do: IO.inspect(event) + + Results are the structs Phantom serves agents with: `Phantom.A2A.Message`, + `Phantom.A2A.Task`, `Phantom.A2A.StatusUpdate`, `Phantom.A2A.ArtifactUpdate`, + `Phantom.A2A.PushConfig`, and `Phantom.A2A.Card`. Errors the agent returns + are `{:error, %Phantom.A2A.Error{}}`. + + The client checks the card before optional operations: it refuses to stream + when the card doesn't advertise streaming, and likewise push notifications + and extended cards, without asking the agent. + + ## Options + + * `:bindings` - the bindings the client may use (default + `[:jsonrpc, :http_json]`). A2A has clients take the card's earliest + interface they support, so the card's order decides between them. + * `:headers` - headers for every request, such as `authorization`. When + `new/2` fetches the card, it refuses a card whose interface is on another + origin, as these headers would go there. + * `:cross_origin_headers` - set `true` to send `:headers` to an interface on + another origin + * `:extensions` - extension URIs to request in the `A2A-Extensions` header + * `:req_options` - options for `Req.new/1`, such as `:receive_timeout`, or + `plug:` to call a Plug instead of the network + """ + + @compile {:no_warn_undefined, Req} + + alias Phantom.A2A.{Card, Error, Message, PushConfig, Task, TaskStatus} + + defstruct [:url, :binding, :card, :tenant, headers: [], extensions: [], req_options: []] + + @type t :: %__MODULE__{ + url: String.t(), + binding: :jsonrpc | :http_json, + card: Card.t(), + tenant: String.t() | nil, + headers: [{String.t(), String.t()}], + extensions: [String.t()], + req_options: keyword() + } + + @bindings %{"JSONRPC" => :jsonrpc, "HTTP+JSON" => :http_json} + + @doc """ + Fetch an agent card. Give the card's URL, or an origin to read + `/.well-known/agent-card.json` from. Takes the `:headers` and + `:req_options` options. + """ + def get_card(url, opts \\ []) do + url = + if String.ends_with?(url, ".json"), + do: url, + else: String.trim_trailing(url, "/") <> "/.well-known/agent-card.json" + + with {:ok, %{status: 200, body: body}} <- http(:get, url, nil, opts), + {:ok, json} <- JSON.decode(body) do + Card.from_json(json) + else + {:ok, %{status: status, body: body}} -> {:error, error_from_body(body, status)} + error -> error + end + end + + @doc """ + A client for an agent, from its card or a URL to fetch the card from. See the + moduledoc for options. + """ + def new(card_or_url, opts \\ []) + + def new(%Card{} = card, opts) do + bindings = Keyword.get(opts, :bindings, [:jsonrpc, :http_json]) + + case Enum.find_value(card.supported_interfaces, &usable_interface(&1, bindings)) do + {binding, url, tenant} -> + {:ok, + %__MODULE__{ + url: url, + binding: binding, + card: card, + tenant: tenant, + headers: Keyword.get(opts, :headers, []), + extensions: Keyword.get(opts, :extensions, []), + req_options: Keyword.get(opts, :req_options, []) + }} + + nil -> + {:error, :no_supported_interface} + end + end + + def new(url, opts) when is_binary(url) do + with {:ok, card} <- get_card(url, opts), + {:ok, client} <- new(card, opts), + :ok <- check_origin(client, url, opts), + do: {:ok, client} + end + + defp usable_interface(%{"url" => url, "protocolBinding" => name} = interface, bindings) + when is_binary(url) do + binding = @bindings[name] + + if binding in bindings and version_one?(interface["protocolVersion"]), + do: {binding, url, tenant(interface["tenant"])} + end + + defp usable_interface(_interface, _bindings), do: nil + + defp version_one?(nil), do: true + defp version_one?(version) when is_binary(version), do: String.starts_with?(version, "1.") + defp version_one?(_version), do: false + + defp tenant(tenant) when is_binary(tenant) and tenant != "", do: tenant + defp tenant(_tenant), do: nil + + defp check_origin(%__MODULE__{headers: []}, _card_url, _opts), do: :ok + + defp check_origin(%__MODULE__{url: url}, card_url, opts) do + if opts[:cross_origin_headers] == true or origin(url) == origin(card_url), + do: :ok, + else: {:error, {:cross_origin_interface, url}} + end + + defp origin(url) do + %URI{scheme: scheme, host: host, port: port} = URI.parse(url) + {scheme, host, port} + end + + @doc """ + Send a message, given as text or a `Phantom.A2A.Message`. Returns the agent's + `Phantom.A2A.Message` or `Phantom.A2A.Task`. + + ## Options + + * `:return_immediately` - answer with the task before it finishes + * `:history_length` - how much of the task's history to return + * `:accepted_output_modes` - the media types the client accepts + * `:push_config` - a `Phantom.A2A.PushConfig` to register for the task + * `:metadata` - the request's metadata + """ + def send_message(client, message, opts \\ []) do + with {:ok, result} <- request(client, "SendMessage", message_params(message, opts)) do + Phantom.A2A.event_from_json(result) + end + end + + @doc """ + Send a message and stream the agent's answer: a `Phantom.A2A.Message`, or + the task and then each event, until it finishes or needs input. It stays + open while the task waits on authentication. Returns + `{:ok, stream}` once the agent starts answering. A `Phantom.A2A.Error` in the + stream ends it. Consume the stream in the process that called this. Takes + the options of `send_message/3`. + """ + def stream_message(client, message, opts \\ []) do + with :ok <- + require_capability( + client, + "streaming", + Error.unsupported_operation("The agent doesn't stream") + ), + do: stream(client, "SendStreamingMessage", message_params(message, opts)) + end + + @doc "Fetch a task. Takes `:history_length`." + def get_task(client, task_id, opts \\ []) do + params = compact(%{"id" => task_id, "historyLength" => opts[:history_length]}) + with {:ok, json} <- request(client, "GetTask", params), do: Task.from_json(json) + end + + @doc """ + List tasks. Returns a page: `%{tasks: tasks, next_page_token: token | nil, + page_size: size, total_size: size}`. + + ## Options + + * `:context_id`, `:status` (such as `:working`), `:page_size`, + `:page_token`, `:history_length`, `:include_artifacts`, and + `:status_timestamp_after` (a `DateTime`) + """ + def list_tasks(client, opts \\ []) do + params = + compact(%{ + "contextId" => opts[:context_id], + "status" => + opts[:status] && TaskStatus.to_json(%TaskStatus{state: opts[:status]})["state"], + "pageSize" => opts[:page_size], + "pageToken" => opts[:page_token], + "historyLength" => opts[:history_length], + "includeArtifacts" => opts[:include_artifacts], + "statusTimestampAfter" => + opts[:status_timestamp_after] && DateTime.to_iso8601(opts[:status_timestamp_after]) + }) + + with {:ok, json} <- request(client, "ListTasks", params), + {:ok, tasks} <- Phantom.A2A.Part.list_from_json(json["tasks"] || [], &Task.from_json/1) do + {:ok, + %{ + tasks: tasks, + next_page_token: + if(json["nextPageToken"] in [nil, ""], do: nil, else: json["nextPageToken"]), + page_size: json["pageSize"], + total_size: json["totalSize"] + }} + end + end + + @doc "Cancel a task" + def cancel_task(client, task_id) do + with {:ok, json} <- request(client, "CancelTask", %{"id" => task_id}), + do: Task.from_json(json) + end + + @doc """ + Stream a task: its current state, then each event, until it finishes or + needs input, as `stream_message/3` does. See `stream_message/3`. + """ + def subscribe(client, task_id) do + with :ok <- + require_capability( + client, + "streaming", + Error.unsupported_operation("The agent doesn't stream") + ), + do: subscribe_stream(client, %{"id" => task_id}) + end + + # The method tables name POST and the proto names GET, so servers may route + # either. + defp subscribe_stream(client, params) do + with {:error, {:http_status, 405}} <- stream(client, "SubscribeToTask", params), + do: stream(client, "SubscribeToTask:get", params) + end + + @doc "Register a webhook for a task's updates" + def create_push_config(client, %PushConfig{} = config) do + with :ok <- require_push(client), + {:ok, json} <- + request(client, "CreateTaskPushNotificationConfig", PushConfig.to_json(config)), + do: PushConfig.from_json(json) + end + + @doc "Fetch a task's push notification config" + def get_push_config(client, task_id, id) do + with :ok <- require_push(client), + {:ok, json} <- + request(client, "GetTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}), + do: PushConfig.from_json(json) + end + + @doc "List a task's push notification configs" + def list_push_configs(client, task_id) do + with :ok <- require_push(client), + {:ok, json} <- request(client, "ListTaskPushNotificationConfigs", %{"taskId" => task_id}), + do: Phantom.A2A.Part.list_from_json(json["configs"] || [], &PushConfig.from_json/1) + end + + @doc "Delete a task's push notification config" + def delete_push_config(client, task_id, id) do + with :ok <- require_push(client), + {:ok, _result} <- + request(client, "DeleteTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}), + do: :ok + end + + @doc "Fetch the extended card the agent shows this client" + def get_extended_card(client) do + with :ok <- + require_capability( + client, + "extendedAgentCard", + Error.unsupported_operation("The agent has no extended card") + ), + {:ok, json} <- request(client, "GetExtendedAgentCard", %{}), + do: Card.from_json(json) + end + + defp message_params(text, opts) when is_binary(text), + do: message_params(Message.user(text), opts) + + defp message_params(%Message{} = message, opts) do + configuration = + compact(%{ + "returnImmediately" => opts[:return_immediately], + "historyLength" => opts[:history_length], + "acceptedOutputModes" => opts[:accepted_output_modes], + "taskPushNotificationConfig" => + opts[:push_config] && PushConfig.to_json(opts[:push_config]) + }) + + compact(%{ + "message" => Message.to_json(message), + "configuration" => if(configuration != %{}, do: configuration), + "metadata" => opts[:metadata] + }) + end + + defp compact(map), do: for({key, value} <- map, not is_nil(value), into: %{}, do: {key, value}) + + defp require_push(client), + do: require_capability(client, "pushNotifications", Error.push_notification_not_supported()) + + defp require_capability(%__MODULE__{card: card}, capability, error) do + if card.capabilities[capability] == true, do: :ok, else: {:error, error} + end + + ## Transport + + defp request(client, method, params) do + {verb, url, body, query} = http_request(client, method, params) + + case http(verb, url, body, + query: query, + headers: headers(client), + req_options: client.req_options + ) do + {:ok, %{status: status, body: body}} -> client |> decode(status, body) |> check_result() + {:error, reason} -> {:error, reason} + end + end + + # Every method answers with an object, or nothing. + defp check_result({:ok, result}) when not (is_map(result) or is_nil(result)), + do: {:error, Error.invalid_agent_response()} + + defp check_result(result), do: result + + defp decode(%{binding: :jsonrpc}, _status, body) do + case JSON.decode(body) do + {:ok, %{"result" => result}} -> {:ok, result} + {:ok, %{"error" => error}} -> {:error, Error.from_json(error)} + _invalid -> {:error, Error.invalid_agent_response()} + end + end + + defp decode(%{binding: :http_json}, status, "") when status in 200..299, do: {:ok, nil} + + defp decode(%{binding: :http_json}, status, body) when status in 200..299 do + case JSON.decode(body) do + {:ok, json} -> {:ok, json} + _invalid -> {:error, Error.invalid_agent_response()} + end + end + + defp decode(%{binding: :http_json}, status, body), do: {:error, error_from_body(body, status)} + + defp error_from_body(body, status) do + case JSON.decode(body) do + {:ok, %{"error" => %{} = error}} -> Error.from_json(error) + _other -> {:http_status, status} + end + end + + defp http_request(%{binding: :jsonrpc, url: url, tenant: tenant}, method, params) do + params = if tenant, do: Map.put(params, "tenant", tenant), else: params + + {:post, url, + %{"jsonrpc" => "2.0", "id" => UUIDv7.generate(), "method" => method, "params" => params}, []} + end + + defp http_request(%{binding: :http_json, url: url, tenant: tenant}, method, params) do + {verb, path, body, query} = rest(method, params) + prefix = if tenant, do: "/" <> segment(tenant), else: "" + {verb, url <> prefix <> path, body, query} + end + + # The HTTP+JSON routes from the A2A protobuf. + defp rest("SendMessage", params), do: {:post, "/message:send", params, []} + defp rest("SendStreamingMessage", params), do: {:post, "/message:stream", params, []} + + defp rest("GetTask", %{"id" => id} = p), + do: {:get, "/tasks/#{segment(id)}", nil, query(p, ["historyLength"])} + + defp rest("ListTasks", params), do: {:get, "/tasks", nil, Map.to_list(params)} + defp rest("CancelTask", %{"id" => id}), do: {:post, "/tasks/#{segment(id)}:cancel", %{}, []} + + defp rest("SubscribeToTask", %{"id" => id}), + do: {:post, "/tasks/#{segment(id)}:subscribe", %{}, []} + + defp rest("SubscribeToTask:get", %{"id" => id}), + do: {:get, "/tasks/#{segment(id)}:subscribe", nil, []} + + defp rest("GetExtendedAgentCard", _params), do: {:get, "/extendedAgentCard", nil, []} + + defp rest("CreateTaskPushNotificationConfig", %{"taskId" => task_id} = config), + do: {:post, "/tasks/#{segment(task_id)}/pushNotificationConfigs", config, []} + + defp rest("GetTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}), + do: {:get, "/tasks/#{segment(task_id)}/pushNotificationConfigs/#{segment(id)}", nil, []} + + defp rest("ListTaskPushNotificationConfigs", %{"taskId" => task_id}), + do: {:get, "/tasks/#{segment(task_id)}/pushNotificationConfigs", nil, []} + + defp rest("DeleteTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}), + do: {:delete, "/tasks/#{segment(task_id)}/pushNotificationConfigs/#{segment(id)}", nil, []} + + defp segment(value), do: URI.encode(value, &URI.char_unreserved?/1) + defp query(params, keys), do: params |> Map.take(keys) |> Map.to_list() + + defp headers(client) do + extensions = + if client.extensions == [], + do: [], + else: [{"a2a-extensions", Enum.join(client.extensions, ", ")}] + + extensions ++ client.headers + end + + defp http(method, url, body, opts) do + if not Code.ensure_loaded?(Req) do + raise "Phantom.A2A.Client requires the :req dependency: {:req, \"~> 0.5\"}" + end + + headers = + [ + {"a2a-version", "1.0"}, + {"accept", "application/json, application/a2a+json, text/event-stream"} + ] ++ + if(body, do: [{"content-type", "application/json"}], else: []) ++ + Keyword.get(opts, :headers, []) + + [ + method: method, + url: url, + headers: headers, + params: opts[:query] || [], + decode_body: false, + retry: false + ] + |> Keyword.merge(if(body, do: [body: JSON.encode!(body)], else: [])) + |> Keyword.merge(Keyword.get(opts, :req_options, [])) + |> Keyword.merge(Keyword.take(opts, [:into])) + |> Req.request() + end + + ## Streaming + + # The request runs in a task that sends each chunk to the caller, so the + # stream is lazy and works with Req's `plug:` adapter too. + defp stream(client, method, params) do + {verb, url, body, query} = http_request(client, method, params) + parent = self() + ref = make_ref() + + into = fn {:data, data}, {request, response} -> + send(parent, {ref, :data, response.status, response.headers, data}) + {:cont, {request, response}} + end + + task = + Elixir.Task.async(fn -> + http(verb, url, body, + query: query, + headers: headers(client), + req_options: client.req_options, + into: into + ) + end) + + case await_chunk(ref, task) do + {:data, 200, headers, data} -> + if event_stream?(headers) do + {:ok, events(client, ref, task, data)} + else + finish_error(client, ref, task, 200, data) + end + + {:data, status, _headers, data} -> + finish_error(client, ref, task, status, data) + + {:done, {:ok, %{status: status, body: body}}} -> + decode_stream_error(client, status, body) + + {:done, {:error, reason}} -> + {:error, reason} + end + end + + defp await_chunk(ref, %Elixir.Task{ref: task_ref}) do + receive do + {^ref, :data, status, headers, data} -> + {:data, status, headers, data} + + {^task_ref, result} -> + Process.demonitor(task_ref, [:flush]) + {:done, result} + + {:DOWN, ^task_ref, :process, _pid, reason} -> + {:done, {:error, reason}} + end + end + + defp event_stream?(headers) do + Enum.any?(Map.get(headers, "content-type", []), &String.starts_with?(&1, "text/event-stream")) + end + + defp finish_error(client, ref, task, status, data) do + body = collect_body(ref, task, data) + decode_stream_error(client, status, body) + end + + defp collect_body(ref, task, acc) do + case await_chunk(ref, task) do + {:data, _status, _headers, data} -> collect_body(ref, task, acc <> data) + {:done, _result} -> acc + end + end + + defp decode_stream_error(client, status, body) do + case decode(client, status, body) do + {:error, error} -> {:error, error} + {:ok, _result} -> {:error, Error.invalid_agent_response()} + end + end + + defp events(client, ref, task, first) do + Stream.resource( + fn -> {first, false} end, + fn + :halt -> + {:halt, :halt} + + {buffer, done?} -> + case Regex.split(~r/\r\n\r\n|\n\n|\r\r/, buffer, parts: 2) do + [event, rest] -> + emit(parse_event(client, event), {rest, done?}) + + [_incomplete] when done? -> + {List.wrap(parse_event(client, buffer)), :halt} + + [_incomplete] -> + case await_chunk(ref, task) do + {:data, _status, _headers, data} -> {[], {buffer <> data, false}} + {:done, {:error, _reason}} -> {[Error.invalid_agent_response()], :halt} + {:done, _result} -> {[], {buffer, true}} + end + end + end, + fn _acc -> + Elixir.Task.shutdown(task, :brutal_kill) + flush_chunks(ref) + end + ) + end + + defp emit(%Error{} = error, _next), do: {[error], :halt} + defp emit(events, next), do: {List.wrap(events), next} + + defp flush_chunks(ref) do + receive do + {^ref, :data, _status, _headers, _data} -> flush_chunks(ref) + after + 0 -> :ok + end + end + + defp parse_event(client, event) do + data = + for line <- String.split(event, ~r/\r\n|\n|\r/), + String.starts_with?(line, "data:"), + do: line |> String.trim_leading("data:") |> String.trim_leading() + + case data do + [] -> [] + lines -> decode_event(client, Enum.join(lines, "\n")) + end + end + + defp decode_event(%{binding: :jsonrpc}, data) do + case JSON.decode(data) do + {:ok, %{"result" => result}} -> event_or_error(result) + {:ok, %{"error" => error}} -> Error.from_json(error) + _invalid -> Error.invalid_agent_response() + end + end + + defp decode_event(%{binding: :http_json}, data) do + case JSON.decode(data) do + {:ok, %{"error" => %{} = error}} -> Error.from_json(error) + {:ok, json} -> event_or_error(json) + _invalid -> Error.invalid_agent_response() + end + end + + defp event_or_error(json) do + case Phantom.A2A.event_from_json(json) do + {:ok, event} -> event + {:error, _reason} -> Error.invalid_agent_response() + end + end +end diff --git a/lib/phantom/a2a/context.ex b/lib/phantom/a2a/context.ex new file mode 100644 index 0000000..07fcb7e --- /dev/null +++ b/lib/phantom/a2a/context.ex @@ -0,0 +1,59 @@ +defmodule Phantom.A2A.Context do + @moduledoc """ + The request an A2A agent handler is answering. + + * `:session` - the `Phantom.Session` returned by `c:Phantom.Router.connect/2` + * `:assigns` - `session.assigns` + * `:agent` - `%{id: id, opts: opts, params: params, state: state}`: the + agent's path, the options of its `agent` route, and for a route with + path params, the params and the state its resolver returned + * `:message` - the client's `Phantom.A2A.Message`, for `SendMessage` + * `:task_ref` - the `Phantom.A2A.TaskRef` of the task the request is + about: the one a message creates or continues, or the one fetched, + canceled, or subscribed to. A new task must use `task_ref.task_id` as + its ID. + * `:context_id` - the message's context, or a new one + * `:requested_context_id` - the context the client named, if any + * `:tenant` - the request's `tenant`, passed through. A2A lets one + endpoint serve several tenants, such as customer organizations: a + card's interface names a tenant, and clients send it with each request. + Phantom routes on the agent path, not the tenant. It is the client's + claim: check it in `c:Phantom.Router.connect/2`, which sees it in + `conn.body_params["params"]["tenant"]`, and assign what the session may + use, such as its organization. + * `:metadata` - the request's `metadata` + * `:extensions` - the extension URIs the client named in its + `A2A-Extensions` header + * `:configuration` - the `SendMessage` configuration, as sent + """ + + alias Phantom.A2A.{Message, TaskRef} + + defstruct [ + :session, + :agent, + :message, + :task_ref, + :context_id, + :requested_context_id, + :tenant, + :metadata, + assigns: %{}, + configuration: %{}, + extensions: [] + ] + + @type t :: %__MODULE__{ + session: Phantom.Session.t(), + agent: %{id: String.t(), opts: keyword(), params: map(), state: term()}, + message: Message.t() | nil, + task_ref: TaskRef.t() | nil, + context_id: String.t() | nil, + requested_context_id: String.t() | nil, + tenant: String.t() | nil, + metadata: map() | nil, + assigns: map(), + configuration: map(), + extensions: [String.t()] + } +end diff --git a/lib/phantom/a2a/error.ex b/lib/phantom/a2a/error.ex new file mode 100644 index 0000000..5276385 --- /dev/null +++ b/lib/phantom/a2a/error.ex @@ -0,0 +1,140 @@ +defmodule Phantom.A2A.Error do + @moduledoc """ + A2A protocol errors. An agent handler can return one: + + {:error, Phantom.A2A.Error.unsupported_operation("Exports are disabled"), ctx} + """ + + @enforce_keys [:code, :message] + defstruct [:code, :message] + + @type t :: %__MODULE__{code: integer(), message: String.t()} + + @reasons %{ + -32001 => "TASK_NOT_FOUND", + -32002 => "TASK_NOT_CANCELABLE", + -32003 => "PUSH_NOTIFICATION_NOT_SUPPORTED", + -32004 => "UNSUPPORTED_OPERATION", + -32005 => "CONTENT_TYPE_NOT_SUPPORTED", + -32006 => "INVALID_AGENT_RESPONSE", + -32007 => "EXTENDED_AGENT_CARD_NOT_CONFIGURED", + -32008 => "EXTENSION_SUPPORT_REQUIRED", + -32009 => "VERSION_NOT_SUPPORTED", + -32700 => "PARSE_ERROR", + -32600 => "INVALID_REQUEST", + -32601 => "METHOD_NOT_FOUND", + -32602 => "INVALID_PARAMS" + } + + def task_not_found(task_id), do: new(-32001, "Task not found: #{task_id}") + def push_config_not_found(id), do: new(-32001, "Push notification config not found: #{id}") + def task_not_cancelable(task_id), do: new(-32002, "Task cannot be canceled: #{task_id}") + def push_notification_not_supported, do: new(-32003, "Push notifications are not supported") + def unsupported_operation(message), do: new(-32004, message) + def content_type_not_supported, do: new(-32005, "Content type not supported") + def invalid_agent_response, do: new(-32006, "Invalid agent response") + def extended_card_not_configured, do: new(-32007, "Extended agent card not configured") + def extension_support_required(uri), do: new(-32008, "Extension support required: #{uri}") + def version_not_supported(version), do: new(-32009, "A2A version not supported: #{version}") + def parse_error, do: new(-32700, "Parse error") + def invalid_request(message \\ "Invalid request"), do: new(-32600, message) + def method_not_found(method), do: new(-32601, "Method not found: #{method}") + def invalid_params(message), do: new(-32602, message) + def internal_error, do: new(-32603, "Internal error") + + defp new(code, message), do: %__MODULE__{code: code, message: message} + + @http_statuses %{ + -32001 => 404, + -32002 => 400, + -32003 => 400, + -32004 => 400, + -32005 => 400, + -32007 => 400, + -32008 => 400, + -32009 => 400, + -32700 => 400, + -32600 => 400, + -32601 => 501, + -32602 => 400 + } + + @grpc_statuses %{ + -32001 => "NOT_FOUND", + -32601 => "UNIMPLEMENTED", + -32002 => "FAILED_PRECONDITION", + -32003 => "FAILED_PRECONDITION", + -32004 => "FAILED_PRECONDITION", + -32007 => "FAILED_PRECONDITION", + -32008 => "FAILED_PRECONDITION", + -32009 => "FAILED_PRECONDITION", + -32005 => "INVALID_ARGUMENT", + -32700 => "INVALID_ARGUMENT", + -32600 => "INVALID_ARGUMENT", + -32602 => "INVALID_ARGUMENT" + } + + @grpc_by_http %{ + 400 => "INVALID_ARGUMENT", + 401 => "UNAUTHENTICATED", + 403 => "PERMISSION_DENIED", + 404 => "NOT_FOUND", + 405 => "UNIMPLEMENTED", + 413 => "INVALID_ARGUMENT", + 501 => "UNIMPLEMENTED" + } + + @doc false + # An error a server sent: a JSON-RPC error, or the `error` of a + # `google.rpc.Status`, whose `ErrorInfo` reason names the A2A error. + def from_json(%{"code" => code, "message" => message}) + when is_integer(code) and code < 0 and is_binary(message), + do: new(code, message) + + def from_json(%{"message" => message} = json) when is_binary(message) do + reason = Enum.find_value(List.wrap(json["details"]), &(is_map(&1) && &1["reason"])) + code = Enum.find_value(@reasons, -32603, fn {code, name} -> name == reason && code end) + new(code, message) + end + + def from_json(_json), do: invalid_agent_response() + + @doc false + # The HTTP status of an error on the HTTP+JSON binding. + def http_status(%__MODULE__{code: code}), do: Map.get(@http_statuses, code, 500) + + @doc false + # A `google.rpc.Status` body for the HTTP+JSON binding. An explicit HTTP + # status, such as 401 from `connect/2`, decides the gRPC status. + def to_status_json(%__MODULE__{} = error, http_status) do + status = + if http_status == http_status(error), + do: Map.get(@grpc_statuses, error.code, "INTERNAL"), + else: Map.get(@grpc_by_http, http_status, "INTERNAL") + + %{ + "error" => %{ + "code" => http_status, + "status" => status, + "message" => error.message, + "details" => to_json(error)["data"] + } + } + end + + @doc false + def to_json(%__MODULE__{} = error) do + %{ + "code" => error.code, + "message" => error.message, + "data" => [ + %{ + "@type" => "type.googleapis.com/google.rpc.ErrorInfo", + "reason" => Map.get(@reasons, error.code, "INTERNAL_ERROR"), + "domain" => "a2a-protocol.org", + "metadata" => %{"timestamp" => DateTime.to_iso8601(DateTime.utc_now())} + } + ] + } + end +end diff --git a/lib/phantom/a2a/events.ex b/lib/phantom/a2a/events.ex new file mode 100644 index 0000000..5deebcd --- /dev/null +++ b/lib/phantom/a2a/events.ex @@ -0,0 +1,93 @@ +defmodule Phantom.A2A.Events do + @moduledoc """ + How A2A events change a task. + + Save each event with `apply/2` before you publish it: + + {:ok, task, event} = Phantom.A2A.Events.apply(stored_task, event) + MyApp.A2ATasks.save!(task) + Phantom.A2A.publish(task_ref, event) + + `apply/2` numbers the event after the task's last one, and the task keeps + that number in `sequence`. A client that subscribes while you publish gets + the task from `c:Phantom.A2A.Agent.get_task/2`, so Phantom skips the events + already in it. Apply events within the lock or transaction you save the task + in, so two writers don't number events the same. + """ + + alias Phantom.A2A.{Artifact, ArtifactUpdate, Message, StatusUpdate, Task} + + @terminal ~w[completed failed canceled rejected]a + @interrupted ~w[input_required auth_required]a + + @type event :: Task.t() | StatusUpdate.t() | ArtifactUpdate.t() | Message.t() + + @doc """ + Apply an event to a task, and return the task with the numbered event. + + An event that already has a `sequence` keeps it. Messages don't change the + task and aren't numbered. Returns `{:error, :unknown_artifact}` for an append + to an artifact the task doesn't have. + """ + def apply(%Task{} = task, %Message{} = message), do: {:ok, task, message} + + def apply(%Task{} = task, event) do + sequence = sequence(event) || task.sequence + 1 + event = put_sequence(event, sequence) + + with {:ok, task} <- change(task, event), do: {:ok, %{task | sequence: sequence}, event} + end + + @doc "Whether the task already includes a numbered event" + def seen?(%Task{sequence: last}, event) do + case sequence(event) do + nil -> false + sequence -> sequence <= last + end + end + + defp sequence(%Message{}), do: nil + defp sequence(%Task{sequence: 0}), do: nil + defp sequence(%{sequence: sequence}), do: sequence + + defp put_sequence(event, sequence), do: %{event | sequence: sequence} + + defp change(%Task{}, %Task{} = task), do: {:ok, task} + defp change(%Task{} = task, %StatusUpdate{status: status}), do: {:ok, %{task | status: status}} + + defp change(%Task{} = task, %ArtifactUpdate{artifact: artifact, append: true}) do + case Enum.find_index(task.artifacts, &(&1.artifact_id == artifact.artifact_id)) do + nil -> + {:error, :unknown_artifact} + + index -> + artifacts = + List.update_at(task.artifacts, index, fn %Artifact{} = existing -> + %{ + existing + | parts: existing.parts ++ artifact.parts, + metadata: merge_metadata(existing.metadata, artifact.metadata) + } + end) + + {:ok, %{task | artifacts: artifacts}} + end + end + + defp change(%Task{} = task, %ArtifactUpdate{artifact: artifact}) do + case Enum.find_index(task.artifacts, &(&1.artifact_id == artifact.artifact_id)) do + nil -> {:ok, %{task | artifacts: task.artifacts ++ [artifact]}} + index -> {:ok, %{task | artifacts: List.replace_at(task.artifacts, index, artifact)}} + end + end + + defp merge_metadata(existing, nil), do: existing + defp merge_metadata(nil, new), do: new + defp merge_metadata(existing, new), do: Map.merge(existing, new) + + @doc "Whether a task in this state is finished" + def terminal?(state), do: state in @terminal + + @doc "Whether a task in this state waits on the client" + def interrupted?(state), do: state in @interrupted +end diff --git a/lib/phantom/a2a/message.ex b/lib/phantom/a2a/message.ex new file mode 100644 index 0000000..024c5c1 --- /dev/null +++ b/lib/phantom/a2a/message.ex @@ -0,0 +1,104 @@ +defmodule Phantom.A2A.Message do + @moduledoc """ + A message between a client and an A2A agent. + + An agent handler can answer `SendMessage` with a message instead of a + task, when there is no work to track: + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("pong"), ctx} + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.Part + + @enforce_keys [:message_id, :role] + defstruct [ + :message_id, + :role, + :context_id, + :task_id, + :metadata, + parts: [], + extensions: [], + reference_task_ids: [] + ] + + @type role :: :user | :agent + @type t :: %__MODULE__{ + message_id: String.t(), + role: role(), + context_id: String.t() | nil, + task_id: String.t() | nil, + metadata: map() | nil, + parts: [Part.t()], + extensions: [String.t()], + reference_task_ids: [String.t()] + } + + @roles %{"ROLE_USER" => :user, "ROLE_AGENT" => :agent} + + @doc """ + A message from the agent. Give it text, or a list of `Phantom.A2A.Part`. + `opts` sets other fields, such as `:metadata`. + """ + def agent(text_or_parts, opts \\ []), do: build(:agent, text_or_parts, opts) + + @doc "A message from the user. Useful in tests." + def user(text_or_parts, opts \\ []), do: build(:user, text_or_parts, opts) + + defp build(role, text, opts) when is_binary(text), do: build(role, [Part.text(text)], opts) + + defp build(role, parts, opts) when is_list(parts) do + struct!(__MODULE__, [message_id: UUIDv7.generate(), role: role, parts: parts] ++ opts) + end + + @doc false + def from_json(%{"messageId" => id, "role" => role, "parts" => [_ | _] = parts} = json) + when is_binary(id) and is_map_key(@roles, role) do + with :ok <- check_fields(json), + {:ok, parts} <- Part.list_from_json(parts) do + {:ok, + %__MODULE__{ + message_id: id, + role: Map.fetch!(@roles, role), + context_id: json["contextId"], + task_id: json["taskId"], + metadata: json["metadata"], + parts: parts, + extensions: json["extensions"] || [], + reference_task_ids: json["referenceTaskIds"] || [] + }} + end + end + + def from_json(_json), do: {:error, "message must have a messageId, a role, and parts"} + + defp check_fields(json) do + valid? = + optional?(json["contextId"], &is_binary/1) and optional?(json["taskId"], &is_binary/1) and + optional?(json["metadata"], &is_map/1) and optional?(json["extensions"], &is_list/1) and + optional?(json["referenceTaskIds"], &is_list/1) + + if valid?, do: :ok, else: {:error, "message has a field of the wrong type"} + end + + defp optional?(value, valid?), do: is_nil(value) or valid?.(value) + + @doc false + def to_json(%__MODULE__{} = message) do + remove_nils(%{ + "messageId" => message.message_id, + "role" => if(message.role == :user, do: "ROLE_USER", else: "ROLE_AGENT"), + "contextId" => message.context_id, + "taskId" => message.task_id, + "metadata" => message.metadata, + "parts" => Enum.map(message.parts, &Part.to_json/1), + "extensions" => non_empty(message.extensions), + "referenceTaskIds" => non_empty(message.reference_task_ids) + }) + end + + defp non_empty([]), do: nil + defp non_empty(list), do: list +end diff --git a/lib/phantom/a2a/part.ex b/lib/phantom/a2a/part.ex new file mode 100644 index 0000000..f31ce6d --- /dev/null +++ b/lib/phantom/a2a/part.ex @@ -0,0 +1,102 @@ +defmodule Phantom.A2A.Part do + @moduledoc """ + One piece of content in an A2A message or artifact. + + A part holds exactly one of `text`, `raw` bytes, a `url`, or structured + `data`, with optional `media_type`, `filename`, and `metadata`. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + defstruct [:text, :raw, :url, :data, :metadata, :filename, :media_type] + + @type t :: %__MODULE__{ + text: String.t() | nil, + raw: binary() | nil, + url: String.t() | nil, + data: term(), + metadata: map() | nil, + filename: String.t() | nil, + media_type: String.t() | nil + } + + @doc "A text part" + def text(text, opts \\ []) when is_binary(text), do: struct!(__MODULE__, [text: text] ++ opts) + + @doc "A part of raw bytes, sent base64-encoded" + def raw(bytes, opts \\ []) when is_binary(bytes), do: struct!(__MODULE__, [raw: bytes] ++ opts) + + @doc "A part that points at a file by URL" + def url(url, opts \\ []) when is_binary(url), do: struct!(__MODULE__, [url: url] ++ opts) + + @doc "A part of structured JSON data" + def data(data, opts \\ []), do: struct!(__MODULE__, [data: data] ++ opts) + + @doc false + def from_json(%{} = json) do + with :ok <- one_kind_of_content(json), + {:ok, part} <- content(json) do + {:ok, + %{ + part + | metadata: json["metadata"], + filename: json["filename"], + media_type: json["mediaType"] + }} + end + end + + def from_json(_json), do: {:error, "part must be an object"} + + @doc false + # Decode a list with a `from_json/1`, stopping at the first error. + def list_from_json(list, decode \\ &from_json/1) + + def list_from_json(list, decode) when is_list(list) do + list + |> Enum.reduce_while({:ok, []}, fn json, {:ok, acc} -> + case decode.(json) do + {:ok, value} -> {:cont, {:ok, [value | acc]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, values} -> {:ok, Enum.reverse(values)} + error -> error + end + end + + def list_from_json(_list, _decode), do: {:error, "expected a list"} + + defp one_kind_of_content(json) do + if Enum.count(~w[text raw url data], &(json[&1] != nil)) == 1, + do: :ok, + else: {:error, "part must have exactly one of text, raw, url, or data"} + end + + defp content(%{"text" => text}) when is_binary(text), do: {:ok, %__MODULE__{text: text}} + defp content(%{"url" => url}) when is_binary(url), do: {:ok, %__MODULE__{url: url}} + defp content(%{"data" => data}), do: {:ok, %__MODULE__{data: data}} + + defp content(%{"raw" => raw}) when is_binary(raw) do + case Base.decode64(raw) do + {:ok, bytes} -> {:ok, %__MODULE__{raw: bytes}} + :error -> {:error, "part raw content must be base64"} + end + end + + defp content(_json), do: {:error, "part must have text, raw, url, or data"} + + @doc false + def to_json(%__MODULE__{} = part) do + remove_nils(%{ + "text" => part.text, + "raw" => part.raw && Base.encode64(part.raw), + "url" => part.url, + "data" => part.data, + "metadata" => part.metadata, + "filename" => part.filename, + "mediaType" => part.media_type + }) + end +end diff --git a/lib/phantom/a2a/plug.ex b/lib/phantom/a2a/plug.ex new file mode 100644 index 0000000..2897036 --- /dev/null +++ b/lib/phantom/a2a/plug.ex @@ -0,0 +1,1543 @@ +defmodule Phantom.A2A.Plug do + @default_opts [ + pubsub: nil, + origins: ["http://localhost:4000"], + validate_origin: true, + hosts: :all, + mount: nil, + blocking_timeout: :timer.seconds(60), + poll_interval: :timer.seconds(1), + card_max_age: 300, + bindings: [:jsonrpc, :http_json], + keepalive: :timer.seconds(15), + push_notifications: true, + extended_card: true, + max_request_size: 1_048_576 + ] + + @moduledoc """ + Serve a router's A2A agents over the JSON-RPC and HTTP+JSON bindings. + + Mount it under a path, and optionally serve one agent's card at the + well-known path. Mount it without `Plug.Parsers`: it reads the body itself, + so a request with invalid JSON gets the error A2A requires rather than the + parser's 400. A body that is already parsed is used as is. + + get "/.well-known/agent-card.json", Phantom.A2A.Plug, + router: MyApp.MCP.Router, + mount: "/a2a" + + scope "/a2a" do + forward "/", Phantom.A2A.Plug, router: MyApp.MCP.Router, pubsub: MyApp.PubSub + end + + Each `agent "research", ...` route in the router is then served at: + + * `GET /a2a/research/agent-card.json` - the agent's card + * `POST /a2a/research` - JSON-RPC requests + * the HTTP+JSON routes under `/a2a/research`, from the A2A protobuf: + `POST message:send`, `POST message:stream`, `GET tasks`, + `GET tasks/{id}`, `POST tasks/{id}:cancel`, `GET` or + `POST tasks/{id}:subscribe`, `GET extendedAgentCard`, and + `POST`, `GET`, and `DELETE` on `tasks/{id}/pushNotificationConfigs`. + A segment before them names a tenant, as in `/a2a/research/acme/message:send`. + + Both bindings serve the same methods, and the card lists both. HTTP+JSON + answers with `application/a2a+json`, and with errors as `google.rpc.Status` + under their HTTP status. + + The well-known path serves the card of the agent named by the router's + `a2a: [well_known: "research"]` option. + + Requests must name version 1.0, in an `A2A-Version` header or query + parameter. Each request + calls `c:Phantom.Router.connect/2` with the conn and the request in + `conn.body_params`: the JSON-RPC request, or for HTTP+JSON, + `%{"method" => method, "params" => params}` in the same shape. Agents + excluded with `Phantom.Session.allow_agents/2` are not found. Cards are + public. + + ## Options + + * `:router` - the `Phantom.Router` (required) + * `:bindings` - which bindings to serve (default `[:jsonrpc, :http_json]`) + * `:pubsub` - a `Phoenix.PubSub` server, which `Phantom.A2A.publish/2` + sends events through. Like `Phantom.Plug`, options also come from the + router's application config, so one `pubsub` there serves both plugs. With it, cards advertise streaming, + `SendStreamingMessage` and `SubscribeToTask` stream events over SSE, and + `SendMessage` waits for the task to finish or need input. Without it, + `SendMessage` polls `c:Phantom.A2A.Agent.get_task/2` instead, and returns + at once for an agent that doesn't store tasks. + * `:blocking_timeout` - how long `SendMessage` waits for a task to finish + or need input, in milliseconds or `:infinity` (default 60 seconds). The + spec sets no limit, but a blocked request can't notice that its client + left, and proxies often close idle connections, so the default stops + abandoned requests from waiting forever. A task that outlasts it is + returned as it is, and the client continues with `GetTask` or + `SubscribeToTask`. Set `:infinity` to wait as the spec says. + * `:poll_interval` - milliseconds between polls when `SendMessage` can't + wait on events (default 1 second) + * `:mount` - the path the agents are forwarded from, for the well-known + route, such as `"/a2a"` + * `:card_max_age` - seconds clients may cache a card (default 300). Cards + also carry an `ETag`. + * `:push_notifications` - set `false` to turn push notifications off for + this mount, even for agents that support them + * `:extended_card` - set `false` to turn extended agent cards off for this + mount + * `:keepalive` - milliseconds between keepalive comments on an idle + stream (default 15 seconds). Lower it to stay under a proxy's idle + timeout. + * `:max_request_size` - the largest body read, in bytes, when no + `Plug.Parsers` ran before this plug + * `:origins`, `:validate_origin`, `:hosts` - as in `Phantom.Plug` + + ## Telemetry + + Each request, on either binding, is a `[:phantom, :a2a, :dispatch]` + telemetry span: + + * `:start` with metadata `~w[router agent_id method params session trace_context]a` + * `:stop` with the same metadata plus `:result`: `{:ok, result}`, + `{:error, %Phantom.A2A.Error{}}`, or `:stream` for a streamed response, + whose span lasts until the stream closes + * `:exception` with the metadata plus `~w[kind reason stacktrace]a` + + `:trace_context` holds the W3C `traceparent`, `tracestate`, and `baggage` + headers the client sent. + + Defaults: + + ```elixir + #{inspect(@default_opts, pretty: true)} + ``` + """ + + @behaviour Plug + + import Plug.Conn + + require Logger + + alias Phantom.A2A + + alias Phantom.A2A.{ + ArtifactUpdate, + Context, + Error, + Events, + Message, + PushConfig, + StatusUpdate, + Task, + TaskRef, + TaskStatus + } + + @protocol_version "1.0" + + @impl Plug + def init(opts) do + @default_opts + |> Keyword.merge(opts) + |> Map.new() + end + + @impl Plug + def call(conn, opts) do + # Configuration for the router applies here too, as in `Phantom.Plug`. + opts = Map.merge(opts, Map.new(Application.get_all_env(opts.router))) + origin = conn |> get_req_header("origin") |> List.first() + + cond do + not Phantom.Plug.valid_host?(conn.host, opts.hosts) -> + send_error(conn, opts, nil, Error.invalid_request("Host not allowed"), 403) + + not Phantom.Plug.valid_origin?(origin, opts) -> + send_error(conn, opts, nil, Error.invalid_request("Origin not allowed"), 403) + + true -> + route(conn, opts) + end + end + + defp route( + %Plug.Conn{method: "GET", path_info: [".well-known", "agent-card.json"]} = conn, + opts + ) do + with id when is_binary(id) <- opts.router.__phantom__(:info).a2a[:well_known], + {:ok, %{params?: false} = route, _params} <- + Phantom.Router.A2A.match(opts.router, String.split(id, "/")) do + serve_card(conn, opts, static_agent(route, opts), opts.mount || "", "public") + else + _ -> not_found(conn, opts) + end + end + + defp route(%Plug.Conn{method: "GET", path_info: path_info} = conn, opts) do + with {segments, ["agent-card.json"]} <- Enum.split(path_info, -1), + {:ok, route, params} <- Phantom.Router.A2A.match(opts.router, segments) do + card_for(conn, opts, route, segments, params) + else + _ -> route_binding(conn, opts) + end + end + + defp route(conn, opts), do: route_binding(conn, opts) + + # An HTTP+JSON route, then a JSON-RPC request to the agent's path. + defp route_binding(%Plug.Conn{method: method, path_info: segments} = conn, opts) do + with true <- :http_json in opts.bindings, + {:ok, rpc_method, path_params, rest} <- rest_route(method, Enum.reverse(segments)), + {:ok, match, tenant} <- match_rest_agent(opts.router, Enum.reverse(rest)) do + path_params = Map.new(path_params, fn {key, value} -> {key, decode_segment(value)} end) + tenant = tenant && decode_segment(tenant) + + rest(conn, Map.put(opts, :binding, :http_json), match, rpc_method, path_params, tenant) + else + _ -> + case {method, :jsonrpc in opts.bindings, Phantom.Router.A2A.match(opts.router, segments)} do + {"POST", true, {:ok, route, params}} -> rpc(conn, opts, {route, segments, params}) + {method, _, _} when method in ~w[GET POST DELETE] -> not_found(conn, opts) + _ -> send_error(conn, opts, nil, Error.invalid_request("Method not allowed"), 405) + end + end + end + + # A static agent's card is public. A resolved agent's card needs the session. + defp card_for(conn, opts, %{params?: false, resolver: nil} = route, _segments, _params), + do: serve_card(conn, opts, static_agent(route, opts), mount_path(conn), "public") + + defp card_for(conn, opts, route, segments, params) do + case open_agent(conn, opts, {route, segments, params}) do + # Resolved for this session, so a shared cache mustn't keep it. + {:ok, _session, agent} -> serve_card(conn, opts, agent, mount_path(conn), "private") + :not_found -> not_found(conn, opts) + %Plug.Conn{} = conn -> conn + end + end + + defp static_agent(route, opts) do + %{ + id: route.id, + route: route, + handler: route.handler, + opts: route.opts, + params: %{}, + state: nil, + card: + route.handler.card(route.handler.__phantom_a2a__(:card), %{ + router: opts.router, + opts: route.opts + }) + } + end + + # Connect, check the allow-list, and resolve the agent for the session. + defp open_agent(conn, opts, {route, segments, params}) do + with {:ok, session} <- connect(conn, opts), + :ok <- check_allowed(session, route, segments), + {:ok, agent} <- resolve_agent(route, segments, params, session, opts) do + {:ok, session, agent} + end + end + + defp resolve_agent(%{resolver: nil} = route, _segments, _params, _session, opts), + do: {:ok, static_agent(route, opts)} + + defp resolve_agent(route, segments, params, session, opts) do + case apply(route.handler, route.resolver, [params, session]) do + {:ok, %A2A.Card{} = card, state} -> + {:ok, + %{ + static_agent(route, opts) + | id: Enum.join(segments, "/"), + params: params, + state: state, + card: card + }} + + {:error, :not_found} -> + :not_found + end + end + + defp mount_path(%Plug.Conn{script_name: []}), do: "" + defp mount_path(%Plug.Conn{script_name: script_name}), do: "/" <> Enum.join(script_name, "/") + + defp not_found(conn, opts), + do: send_error(conn, opts, nil, Error.invalid_request("Not found"), 404) + + ## Agent cards + + defp serve_card(conn, opts, agent, mount, cacheability) do + body = conn |> card(opts, agent, mount) |> A2A.Card.to_json() |> JSON.encode!() + etag = ~s("#{:sha256 |> :crypto.hash(body) |> Base.url_encode64(padding: false)}") + conn = put_resp_header(conn, "cache-control", "#{cacheability}, max-age=#{opts.card_max_age}") + + if etag in get_req_header(conn, "if-none-match") do + conn |> put_resp_header("etag", etag) |> send_resp(304, "") + else + conn + |> put_resp_header("etag", etag) + |> put_resp_content_type("application/json") + |> send_resp(200, body) + end + end + + defp card(conn, opts, agent, mount) do + info = opts.router.__phantom__(:info) + card = agent.card + base = URI.to_string(%URI{scheme: to_string(conn.scheme), host: conn.host, port: conn.port}) + + %{ + card + | version: card.version || info.version, + provider: info.a2a[:provider], + security_schemes: info.a2a[:security_schemes], + security_requirements: info.a2a[:security_requirements], + supported_interfaces: + for( + {binding, name} <- [jsonrpc: "JSONRPC", http_json: "HTTP+JSON"], + binding in opts.bindings, + do: %{ + "url" => "#{base}#{mount}/#{agent.id}", + "protocolBinding" => name, + "protocolVersion" => @protocol_version + } + ), + capabilities: + Phantom.Utils.remove_nils(%{ + "streaming" => not is_nil(opts.pubsub), + "pushNotifications" => push_notifications?(agent, opts), + "extensions" => + if(card.extensions != [], + do: Enum.map(card.extensions, &A2A.Card.extension_to_json/1) + ), + "extendedAgentCard" => extended_card?(agent, opts) + }) + } + end + + ## HTTP+JSON + + # Routes from the `google.api.http` annotations of a2a.proto, matched on the + # path's segments in reverse. What precedes them is the agent and tenant. + defp rest_route("POST", ["message:send" | rest]), do: {:ok, "SendMessage", %{}, rest} + defp rest_route("POST", ["message:stream" | rest]), do: {:ok, "SendStreamingMessage", %{}, rest} + + defp rest_route("GET", ["extendedAgentCard" | rest]), + do: {:ok, "GetExtendedAgentCard", %{}, rest} + + defp rest_route("GET", ["tasks" | rest]), do: {:ok, "ListTasks", %{}, rest} + + defp rest_route("POST", ["pushNotificationConfigs", task_id, "tasks" | rest]), + do: {:ok, "CreateTaskPushNotificationConfig", %{"taskId" => task_id}, rest} + + defp rest_route("GET", ["pushNotificationConfigs", task_id, "tasks" | rest]), + do: {:ok, "ListTaskPushNotificationConfigs", %{"taskId" => task_id}, rest} + + defp rest_route("GET", [id, "pushNotificationConfigs", task_id, "tasks" | rest]), + do: {:ok, "GetTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}, rest} + + defp rest_route("DELETE", [id, "pushNotificationConfigs", task_id, "tasks" | rest]), + do: {:ok, "DeleteTaskPushNotificationConfig", %{"taskId" => task_id, "id" => id}, rest} + + defp rest_route("POST", [last, "tasks" | rest]) do + case task_verb(last) do + {id, "cancel"} -> {:ok, "CancelTask", %{"id" => id}, rest} + # The method tables name POST; the proto names GET. Accept both. + {id, "subscribe"} -> {:ok, "SubscribeToTask", %{"id" => id}, rest} + _ -> :error + end + end + + defp rest_route("GET", [last, "tasks" | rest]) do + case task_verb(last) do + {id, "subscribe"} -> {:ok, "SubscribeToTask", %{"id" => id}, rest} + _ -> {:ok, "GetTask", %{"id" => last}, rest} + end + end + + defp rest_route(_method, _segments), do: :error + + # The plug doesn't decode `path_info`. A malformed escape stays as it was, and + # finds nothing. + defp decode_segment(segment) do + URI.decode(segment) + rescue + ArgumentError -> segment + end + + # "t1:cancel" -> {"t1", "cancel"}. Task IDs may contain ":", so split on the last. + defp task_verb(segment) do + case String.split(segment, ":") do + [_id] -> :error + parts -> {parts |> Enum.drop(-1) |> Enum.join(":"), List.last(parts)} + end + end + + # Static agents match first, so a dynamic agent can't take a static agent's tenant. + defp match_rest_agent(router, segments) do + paths = [{segments, nil} | tenant_split(segments)] + + Enum.find_value([false, true], :error, fn params? -> + Enum.find_value(paths, fn {agent_segments, tenant} -> + case Phantom.Router.A2A.match(router, agent_segments, params?) do + {:ok, route, params} -> {:ok, {route, agent_segments, params}, tenant} + :error -> nil + end + end) + end) + end + + defp tenant_split([]), do: [] + + defp tenant_split(segments) do + {agent_segments, [tenant]} = Enum.split(segments, -1) + [{agent_segments, tenant}] + end + + defp rest(conn, opts, match, method, path_params, tenant) do + case rest_body(conn, opts) do + {:ok, body, conn} -> + conn = Plug.Conn.fetch_query_params(conn) + + params = + body + |> Map.merge(query_params(conn.query_params)) + |> Map.merge(path_params) + |> then(&if tenant, do: Map.put(&1, "tenant", tenant), else: &1) + + # connect/2 sees the request as on JSON-RPC: its method and params. + conn = %{conn | body_params: %{"method" => method, "params" => params}} + serve(conn, opts, match, nil, method, params) + + {:error, :too_large, conn} -> + send_error(conn, opts, nil, Error.invalid_request("Request too large"), 413) + + {:error, _reason, conn} -> + send_error(conn, opts, nil, Error.parse_error(), 200) + end + end + + defp rest_body(%Plug.Conn{method: "POST"} = conn, opts) do + case read_json(conn, opts) do + {:ok, %{} = body, conn} -> {:ok, body, conn} + {:ok, _not_an_object, conn} -> {:error, :invalid, conn} + error -> error + end + end + + defp rest_body(conn, _opts), do: {:ok, %{}, conn} + + @integer_params ~w[pageSize historyLength] + + defp query_params(query) do + Map.new(query, fn + {key, value} when key in @integer_params and is_binary(value) -> + case Integer.parse(value) do + {integer, ""} -> {key, integer} + _ -> {key, value} + end + + {"includeArtifacts", value} -> + {"includeArtifacts", value == "true"} + + pair -> + pair + end) + end + + ## JSON-RPC + + defp rpc(conn, opts, match) do + case read_json(conn, opts) do + {:ok, body, conn} -> + rpc(conn, opts, match, body) + + {:error, :too_large, conn} -> + send_error(conn, opts, nil, Error.invalid_request("Request too large"), 413) + + {:error, _reason, conn} -> + send_error(conn, opts, nil, Error.parse_error(), 200) + end + end + + # Without Plug.Parsers upstream, the body is read here, so invalid JSON gets + # a JSON-RPC parse error instead of the parser's 400. + defp read_json(%Plug.Conn{body_params: %Plug.Conn.Unfetched{}} = conn, opts) do + case read_body(conn, length: opts.max_request_size) do + # An HTTP+JSON request may have no body, such as to cancel a task. + {:ok, "", conn} when opts.binding == :http_json -> + {:ok, %{}, conn} + + {:ok, raw, conn} -> + case JSON.decode(raw) do + {:ok, body} -> {:ok, body, put_body_params(conn, body)} + {:error, reason} -> {:error, reason, conn} + end + + {:more, _partial, conn} -> + {:error, :too_large, conn} + + {:error, reason} -> + {:error, reason, conn} + end + end + + defp read_json(conn, _opts), do: {:ok, conn.body_params, conn} + + # As `Plug.Parsers` would, so `c:Phantom.Router.connect/2` sees the body, + # such as the request's `tenant`. + defp put_body_params(conn, body) when is_map(body) do + params = if is_map(conn.params) and not is_struct(conn.params), do: conn.params, else: %{} + %{conn | body_params: body, params: Map.merge(params, body)} + end + + defp put_body_params(conn, body), do: put_body_params(conn, %{"_json" => body}) + + defp rpc(conn, opts, match, body) do + case envelope(body) do + {:ok, id, method, params} -> serve(conn, opts, match, id, method, params) + :error -> send_error(conn, opts, nil, Error.invalid_request(), 200) + end + end + + # Serve a request of either binding, once it is a method and its params. + defp serve(conn, opts, match, id, method, params) do + with :ok <- check_version(conn), + {:ok, session, agent} <- open_agent(conn, opts, match), + {:ok, extensions} <- check_extensions(conn, agent), + :ok <- check_params(params) do + conn = put_extensions_header(conn, extensions, agent) + + ctx = %Context{ + extensions: extensions, + session: session, + assigns: session.assigns, + agent: Map.take(agent, [:id, :opts, :params, :state]), + tenant: params["tenant"], + metadata: params["metadata"] + } + + opts = Map.put(opts, :card, fn -> card(conn, opts, agent, mount_path(conn)) end) + refetch = if agent.handler.follows_mcp_tasks?(), do: &agent.handler.get_task(&1, ctx) + + opts = + Map.merge(opts, %{ + refetch: refetch, + history_length: (params["configuration"] || params)["historyLength"] + }) + + meta = %{ + router: opts.router, + agent_id: agent.id, + method: method, + params: params, + session: session, + trace_context: trace_context(conn) + } + + try do + :telemetry.span([:phantom, :a2a, :dispatch], meta, fn -> + result = dispatch(method, params, agent, ctx, opts) + conn = respond(conn, id, result, opts) + {conn, %{}, Map.put(meta, :result, telemetry_result(result))} + end) + rescue + exception -> + send_error(conn, opts, id, Error.internal_error(), 200) + reraise exception, __STACKTRACE__ + after + flush_events() + end + else + %Error{} = error -> send_error(conn, opts, id, error, 200) + %Plug.Conn{} = conn -> conn + :not_found -> not_found(conn, opts) + end + end + + # Requests can share a process, as on a keep-alive connection, so nothing a + # request listened for may reach the next one. + defp flush_events do + receive do + {:phantom_a2a_event, _task_id, _event} -> flush_events() + {:"$gen_cast", {:task_updated, _task_id}} -> flush_events() + after + 0 -> :ok + end + end + + defp check_params(%{"configuration" => configuration}) + when not (is_map(configuration) or is_nil(configuration)), + do: Error.invalid_params("configuration must be an object") + + defp check_params(params) do + configuration = params["configuration"] || %{} + + checks = [ + {"historyLength", params["historyLength"], &history_length?/1}, + {"metadata", params["metadata"], &is_map/1}, + {"configuration.historyLength", configuration["historyLength"], &history_length?/1}, + {"configuration.returnImmediately", configuration["returnImmediately"], &is_boolean/1}, + {"configuration.acceptedOutputModes", configuration["acceptedOutputModes"], &is_list/1}, + {"configuration.taskPushNotificationConfig", configuration["taskPushNotificationConfig"], + &is_map/1} + ] + + case Enum.find(checks, fn {_name, value, valid?} -> not (is_nil(value) or valid?.(value)) end) do + nil -> :ok + {name, _value, _valid?} -> Error.invalid_params("#{name} has an invalid value") + end + end + + defp history_length?(length), do: is_integer(length) and length >= 0 + + defp check_extensions(conn, agent) do + requested = + conn + |> get_req_header("a2a-extensions") + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + case Enum.find(agent.card.extensions, &(&1[:required] && &1.uri not in requested)) do + nil -> {:ok, requested} + extension -> Error.extension_support_required(extension.uri) + end + end + + # Name the requested extensions the agent supports, so the client knows + # which ones apply. + defp put_extensions_header(conn, requested, agent) do + supported = for %{uri: uri} <- agent.card.extensions, uri in requested, do: uri + + if supported == [], + do: conn, + else: put_resp_header(conn, "a2a-extensions", Enum.join(supported, ", ")) + end + + # W3C Trace Context, as A2A clients send it: in HTTP headers. + defp trace_context(conn) do + for name <- ~w[traceparent tracestate baggage]a, + [value | _] <- [get_req_header(conn, Atom.to_string(name))], + into: %{}, + do: {name, value} + end + + defp telemetry_result({:stream, _ref, _start}), do: :stream + defp telemetry_result(result), do: result + + defp envelope(%{"jsonrpc" => "2.0", "method" => method} = body) when is_binary(method) do + case Map.get(body, "params", %{}) do + %{} = params -> {:ok, body["id"], method, params} + _ -> :error + end + end + + defp envelope(_body), do: :error + + # A missing version means 0.3, which this server doesn't speak. + defp check_version(conn) do + case request_version(conn) do + version when is_binary(version) and version == @protocol_version -> :ok + version -> Error.version_not_supported(inspect(version)) + end + end + + defp request_version(conn) do + case get_req_header(conn, "a2a-version") do + [version | _] -> String.trim(version) + [] -> Map.get(fetch_query_params(conn).query_params, "A2A-Version", "0.3") + end + end + + defp connect(conn, opts) do + session = + Phantom.Session.new(nil, + router: opts.router, + pubsub: opts.pubsub, + pid: self(), + transport_pid: conn.owner + ) + + case opts.router.connect(session, conn) do + {:ok, %Phantom.Session{} = session} -> + {:ok, session} + + {status, info} when status in [401, :unauthorized] -> + conn + |> put_www_authenticate(info) + |> send_error(opts, nil, Error.invalid_request("Unauthorized"), 401) + + {status, message} when status in [403, :forbidden] -> + send_error(conn, opts, nil, Error.invalid_request(message), 403) + + {status, message} when status in [404, :not_found] -> + send_error(conn, opts, nil, Error.invalid_request(message), 404) + + {:error, reason} -> + Logger.warning("A2A connect/2 failed: #{inspect(reason)}") + send_error(conn, opts, nil, Error.invalid_request("Connection failed"), 400) + end + end + + defp put_www_authenticate(conn, nil), do: conn + + defp put_www_authenticate(conn, info) when is_map(info), + do: put_resp_header(conn, "www-authenticate", Phantom.Plug.www_authenticate(info)) + + defp put_www_authenticate(conn, info), do: put_resp_header(conn, "www-authenticate", info) + + defp check_allowed(%Phantom.Session{allowed_agents: nil}, _route, _segments), do: :ok + + defp check_allowed(%Phantom.Session{allowed_agents: allowed}, route, segments) do + if route.id in allowed or Enum.join(segments, "/") in allowed, do: :ok, else: :not_found + end + + defp respond(conn, id, {:stream, ref, start}, opts) do + # Subscribe before the work can start, so no event is missed. + A2A.subscribe(ref) + opts = Map.put(opts, :ref_task_id, ref.task_id) + + try do + case start.() do + {:ok, events, task, adopted} -> + conn = start_stream(conn) + + try do + stream(conn, id, events, task, opts) + rescue + # The status line is sent, so the error can only be an event. + exception -> + Logger.error(Exception.format(:error, exception, __STACKTRACE__)) + + {_result, conn} = + send_sse(conn, nil, error_payload(id, Error.internal_error(), 200, opts)) + + conn + after + if adopted, do: A2A.unsubscribe(adopted) + if task, do: unfollow_mcp(task.id, opts) + end + + {:error, %Error{} = error} -> + send_error(conn, opts, id, error, 200) + end + after + A2A.unsubscribe(ref) + end + end + + defp respond(conn, _id, {:ok, nil}, %{binding: :http_json}), do: send_resp(conn, 200, "") + + defp respond(conn, _id, {:ok, result}, %{binding: :http_json} = opts), + do: send_json(conn, 200, result, opts) + + defp respond(conn, id, {:ok, result}, _opts), + do: send_json(conn, 200, %{"jsonrpc" => "2.0", "id" => id, "result" => result}) + + defp respond(conn, id, {:error, %Error{} = error}, opts), + do: send_error(conn, opts, id, error, 200) + + # On JSON-RPC, protocol errors are HTTP 200; on HTTP+JSON, each error has + # its HTTP status. An explicit status, such as 401, applies to both. + defp send_error(conn, opts, id, error, status) do + send_json( + conn, + error_status(error, status, opts), + error_payload(id, error, status, opts), + opts + ) + end + + defp error_status(error, 200, %{binding: :http_json}), do: Error.http_status(error) + defp error_status(_error, status, _opts), do: status + + defp error_payload(_id, error, status, %{binding: :http_json} = opts), + do: Error.to_status_json(error, error_status(error, status, opts)) + + defp error_payload(id, error, _status, _opts), do: error_body(id, error) + + defp error_body(id, error), + do: %{"jsonrpc" => "2.0", "id" => id, "error" => Error.to_json(error)} + + defp send_json(conn, status, body, opts \\ %{}) do + content_type = + if opts[:binding] == :http_json, do: "application/a2a+json", else: "application/json" + + conn + |> put_resp_content_type(content_type) + |> send_resp(status, JSON.encode!(body)) + end + + ## Methods + + defp dispatch("SendMessage", params, agent, ctx, opts) do + with {:ok, ctx} <- prepare_send(params, agent, ctx, opts) do + A2A.subscribe(ctx.task_ref) + + try do + case handle_message(agent, ctx) do + {:ok, %Message{} = message} -> + {:ok, %{"message" => Message.to_json(message)}} + + {:ok, %Task{} = task} -> + followed = follow_mcp(task.id, opts) + {task, adopted} = adopt_task(task, agent, ctx, opts) + + try do + with {:ok, task} <- await(task, agent, ctx, followed, opts) do + {:ok, + %{ + "task" => + Task.to_json(task, history_length: ctx.configuration["historyLength"]) + }} + end + after + if adopted, do: A2A.unsubscribe(adopted) + unfollow_mcp(task.id, opts) + end + + error -> + error + end + after + A2A.unsubscribe(ctx.task_ref) + end + end + end + + defp dispatch("SendStreamingMessage", params, agent, ctx, opts) do + with :ok <- require_streaming(opts), + {:ok, ctx} <- prepare_send(params, agent, ctx, opts) do + {:stream, ctx.task_ref, + fn -> + case handle_message(agent, ctx) do + {:ok, %Message{} = message} -> + {:ok, [message], nil, nil} + + {:ok, %Task{} = task} -> + followed = follow_mcp(task.id, opts) + {task, adopted} = adopt_task(task, agent, ctx, opts) + start_following(task, adopted, followed) + + error -> + error + end + end} + end + end + + defp dispatch("SubscribeToTask", %{"id" => id}, agent, ctx, opts) when is_binary(id) do + with :ok <- require_streaming(opts) do + ctx = %{ctx | task_ref: task_ref(id, agent, opts)} + + {:stream, ctx.task_ref, + fn -> + followed = follow_mcp(id, opts) + + with {:ok, task} <- existing_task(id, agent, ctx, allow_terminal: true), + false <- Events.terminal?(task.status.state), + {:ok, _events, _task, _adopted} = started <- start_following(task, nil, followed) do + started + else + true -> + unfollow_mcp(id, opts) + {:error, Error.unsupported_operation("Task #{id} is already finished")} + + error -> + unfollow_mcp(id, opts) + error + end + end} + end + end + + defp dispatch("ListTasks", params, agent, ctx, _opts) do + with {:ok, filter} <- list_filter(params), + {:ok, page} <- list_tasks(agent, filter, ctx), + {:ok, total_size} <- total_size(page) do + opts = [ + history_length: params["historyLength"], + include_artifacts: params["includeArtifacts"] == true + ] + + {:ok, + %{ + "tasks" => Enum.map(page.tasks, &Task.to_json(&1, opts)), + "nextPageToken" => Map.get(page, :next_page_token) || "", + "pageSize" => filter.page_size, + "totalSize" => total_size + }} + end + end + + defp dispatch("GetTask", %{"id" => id} = params, agent, ctx, opts) when is_binary(id) do + ctx = %{ctx | task_ref: task_ref(id, agent, opts)} + + with {:ok, task} <- existing_task(id, agent, ctx, allow_terminal: true) do + {:ok, Task.to_json(task, history_length: params["historyLength"])} + end + end + + defp dispatch("CancelTask", %{"id" => id}, agent, ctx, opts) when is_binary(id) do + ctx = %{ctx | task_ref: task_ref(id, agent, opts)} + + with {:ok, task} <- existing_task(id, agent, ctx, allow_terminal: true) do + cancel(task, agent, %{ctx | task_ref: %{ctx.task_ref | context_id: task.context_id}}) + end + end + + defp dispatch(method, _params, _agent, _ctx, _opts) + when method in ~w[GetTask CancelTask SubscribeToTask], + do: {:error, Error.invalid_params("id is required")} + + defp dispatch("GetExtendedAgentCard", _params, agent, ctx, opts) do + cond do + not extended_card?(agent, opts) -> + {:error, Error.unsupported_operation("Extended agent cards are not supported")} + + # A2A requires authentication here, and the default connect/2 has none. + ctx.session.anonymous -> + {:error, Error.extended_card_not_configured()} + + true -> + case agent.handler.extended_card(opts.card.(), ctx) do + {:ok, %A2A.Card{} = card} -> {:ok, A2A.Card.to_json(card)} + {:error, %Error{} = error} -> {:error, error} + _other -> {:error, Error.invalid_agent_response()} + end + end + end + + defp dispatch( + "CreateTaskPushNotificationConfig", + %{"taskId" => task_id} = params, + agent, + ctx, + opts + ) + when is_binary(task_id) do + with {:ok, ctx} <- push_task(task_id, agent, ctx, opts), + {:ok, config} <- create_push_config(params, agent, ctx, opts) do + {:ok, PushConfig.to_json(config)} + end + end + + defp dispatch( + "GetTaskPushNotificationConfig", + %{"taskId" => task_id, "id" => id}, + agent, + ctx, + opts + ) + when is_binary(task_id) and is_binary(id) do + with {:ok, ctx} <- push_task(task_id, agent, ctx, opts) do + case agent.handler.get_push_config(task_id, id, ctx) do + {:ok, %PushConfig{} = config} -> {:ok, PushConfig.to_json(config)} + {:error, :not_found} -> {:error, Error.push_config_not_found(id)} + {:error, %Error{} = error} -> {:error, error} + _other -> {:error, Error.invalid_agent_response()} + end + end + end + + defp dispatch( + "ListTaskPushNotificationConfigs", + %{"taskId" => task_id} = params, + agent, + ctx, + opts + ) + when is_binary(task_id) do + with {:ok, ctx} <- push_task(task_id, agent, ctx, opts), + {:ok, page_size} <- push_page_size(params["pageSize"]) do + case agent.handler.list_push_configs(task_id, ctx) do + {:ok, configs} when is_list(configs) -> + offset = page_offset(params["pageToken"]) + configs = Enum.sort_by(configs, & &1.id) + next = offset + page_size + + {:ok, + %{ + "configs" => + configs |> Enum.slice(offset, page_size) |> Enum.map(&PushConfig.to_json/1), + "nextPageToken" => if(next < length(configs), do: Integer.to_string(next), else: "") + }} + + {:error, %Error{} = error} -> + {:error, error} + + _other -> + {:error, Error.invalid_agent_response()} + end + end + end + + defp dispatch( + "DeleteTaskPushNotificationConfig", + %{"taskId" => task_id, "id" => id}, + agent, + ctx, + opts + ) + when is_binary(task_id) and is_binary(id) do + with {:ok, ctx} <- push_task(task_id, agent, ctx, opts) do + case agent.handler.delete_push_config(task_id, id, ctx) do + :ok -> {:ok, nil} + {:error, %Error{} = error} -> {:error, error} + _other -> {:error, Error.invalid_agent_response()} + end + end + end + + defp dispatch(method, _params, agent, _ctx, opts) + when method in ~w[CreateTaskPushNotificationConfig GetTaskPushNotificationConfig + ListTaskPushNotificationConfigs DeleteTaskPushNotificationConfig] do + with :ok <- require_push(agent, opts), + do: {:error, Error.invalid_params("taskId is required")} + end + + defp dispatch(method, _params, _agent, _ctx, _opts), + do: {:error, Error.method_not_found(method)} + + # Listening started just before this, so fetch the task again: a notification + # sent since the handler read it would otherwise never arrive. + defp adopt_task(%Task{id: id} = task, _agent, %{task_ref: %{task_id: id}}, %{refetch: refetch}) + when is_function(refetch) do + case refetch.(id) do + {:ok, %Task{} = fetched} -> {fetched, nil} + _gone -> {task, nil} + end + end + + defp adopt_task(%Task{id: id} = task, _agent, %{task_ref: %{task_id: id}}, _opts), + do: {task, nil} + + # A handler may answer with a task another system named. Listen on its ID + # too, then fetch it again, so an event published before that isn't missed. + defp adopt_task(%Task{} = task, agent, ctx, _opts) do + ref = %{ctx.task_ref | task_id: task.id, context_id: task.context_id} + A2A.subscribe(ref) + + case agent.handler.get_task(task.id, %{ctx | task_ref: ref}) do + {:ok, %Task{} = fetched} -> {fetched, ref} + _not_stored -> {task, ref} + end + end + + defp prepare_send(params, agent, ctx, opts) do + with {:ok, message} <- decode_message(params["message"]), + :ok <- check_media_types(message, agent), + {:ok, stored} <- existing_task(message.task_id, agent, ctx), + {:ok, context_id} <- context_id(message, stored) do + task_id = if stored, do: stored.id, else: A2A.generate_id() + + ctx = %{ + ctx + | message: %{message | context_id: context_id}, + task_ref: %{task_ref(task_id, agent, opts) | context_id: context_id}, + context_id: context_id, + requested_context_id: message.context_id, + configuration: params["configuration"] || %{} + } + + with :ok <- + inline_push_config(ctx.configuration["taskPushNotificationConfig"], agent, ctx, opts), + do: {:ok, ctx} + end + end + + # A config sent with the message is saved before the work starts, so no + # update is missed. + defp inline_push_config(nil, _agent, _ctx, _opts), do: :ok + + defp inline_push_config(json, agent, ctx, opts) do + json = Map.put(json, "taskId", ctx.task_ref.task_id) + + with :ok <- require_push(agent, opts), + {:ok, _config} <- create_push_config(json, agent, ctx, opts), + do: :ok + end + + defp push_task(task_id, agent, ctx, opts) do + ctx = %{ctx | task_ref: task_ref(task_id, agent, opts)} + + with :ok <- require_push(agent, opts), + {:ok, task} <- existing_task(task_id, agent, ctx, allow_terminal: true), + do: {:ok, %{ctx | task_ref: %{ctx.task_ref | context_id: task.context_id}}} + end + + defp require_push(agent, opts) do + if push_notifications?(agent, opts), + do: :ok, + else: {:error, Error.push_notification_not_supported()} + end + + defp extended_card?(agent, opts), + do: opts.extended_card and agent.handler.__phantom_a2a__(:extended_card) + + defp push_notifications?(agent, opts), + do: opts.push_notifications and agent.handler.__phantom_a2a__(:push_notifications) + + defp create_push_config(json, agent, ctx, opts) do + allow_private? = opts.router.__phantom__(:info).a2a[:allow_private_push_urls] == true + + with {:ok, config} <- decode_push_config(json), + :ok <- validate_push_url(config.url, allow_private?) do + config = %{config | id: config.id || A2A.generate_id()} + + case agent.handler.create_push_config(config, ctx) do + {:ok, %PushConfig{} = config} -> {:ok, config} + {:error, %Error{} = error} -> {:error, error} + _other -> {:error, Error.invalid_agent_response()} + end + end + end + + defp decode_push_config(json) do + case PushConfig.from_json(json) do + {:ok, config} -> {:ok, config} + {:error, reason} -> {:error, Error.invalid_params(reason)} + end + end + + defp validate_push_url(url, allow_private?) do + case A2A.Push.validate_url(url, allow_private_networks: allow_private?) do + :ok -> :ok + {:error, reason} -> {:error, Error.invalid_params("Push notification url #{reason}")} + end + end + + defp page_offset(token) when is_binary(token) do + case Integer.parse(token) do + {offset, ""} when offset >= 0 -> offset + _invalid -> 0 + end + end + + defp page_offset(_token), do: 0 + + defp task_ref(task_id, agent, opts), + do: %TaskRef{pubsub: opts.pubsub, router: opts.router, agent_id: agent.id, task_id: task_id} + + defp handle_message(agent, ctx) do + case agent.handler.handle_message(ctx.message, ctx) do + {:reply, %Message{} = message, _ctx} -> + {:ok, %{message | context_id: message.context_id || ctx.context_id}} + + {:reply, %Task{} = task, _ctx} -> + {:ok, task} + + {:error, %Error{} = error, _ctx} -> + {:error, error} + + _other -> + {:error, Error.invalid_agent_response()} + end + end + + defp require_streaming(%{pubsub: nil}), + do: {:error, Error.unsupported_operation("Streaming is not supported")} + + defp require_streaming(_opts), do: :ok + + defp list_filter(params) do + with {:ok, page_size} <- page_size(params["pageSize"]), + :ok <- check_string("contextId", params["contextId"]), + :ok <- check_string("pageToken", params["pageToken"]), + {:ok, status} <- list_status(params["status"]), + {:ok, after_time} <- timestamp(params["statusTimestampAfter"]) do + {:ok, + %{ + context_id: non_empty(params["contextId"]), + status: status, + page_size: page_size, + page_token: non_empty(params["pageToken"]), + status_timestamp_after: after_time + }} + end + end + + defp push_page_size(nil), do: {:ok, 50} + defp push_page_size(size) when is_integer(size) and size > 0, do: {:ok, size} + defp push_page_size(_size), do: {:error, Error.invalid_params("pageSize must be positive")} + + defp page_size(nil), do: {:ok, 50} + defp page_size(size) when is_integer(size) and size in 1..100, do: {:ok, size} + defp page_size(_size), do: {:error, Error.invalid_params("pageSize must be from 1 to 100")} + + defp list_status(status) when status in [nil, "", "TASK_STATE_UNSPECIFIED"], do: {:ok, nil} + + defp list_status(status) do + case TaskStatus.state_from_json(status) do + {:ok, state} -> {:ok, state} + :error -> {:error, Error.invalid_params("unknown status #{inspect(status)}")} + end + end + + defp timestamp(nil), do: {:ok, nil} + + defp timestamp(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> + {:ok, datetime} + + _error -> + {:error, Error.invalid_params("statusTimestampAfter must be an ISO 8601 timestamp")} + end + end + + defp timestamp(_value), + do: {:error, Error.invalid_params("statusTimestampAfter must be an ISO 8601 timestamp")} + + defp check_string(_name, value) when is_binary(value) or is_nil(value), do: :ok + defp check_string(name, _value), do: {:error, Error.invalid_params("#{name} must be a string")} + + # Only the last page's length can stand in for a total the agent didn't give. + defp total_size(%{total_size: total_size}) when is_integer(total_size), do: {:ok, total_size} + + defp total_size(%{tasks: tasks} = page) do + if Map.get(page, :next_page_token) in [nil, ""], + do: {:ok, length(tasks)}, + else: {:error, Error.invalid_agent_response()} + end + + defp non_empty(""), do: nil + defp non_empty(value), do: value + + defp list_tasks(agent, filter, ctx) do + case agent.handler.list_tasks(filter, ctx) do + {:ok, %{tasks: tasks} = page} when is_list(tasks) -> {:ok, page} + {:error, %Error{} = error} -> {:error, error} + _other -> {:error, Error.invalid_agent_response()} + end + end + + defp decode_message(json) do + case Message.from_json(json) do + {:ok, message} -> {:ok, message} + {:error, reason} -> {:error, Error.invalid_params(reason)} + end + end + + defp check_media_types(message, agent) do + modes = agent.card.default_input_modes + + if Enum.all?(message.parts, &(&1.media_type in [nil, ""] or &1.media_type in modes)), + do: :ok, + else: {:error, Error.content_type_not_supported()} + end + + defp existing_task(task_id, agent, ctx, opts \\ []) + defp existing_task(nil, _agent, _ctx, _opts), do: {:ok, nil} + + defp existing_task(task_id, agent, ctx, opts) do + case agent.handler.get_task(task_id, ctx) do + {:ok, %Task{status: %{state: state}} = task} -> + if Events.terminal?(state) and not Keyword.get(opts, :allow_terminal, false), + do: {:error, Error.unsupported_operation("Task #{task_id} is already finished")}, + else: {:ok, task} + + {:error, :not_found} -> + {:error, Error.task_not_found(task_id)} + + _other -> + {:error, Error.invalid_agent_response()} + end + end + + # Canceling again is a no-op; other finished tasks can't be canceled. + defp cancel(%Task{status: %{state: :canceled}} = task, _agent, _ctx), + do: {:ok, Task.to_json(task)} + + defp cancel(%Task{id: id, status: %{state: state}} = task, agent, ctx) do + if Events.terminal?(state) do + {:error, Error.task_not_cancelable(id)} + else + case agent.handler.cancel_task(task, ctx) do + {:ok, %Task{} = task} -> {:ok, Task.to_json(task)} + {:error, :not_cancelable} -> {:error, Error.task_not_cancelable(id)} + _other -> {:error, Error.invalid_agent_response()} + end + end + end + + defp context_id(%Message{context_id: nil}, nil), do: {:ok, A2A.generate_id()} + defp context_id(%Message{context_id: context_id}, nil), do: {:ok, context_id} + defp context_id(%Message{context_id: nil}, %Task{context_id: context_id}), do: {:ok, context_id} + defp context_id(%Message{context_id: id}, %Task{context_id: id}), do: {:ok, id} + + defp context_id(%Message{context_id: requested}, %Task{} = task), + do: {:error, Error.invalid_params("contextId #{requested} does not match task #{task.id}")} + + # Wait until the task finishes or needs the client, unless the client asked to + # return at once: on published events, or by polling when they can't arrive. + defp await(task, agent, ctx, followed, opts) do + if settled?(task) or ctx.configuration["returnImmediately"] == true do + {:ok, task} + else + wait = %{ + ref_task_id: ctx.task_ref.task_id, + deadline: deadline(opts.blocking_timeout), + poll: + if(is_nil(opts.pubsub) or followed == :unavailable, + do: &agent.handler.get_task(&1, ctx) + ) + } + + await_events(task, wait, opts) + end + end + + defp await_events(task, %{ref_task_id: ref_task_id} = wait, opts) do + remaining = remaining(wait.deadline) + # A number is less than `:infinity` in term order, so `min/2` takes the interval. + timeout = if wait.poll, do: min(remaining, opts.poll_interval), else: remaining + + receive do + {:phantom_a2a_event, task_id, event} when task_id == ref_task_id or task_id == task.id -> + await_event(task, event, wait, opts) + + {:"$gen_cast", {:task_updated, task_id}} + when task_id == task.id and is_function(opts.refetch) -> + case opts.refetch.(task_id) do + {:ok, %Task{} = fetched} -> await_event(task, fetched, wait, opts) + _gone -> await_events(task, wait, opts) + end + after + timeout -> + if timeout < remaining, do: poll(task, wait, opts), else: last_poll(task, wait) + end + end + + defp deadline(:infinity), do: :infinity + defp deadline(timeout), do: System.monotonic_time(:millisecond) + timeout + + defp remaining(:infinity), do: :infinity + defp remaining(deadline), do: max(deadline - System.monotonic_time(:millisecond), 0) + + # The task may have settled since the last poll, so look once more. + defp last_poll(task, %{poll: nil}), do: {:ok, task} + + defp last_poll(task, wait) do + with {:ok, %Task{} = fetched} <- wait.poll.(task.id), + {:ok, task} <- follow(task, fetched) do + {:ok, task} + else + _unchanged -> {:ok, task} + end + end + + # An agent that doesn't store its tasks has nothing to wait on. + defp poll(task, wait, opts) do + case wait.poll.(task.id) do + {:ok, %Task{} = fetched} -> await_event(task, fetched, wait, opts) + _not_stored -> {:ok, task} + end + end + + # Apply a published event to this request's copy of the task, skipping + # events the task already includes. Only numbered events move the copy's + # sequence, as the agent's store numbers them. + defp follow(task, event) do + if Events.seen?(task, event) do + :seen + else + with {:ok, applied, _event} <- Events.apply(task, event) do + {:ok, if(numbered?(event), do: applied, else: %{applied | sequence: task.sequence})} + end + end + end + + defp numbered?(%Message{}), do: false + defp numbered?(%Task{sequence: sequence}), do: sequence > 0 + defp numbered?(%{sequence: sequence}), do: is_integer(sequence) + + defp await_event(task, event, wait, opts) do + case follow(task, event) do + :seen -> await_events(task, wait, opts) + {:ok, task} -> if settled?(task), do: {:ok, task}, else: await_events(task, wait, opts) + {:error, :unknown_artifact} -> {:error, Error.invalid_agent_response()} + end + end + + # Listen for `Phantom.Tracker.notify_task_updated/1` on an MCP task. + defp follow_mcp(task_id, %{refetch: refetch}) when is_function(refetch) do + case Phantom.Tracker.subscribe_task(task_id) do + {:error, reason} when reason in [:tracker_not_in_supervision_tree, :not_available] -> + :unavailable + + _tracked -> + :ok + end + end + + defp follow_mcp(_task_id, _opts), do: :ok + + # Nothing would ever close a stream on an unfinished task nobody can follow. + defp start_following(task, adopted, :unavailable) do + if stream_done?(task) do + {:ok, [task], task, adopted} + else + if adopted, do: A2A.unsubscribe(adopted) + {:error, Error.unsupported_operation("Following this task needs Phantom.Tracker")} + end + end + + defp start_following(task, adopted, :ok), do: {:ok, [task], task, adopted} + + defp unfollow_mcp(task_id, %{refetch: refetch}) when is_function(refetch), + do: Phantom.Tracker.unsubscribe_task(task_id) + + defp unfollow_mcp(_task_id, _opts), do: :ok + + ## SSE + + defp start_stream(conn) do + conn + |> put_resp_content_type("text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> send_chunked(200) + end + + defp stream(conn, id, events, task, opts) do + case send_events(conn, id, 1, events, opts) do + {:ok, conn, _n} when is_nil(task) -> + conn + + {:ok, conn, n} -> + if stream_done?(task), do: conn, else: stream_events(conn, id, n, task, opts) + + {:error, conn} -> + conn + end + end + + defp stream_events(conn, id, n, task, opts) do + ref_task_id = opts.ref_task_id + + receive do + {:"$gen_cast", {:task_updated, task_id}} + when task_id == task.id and is_function(opts.refetch) -> + case opts.refetch.(task_id) do + {:ok, %Task{} = fetched} -> enqueue_changes(task, fetched) + _gone -> :ok + end + + stream_events(conn, id, n, task, opts) + + {:phantom_a2a_event, task_id, %Task{} = fetched} + when task_id == ref_task_id or task_id == task.id -> + enqueue_changes(task, fetched) + stream_events(conn, id, n, task, opts) + + {:phantom_a2a_event, task_id, event} when task_id == ref_task_id or task_id == task.id -> + event = fill_ids(event, task) + + with {:ok, task} <- follow(task, event), + {:ok, conn, n} <- send_events(conn, id, n, [event], opts) do + if stream_done?(task), do: conn, else: stream_events(conn, id, n, task, opts) + else + :seen -> + stream_events(conn, id, n, task, opts) + + {:error, :unknown_artifact} -> + error = Error.invalid_agent_response() + {_result, conn} = send_sse(conn, n, error_payload(id, error, 200, opts)) + conn + + {:error, conn} -> + conn + end + after + opts.keepalive -> + case chunk(conn, ": keepalive\n\n") do + {:ok, conn} -> stream_events(conn, id, n, task, opts) + {:error, _reason} -> conn + end + end + end + + # After its first event, a stream only carries update events, so a newer copy + # of the task goes out as what changed: artifacts, then the status that may + # close the stream. + defp enqueue_changes(task, fetched) do + if not Events.seen?(task, fetched) do + task + |> task_changes(fetched) + |> number_last(fetched.sequence) + |> Enum.each(&send(self(), {:phantom_a2a_event, task.id, &1})) + end + end + + defp task_changes(task, fetched) do + artifacts = + for artifact <- fetched.artifacts, artifact not in task.artifacts do + %ArtifactUpdate{ + task_id: task.id, + context_id: task.context_id, + artifact: artifact, + last_chunk: true + } + end + + status = + if fetched.status == task.status, + do: [], + else: [ + %StatusUpdate{task_id: task.id, context_id: task.context_id, status: fetched.status} + ] + + artifacts ++ status + end + + defp number_last([], _sequence), do: [] + defp number_last(events, 0), do: events + defp number_last(events, sequence), do: List.update_at(events, -1, &%{&1 | sequence: sequence}) + + defp send_events(conn, _id, n, [], _opts), do: {:ok, conn, n} + + defp send_events(conn, id, n, [event | rest], opts) do + json = A2A.event_to_json(event, history_length: opts[:history_length]) + + body = + case opts do + %{binding: :http_json} -> json + _ -> %{"jsonrpc" => "2.0", "id" => id, "result" => json} + end + + case send_sse(conn, n, body) do + {:ok, conn} -> send_events(conn, id, n + 1, rest, opts) + {:error, conn} -> {:error, conn} + end + end + + defp send_sse(conn, n, body) do + id = if n, do: ["id: ", Integer.to_string(n), "\n"], else: [] + + case chunk(conn, [id, "data: ", JSON.encode!(body), "\n\n"]) do + {:ok, conn} -> {:ok, conn} + {:error, _reason} -> {:error, conn} + end + end + + defp fill_ids(%{task_id: nil} = event, task), do: fill_ids(%{event | task_id: task.id}, task) + + defp fill_ids(%{context_id: nil} = event, task), + do: fill_ids(%{event | context_id: task.context_id}, task) + + defp fill_ids(event, _task), do: event + + defp settled?(%Task{status: %{state: state}}), + do: Events.terminal?(state) or Events.interrupted?(state) + + # A stream stays open through AUTH_REQUIRED, as the agent may get the + # credential out of band and carry on (§7.6.1), and closes at INPUT_REQUIRED, + # which only the client's next message can answer. + defp stream_done?(%Task{status: %{state: state}}), + do: Events.terminal?(state) or state == :input_required +end diff --git a/lib/phantom/a2a/push.ex b/lib/phantom/a2a/push.ex new file mode 100644 index 0000000..953194d --- /dev/null +++ b/lib/phantom/a2a/push.ex @@ -0,0 +1,256 @@ +defmodule Phantom.A2A.Push do + @moduledoc """ + Delivers A2A push notifications: each event about a task is `POST`ed as + JSON to the task's webhooks. + + A webhook URL comes from a client, so Phantom checks it when a config is + created and again before each delivery: it must use `https`, and every + address its host resolves to must be public, so a client can't make the + agent call into its own network. Delivery connects to the address that was + checked, doesn't follow redirects, and sends: + + * `Authorization` - the config's `authentication`, such as `Bearer secret` + * `X-A2A-Notification-Token` - the config's `token` + + Set `a2a: [allow_private_push_urls: true]` on the router to allow `http` and + private addresses, such as in development. + + Each webhook gets a task's events in order, one at a time. A delivery that + fails on the network or with a 408, 429, or 5xx answer is retried after 1, 5, + and 30 seconds. Set `a2a: [push_retry_backoff: [...]]` on the router to change + the delays, in milliseconds. Up to 100 events wait for each webhook, and later + events are dropped. Events that wait are lost when the node stops. For durable + delivery, override `c:Phantom.A2A.Agent.deliver_push/3`. + + Phantom doesn't read the body of a 200 answer. + + Each delivery emits a `[:phantom, :a2a, :push]` telemetry span, with + metadata `~w[config_id task_id url event]a` and, on `:stop`, `:result`. + """ + + import Bitwise + + alias Phantom.A2A.{PushConfig, TaskRef} + + @timeout :timer.seconds(10) + @profile :phantom_a2a_push + @retry_backoff [:timer.seconds(1), :timer.seconds(5), :timer.seconds(30)] + + @doc """ + Check a webhook URL. + + ## Options + + * `:allow_private_networks` - allow `http` and private addresses + """ + def validate_url(url, opts \\ []) do + with {:ok, _uri, _addresses} <- resolve(url, opts), do: :ok + end + + @doc """ + Deliver an event to a webhook and wait for the answer. Returns `:ok` for a + 2xx answer. Takes the options of `validate_url/2`. + """ + def deliver(%PushConfig{} = config, event, opts \\ []) do + meta = %{config_id: config.id, task_id: config.task_id, url: config.url, event: event} + + :telemetry.span([:phantom, :a2a, :push], meta, fn -> + result = post(config, event, opts) + {result, %{}, Map.put(meta, :result, result)} + end) + end + + defp post(config, event, opts) do + with {:ok, uri, [address | _]} <- resolve(config.url, opts) do + request = { + address_url(uri, address), + headers(config, uri), + ~c"application/a2a+json", + JSON.encode!(Phantom.A2A.event_to_json(event)) + } + + http_opts = [timeout: @timeout, connect_timeout: @timeout, autoredirect: false] ++ ssl(uri) + + case :httpc.request(:post, request, http_opts, [sync: false, stream: :self], @profile) do + {:ok, request_id} -> await_answer(request_id) + {:error, reason} -> {:error, reason} + end + end + end + + # `:httpc` streams only the body of a 200 answer. + defp await_answer(request_id) do + receive do + {:http, {^request_id, :stream_start, _headers}} -> + :httpc.cancel_request(request_id, @profile) + :ok + + {:http, {^request_id, {{_version, status, _reason}, _headers, _body}}} + when status in 200..299 -> + :ok + + {:http, {^request_id, {{_version, status, _reason}, _headers, _body}}} -> + {:error, {:status, status}} + + {:http, {^request_id, {:error, reason}}} -> + {:error, reason} + after + 2 * @timeout -> + :httpc.cancel_request(request_id, @profile) + {:error, :timeout} + end + end + + @doc false + # This profile reaches IPv6-only webhooks and keeps the default `:httpc` profile unchanged. + def start_profile do + case :inets.start(:httpc, profile: @profile) do + {:ok, _pid} -> :httpc.set_options([ipfamily: :inet6fb4], @profile) + {:error, {:already_started, _pid}} -> :ok + end + end + + @doc false + # The default `c:Phantom.A2A.Agent.deliver_push/3`. + def deliver_async(%PushConfig{} = config, event, %TaskRef{router: router} = ref) do + a2a = router.__phantom__(:info).a2a + opts = [allow_private_networks: a2a[:allow_private_push_urls] == true] + backoff = Keyword.get(a2a, :push_retry_backoff, @retry_backoff) + key = {router, ref.agent_id, config.task_id, config.id} + + Phantom.A2A.Push.Delivery.push(key, {config, event}, opts, backoff) + :ok + end + + defp resolve(url, opts) do + allow_private? = Keyword.get(opts, :allow_private_networks, false) + + with {:ok, %URI{scheme: scheme, host: host} = uri} when is_binary(host) and host != "" <- + URI.new(url), + :ok <- check_scheme(scheme, allow_private?), + {:ok, addresses} <- addresses(host), + :ok <- check_addresses(addresses, allow_private?) do + {:ok, uri, addresses} + else + {:error, reason} when is_binary(reason) -> {:error, reason} + _ -> {:error, "is not a valid URL"} + end + end + + defp check_scheme("https", _allow_private?), do: :ok + defp check_scheme("http", true), do: :ok + defp check_scheme(_scheme, _allow_private?), do: {:error, "must use https"} + + defp addresses(host) do + host = host |> String.trim_leading("[") |> String.trim_trailing("]") |> to_charlist() + + case :inet.parse_address(host) do + {:ok, address} -> + {:ok, [address]} + + {:error, _} -> + case Enum.flat_map([:inet, :inet6], &lookup(host, &1)) do + [] -> {:error, "has a host that doesn't resolve"} + addresses -> {:ok, addresses} + end + end + end + + defp lookup(host, family) do + case :inet.getaddrs(host, family) do + {:ok, addresses} -> addresses + {:error, _reason} -> [] + end + end + + defp check_addresses(_addresses, true), do: :ok + + defp check_addresses(addresses, false) do + if Enum.any?(addresses, &private?/1), + do: {:error, "resolves to a private address"}, + else: :ok + end + + # Loopback, private, link-local, shared (CGNAT), benchmarking, multicast, + # and reserved ranges, which no webhook should need. + defp private?({a, b, c, _d}) do + a in [0, 10, 127] or a >= 224 or + (a == 100 and b in 64..127) or + (a == 169 and b == 254) or + (a == 172 and b in 16..31) or + (a == 192 and b == 168) or + (a == 192 and b == 0 and c in [0, 2]) or + (a == 192 and b == 88 and c == 99) or + (a == 198 and b in 18..19) or + (a == 198 and b == 51 and c == 100) or + (a == 203 and b == 0 and c == 113) + end + + # Unspecified, loopback, and the deprecated IPv4-compatible range. + defp private?({0, 0, 0, 0, 0, 0, _, _}), do: true + defp private?({0, 0, 0, 0, 0, 0xFFFF, x, y}), do: private?(v4(x, y)) + defp private?({0, 0, 0, 0, 0xFFFF, 0, x, y}), do: private?(v4(x, y)) + defp private?({0x64, 0xFF9B, 0, 0, 0, 0, x, y}), do: private?(v4(x, y)) + # Local-use NAT64 and discard-only addresses. + defp private?({0x64, 0xFF9B, 1, _, _, _, _, _}), do: true + defp private?({0x100, 0, 0, 0, _, _, _, _}), do: true + # 6to4 embeds an IPv4 address; Teredo and documentation addresses have no use here. + defp private?({0x2002, x, y, _, _, _, _, _}), do: private?(v4(x, y)) + defp private?({0x2001, second, _, _, _, _, _, _}) when second in [0, 0xDB8], do: true + + # Unique local, link-local, site-local, and multicast. + defp private?({first, _, _, _, _, _, _, _}) do + (first &&& 0xFE00) == 0xFC00 or (first &&& 0xFFC0) == 0xFE80 or + (first &&& 0xFFC0) == 0xFEC0 or (first &&& 0xFF00) == 0xFF00 + end + + defp v4(x, y), do: {x >>> 8, x &&& 0xFF, y >>> 8, y &&& 0xFF} + + # Connect to the address that was checked, so a second DNS lookup can't + # point the request elsewhere. TLS still checks the certificate for the host. + defp address_url(uri, address) do + host = address |> :inet.ntoa() |> to_string() + + %{uri | host: host} |> URI.to_string() |> to_charlist() + end + + defp headers(config, uri) do + name = if String.contains?(uri.host, ":"), do: "[#{uri.host}]", else: uri.host + host = if uri.port == URI.default_port(uri.scheme), do: name, else: "#{name}:#{uri.port}" + + [{~c"host", to_charlist(host)}] + |> add_header(~c"authorization", authorization(config.authentication)) + |> add_header(~c"x-a2a-notification-token", config.token) + end + + defp authorization(%{scheme: scheme, credentials: credentials}) when is_binary(credentials), + do: "#{scheme} #{credentials}" + + defp authorization(_authentication), do: nil + + defp add_header(headers, _name, nil), do: headers + defp add_header(headers, name, value), do: [{name, to_charlist(value)} | headers] + + defp ssl(%URI{scheme: "https", host: host}) do + host = to_charlist(host) + + sni = + case :inet.parse_address(host) do + {:ok, _ip} -> [] + {:error, _} -> [server_name_indication: host] + end + + [ + ssl: + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ] + ] ++ sni + ] + end + + defp ssl(_uri), do: [] +end diff --git a/lib/phantom/a2a/push/delivery.ex b/lib/phantom/a2a/push/delivery.ex new file mode 100644 index 0000000..b631e29 --- /dev/null +++ b/lib/phantom/a2a/push/delivery.ex @@ -0,0 +1,130 @@ +defmodule Phantom.A2A.Push.Delivery do + @moduledoc false + # Delivers the events of one task to one webhook, in order and one at a time. + + use GenServer, restart: :temporary + + require Logger + + alias Phantom.A2A.Push + + @registry Phantom.A2A.PushRegistry + @supervisor Phantom.A2A.PushSupervisor + @max_queue 100 + + def push(key, item, opts, backoff) do + case Registry.lookup(@registry, key) do + [{pid, _value}] -> enqueue(pid, key, item, opts, backoff) + [] -> start(key, item, opts, backoff) + end + end + + defp start(key, {config, _event} = item, opts, backoff) do + case DynamicSupervisor.start_child(@supervisor, {__MODULE__, {key, item, opts, backoff}}) do + {:ok, _pid} -> + :ok + + {:error, {:already_started, pid}} -> + enqueue(pid, key, item, opts, backoff) + + {:error, reason} -> + Logger.warning("A2A push to #{redact(config.url)} dropped: #{inspect(reason)}") + end + end + + # The old process unregisters before it stops, so a failed call starts a new one. + defp enqueue(pid, key, item, opts, backoff) do + GenServer.call(pid, {:push, item}) + catch + :exit, _reason -> start(key, item, opts, backoff) + end + + def start_link({key, item, opts, backoff}) do + state = %{ + key: key, + queue: :queue.from_list([item]), + opts: opts, + retry_backoff: backoff, + backoff: backoff, + attempt: nil + } + + GenServer.start_link(__MODULE__, state, name: {:via, Registry, {@registry, key}}) + end + + @impl GenServer + def init(state), do: {:ok, state, {:continue, :deliver}} + + @impl GenServer + def handle_call({:push, {config, _event} = item}, _from, state) do + if :queue.len(state.queue) >= @max_queue do + Logger.warning("A2A push to #{redact(config.url)} dropped: #{@max_queue} events waiting") + {:reply, :ok, state} + else + {:reply, :ok, %{state | queue: :queue.in(item, state.queue)}} + end + end + + @impl GenServer + def handle_continue(:deliver, state) do + case :queue.peek(state.queue) do + {:value, {config, event}} -> + task = + Task.Supervisor.async_nolink(Phantom.A2A.PushTasks, Push, :deliver, [ + config, + event, + state.opts + ]) + + {:noreply, %{state | attempt: {:task, task.ref}}} + + :empty -> + Registry.unregister(@registry, state.key) + {:stop, :normal, state} + end + end + + @impl GenServer + def handle_info({ref, result}, %{attempt: {:task, ref}} = state) do + Process.demonitor(ref, [:flush]) + handle_result(result, state) + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{attempt: {:task, ref}} = state), + do: handle_result({:error, reason}, state) + + def handle_info(:retry, %{attempt: :waiting} = state), + do: {:noreply, %{state | attempt: nil}, {:continue, :deliver}} + + defp handle_result(:ok, state), do: next(state) + + defp handle_result({:error, reason}, state) do + case {retryable?(reason), state.backoff} do + {true, [delay | rest]} -> + Process.send_after(self(), :retry, delay) + {:noreply, %{state | attempt: :waiting, backoff: rest}} + + _no_retry -> + {:value, {config, _event}} = :queue.peek(state.queue) + Logger.warning("A2A push to #{redact(config.url)} failed: #{inspect(reason)}") + next(state) + end + end + + defp next(state) do + state = %{ + state + | queue: :queue.drop(state.queue), + backoff: state.retry_backoff, + attempt: nil + } + + {:noreply, state, {:continue, :deliver}} + end + + defp retryable?(reason) when is_binary(reason), do: false + defp retryable?({:status, status}), do: status in [408, 429] or status >= 500 + defp retryable?(_network_error), do: true + + defp redact(url), do: URI.to_string(%{URI.parse(url) | query: nil, userinfo: nil}) +end diff --git a/lib/phantom/a2a/push_config.ex b/lib/phantom/a2a/push_config.ex new file mode 100644 index 0000000..4eca341 --- /dev/null +++ b/lib/phantom/a2a/push_config.ex @@ -0,0 +1,89 @@ +defmodule Phantom.A2A.PushConfig do + @moduledoc """ + Where to push a task's updates: a client's webhook `url`, with an optional + `token` and `authentication` to send with each request. + + The agent stores configs; see the push callbacks of `Phantom.A2A.Agent`. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + @enforce_keys [:url] + defstruct [:id, :task_id, :url, :token, :authentication, :tenant] + + @type t :: %__MODULE__{ + id: String.t() | nil, + task_id: String.t() | nil, + url: String.t(), + token: String.t() | nil, + authentication: %{scheme: String.t(), credentials: String.t() | nil} | nil, + tenant: String.t() | nil + } + + @doc false + def from_json(%{"url" => url} = json) when is_binary(url) do + with :ok <- check_strings(json), + {:ok, authentication} <- authentication(json["authentication"]) do + {:ok, + %__MODULE__{ + id: json["id"], + task_id: json["taskId"], + url: url, + token: json["token"], + authentication: authentication, + tenant: json["tenant"] + }} + end + end + + def from_json(_json), do: {:error, "push notification config needs a url"} + + defp check_strings(json) do + cond do + not Enum.all?(~w[id taskId token tenant], &(is_nil(json[&1]) or is_binary(json[&1]))) -> + {:error, "push notification config has a field of the wrong type"} + + not header_value?(json["token"]) -> + {:error, "push notification token has a line break"} + + true -> + :ok + end + end + + defp authentication(nil), do: {:ok, nil} + + defp authentication(%{"scheme" => scheme, "credentials" => credentials}) + when is_binary(scheme) and (is_binary(credentials) or is_nil(credentials)) do + if header_value?(scheme) and header_value?(credentials), + do: {:ok, %{scheme: scheme, credentials: credentials}}, + else: {:error, "authentication has a line break"} + end + + defp authentication(%{"scheme" => scheme} = json) + when is_binary(scheme) and not is_map_key(json, "credentials"), + do: authentication(Map.put(json, "credentials", nil)) + + defp authentication(_json), do: {:error, "authentication needs a scheme"} + + @doc false + def to_json(%__MODULE__{} = config) do + remove_nils(%{ + "id" => config.id, + "taskId" => config.task_id, + "url" => config.url, + "token" => config.token, + "tenant" => config.tenant, + "authentication" => + config.authentication && + remove_nils(%{ + "scheme" => config.authentication.scheme, + "credentials" => config.authentication.credentials + }) + }) + end + + # These values go into webhook request headers. + defp header_value?(nil), do: true + defp header_value?(value), do: not String.contains?(value, ["\r", "\n", <<0>>]) +end diff --git a/lib/phantom/a2a/status_update.ex b/lib/phantom/a2a/status_update.ex new file mode 100644 index 0000000..faaa8f6 --- /dev/null +++ b/lib/phantom/a2a/status_update.ex @@ -0,0 +1,46 @@ +defmodule Phantom.A2A.StatusUpdate do + @moduledoc """ + An event that changes a task's status. Build one with + `Phantom.A2A.Task.status/3` and send it with `Phantom.A2A.publish/2`. + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.TaskStatus + + @enforce_keys [:status] + defstruct [:task_id, :context_id, :status, :metadata, :sequence] + + @type t :: %__MODULE__{ + task_id: String.t() | nil, + context_id: String.t() | nil, + status: TaskStatus.t(), + metadata: map() | nil, + sequence: pos_integer() | nil + } + + @doc false + def from_json(%{"status" => status} = json) do + with {:ok, status} <- TaskStatus.from_json(status) do + {:ok, + %__MODULE__{ + task_id: json["taskId"], + context_id: json["contextId"], + status: status, + metadata: json["metadata"] + }} + end + end + + def from_json(_json), do: {:error, "status update needs a status"} + + @doc false + def to_json(%__MODULE__{} = update) do + remove_nils(%{ + "taskId" => update.task_id, + "contextId" => update.context_id, + "status" => TaskStatus.to_json(update.status, update.task_id, update.context_id), + "metadata" => update.metadata + }) + end +end diff --git a/lib/phantom/a2a/task.ex b/lib/phantom/a2a/task.ex new file mode 100644 index 0000000..117c7d3 --- /dev/null +++ b/lib/phantom/a2a/task.ex @@ -0,0 +1,114 @@ +defmodule Phantom.A2A.Task do + @moduledoc """ + A unit of work an A2A agent tracks for a client. + + Phantom doesn't store tasks. The agent stores them, returns them from + `c:Phantom.A2A.Agent.handle_message/2` and `c:Phantom.A2A.Agent.get_task/2`, + and reports progress with `Phantom.A2A.publish/2`: + + def handle_message(message, ctx) do + {:ok, _job} = MyApp.Reviews.start(ctx.task_ref, message) + {:reply, Phantom.A2A.Task.submitted(ctx), ctx} + end + + # Later, wherever the work runs: + Phantom.A2A.publish(task_ref, Phantom.A2A.Task.status(:working, "Reading study…")) + """ + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.{Artifact, Context, Message, Part, StatusUpdate, TaskStatus} + + @enforce_keys [:id, :status] + defstruct [:id, :context_id, :status, :metadata, artifacts: [], history: [], sequence: 0] + + @type t :: %__MODULE__{ + id: String.t(), + context_id: String.t(), + status: TaskStatus.t(), + metadata: map() | nil, + artifacts: [Artifact.t()], + history: [Message.t()], + sequence: non_neg_integer() + } + + @doc """ + A new task for the request in `ctx`, in the `submitted` state, with the + client's message as its history. `opts` sets other fields. + """ + def submitted(%Context{} = ctx, opts \\ []) do + struct!( + __MODULE__, + [ + id: ctx.task_ref.task_id, + context_id: ctx.context_id, + status: %TaskStatus{state: :submitted, timestamp: DateTime.utc_now()}, + history: [ctx.message] + ] ++ opts + ) + end + + @doc """ + An event that moves a task to `state`. `message` is text or a + `Phantom.A2A.Message` from the agent, such as the question for + `:input_required`. + + ## Options + + * `:metadata` - metadata for the event + """ + def status(state, message \\ nil, opts \\ []) when state in unquote(TaskStatus.states()) do + message = if is_binary(message), do: Message.agent(message), else: message + + %StatusUpdate{ + status: %TaskStatus{state: state, message: message, timestamp: DateTime.utc_now()}, + metadata: opts[:metadata] + } + end + + @doc false + def from_json(%{"id" => id, "status" => status} = json) when is_binary(id) do + with {:ok, context_id} <- context_id(json["contextId"]), + {:ok, status} <- TaskStatus.from_json(status), + {:ok, artifacts} <- Part.list_from_json(json["artifacts"] || [], &Artifact.from_json/1), + {:ok, history} <- Part.list_from_json(json["history"] || [], &Message.from_json/1) do + {:ok, + %__MODULE__{ + id: id, + context_id: context_id, + status: status, + metadata: json["metadata"], + artifacts: artifacts, + history: history + }} + end + end + + def from_json(_json), do: {:error, "task needs an id and a status"} + + defp context_id(id) when is_binary(id) or is_nil(id), do: {:ok, id} + defp context_id(_id), do: {:error, "task contextId must be a string"} + + @doc false + def to_json(%__MODULE__{} = task, opts \\ []) do + remove_nils(%{ + "id" => task.id, + "contextId" => task.context_id, + "status" => TaskStatus.to_json(task.status, task.id, task.context_id), + "metadata" => task.metadata, + "artifacts" => artifacts_json(task.artifacts, Keyword.get(opts, :include_artifacts, true)), + "history" => history_json(trim_history(task.history, opts[:history_length])) + }) + end + + defp artifacts_json([], _include?), do: nil + defp artifacts_json(_artifacts, false), do: nil + defp artifacts_json(artifacts, true), do: Enum.map(artifacts, &Artifact.to_json/1) + + defp history_json([]), do: nil + defp history_json(history), do: Enum.map(history, &Message.to_json/1) + + defp trim_history(history, nil), do: history + defp trim_history(_history, length) when length <= 0, do: [] + defp trim_history(history, length), do: Enum.take(history, -length) +end diff --git a/lib/phantom/a2a/task_ref.ex b/lib/phantom/a2a/task_ref.ex new file mode 100644 index 0000000..9346705 --- /dev/null +++ b/lib/phantom/a2a/task_ref.ex @@ -0,0 +1,48 @@ +defmodule Phantom.A2A.TaskRef do + @moduledoc """ + Addresses an A2A task for `Phantom.A2A.publish/2`. + + Pass it to wherever the task's work runs. To store it, such as in an Oban + job's args, use `encode/1` and `decode/1`. + """ + + @enforce_keys [:router, :agent_id, :task_id] + defstruct [:pubsub, :router, :agent_id, :task_id, :context_id] + + @type t :: %__MODULE__{ + pubsub: atom() | nil, + router: module(), + agent_id: String.t(), + task_id: String.t(), + context_id: String.t() | nil + } + + @doc "Encode as a string" + def encode(%__MODULE__{} = ref) do + JSON.encode!([ + Atom.to_string(ref.pubsub), + Atom.to_string(ref.router), + ref.agent_id, + ref.task_id, + ref.context_id + ]) + end + + @doc "Decode a string from `encode/1`. Only atoms that already exist are accepted." + def decode(encoded) when is_binary(encoded) do + with {:ok, [pubsub, router, agent_id, task_id, context_id]} <- JSON.decode(encoded) do + {:ok, + %__MODULE__{ + pubsub: String.to_existing_atom(pubsub), + router: String.to_existing_atom(router), + agent_id: agent_id, + task_id: task_id, + context_id: context_id + }} + else + _ -> {:error, :invalid} + end + rescue + ArgumentError -> {:error, :invalid} + end +end diff --git a/lib/phantom/a2a/task_status.ex b/lib/phantom/a2a/task_status.ex new file mode 100644 index 0000000..941cfd1 --- /dev/null +++ b/lib/phantom/a2a/task_status.ex @@ -0,0 +1,90 @@ +defmodule Phantom.A2A.TaskStatus do + @moduledoc "The state of an A2A task, with an optional message from the agent." + + import Phantom.Utils, only: [remove_nils: 1] + + alias Phantom.A2A.Message + + @enforce_keys [:state] + defstruct [:state, :message, :timestamp] + + @type state :: + :submitted + | :working + | :input_required + | :auth_required + | :completed + | :failed + | :canceled + | :rejected + + @type t :: %__MODULE__{ + state: state(), + message: Message.t() | nil, + timestamp: DateTime.t() | nil + } + + @states ~w[submitted working input_required auth_required completed failed canceled rejected]a + + @doc false + def states, do: @states + + @doc false + def state_from_json("TASK_STATE_" <> name) do + case Enum.find(@states, &(Atom.to_string(&1) == String.downcase(name))) do + nil -> :error + state -> {:ok, state} + end + end + + def state_from_json(_json), do: :error + + @doc false + def from_json(%{"state" => state} = json) do + with {:ok, state} <- state_from_json(state), + {:ok, message} <- optional(json["message"], &Message.from_json/1), + {:ok, timestamp} <- timestamp(json["timestamp"]) do + {:ok, %__MODULE__{state: state, message: message, timestamp: timestamp}} + else + :error -> {:error, "unknown task state #{inspect(state)}"} + error -> error + end + end + + def from_json(_json), do: {:error, "task status needs a state"} + + defp optional(nil, _decode), do: {:ok, nil} + defp optional(json, decode), do: decode.(json) + + defp timestamp(nil), do: {:ok, nil} + + defp timestamp(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> {:ok, datetime} + _error -> {:error, "invalid timestamp #{inspect(value)}"} + end + end + + defp timestamp(value), do: {:error, "invalid timestamp #{inspect(value)}"} + + @doc false + # A status message names its task and context, unless it already does. + def to_json(%__MODULE__{state: state} = status, task_id \\ nil, context_id \\ nil) + when state in @states do + remove_nils(%{ + "state" => "TASK_STATE_" <> String.upcase(Atom.to_string(state)), + "message" => message_json(status.message, task_id, context_id), + "timestamp" => status.timestamp && DateTime.to_iso8601(status.timestamp) + }) + end + + defp message_json(nil, _task_id, _context_id), do: nil + + defp message_json(%Message{} = message, task_id, context_id) do + Message.to_json(%{ + message + | task_id: message.task_id || task_id, + context_id: message.context_id || context_id + }) + end +end diff --git a/lib/phantom/a2a/test.ex b/lib/phantom/a2a/test.ex new file mode 100644 index 0000000..74738d6 --- /dev/null +++ b/lib/phantom/a2a/test.ex @@ -0,0 +1,115 @@ +defmodule Phantom.A2A.Test do + @moduledoc """ + Test helpers for A2A agents served from a `Phantom.Router`. + + Each helper sends a request through `Phantom.A2A.Plug` in the test process, + so `c:Phantom.Router.connect/2`, allow-lists, and validation all run. + Results are the decoded JSON the client would receive. + + import Phantom.A2A.Test + + test "reviews a study" do + assert {:task, %{"id" => task_id}} = + send_message(MyApp.MCP.Router, "research", "Review study 42", + headers: [{"authorization", "Bearer " <> token}] + ) + end + + ## Options + + * `:headers` - request headers, such as `authorization` + * `:pubsub`, `:blocking_timeout`, `:poll_interval` - passed to `Phantom.A2A.Plug` + * `:configuration` - the `SendMessage` configuration, for `send_message/4` + """ + + alias Phantom.A2A.Message + + @doc "Fetch an agent's card" + def get_card(router, agent_id, opts \\ []) do + conn = request(:get, router, agent_id <> "/agent-card.json", nil, opts) + JSON.decode!(conn.resp_body) + end + + @doc """ + Send a message, given as text or a `Phantom.A2A.Message`. Returns + `{:message, json}`, `{:task, json}`, or `{:error, json}`. + """ + def send_message(router, agent_id, message, opts \\ []) + + def send_message(router, agent_id, text, opts) when is_binary(text), + do: send_message(router, agent_id, Message.user(text), opts) + + def send_message(router, agent_id, %Message{} = message, opts) do + params = + Phantom.Utils.remove_nils(%{ + "message" => Message.to_json(message), + "configuration" => opts[:configuration] + }) + + case rpc(router, agent_id, "SendMessage", params, opts) do + {:ok, %{"message" => message}} -> {:message, message} + {:ok, %{"task" => task}} -> {:task, task} + error -> error + end + end + + @doc """ + Call the router's `c:Phantom.Router.list_agents/2` with a session. + + ## Options + + * `:assigns` - the session's assigns + * `:allowed_agents` - as `Phantom.Session.allow_agents/2` sets + * `:cursor` - the page to list + """ + def list_agents(router, opts \\ []) do + session = + Phantom.Session.new(nil, + router: router, + assigns: Keyword.get(opts, :assigns, %{}), + allowed_agents: opts[:allowed_agents] + ) + + router.list_agents(opts[:cursor], session) + end + + @doc "Fetch a task. Returns `{:ok, json}` or `{:error, json}`." + def get_task(router, agent_id, task_id, opts \\ []), + do: rpc(router, agent_id, "GetTask", %{"id" => task_id}, opts) + + @doc "Cancel a task. Returns `{:ok, json}` or `{:error, json}`." + def cancel_task(router, agent_id, task_id, opts \\ []), + do: rpc(router, agent_id, "CancelTask", %{"id" => task_id}, opts) + + defp rpc(router, agent_id, method, params, opts) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + case JSON.decode!(request(:post, router, agent_id, body, opts).resp_body) do + %{"result" => result} -> {:ok, result} + %{"error" => error} -> {:error, error} + end + end + + defp request(method, router, path, body, opts) do + # Without a pubsub there is nothing to publish to, so answer with the task + # as it is, rather than poll the agent's store for the whole timeout. + defaults = if opts[:pubsub], do: [], else: [blocking_timeout: 0] + + plug_opts = + Phantom.A2A.Plug.init( + [router: router, validate_origin: false] + |> Keyword.merge(defaults) + |> Keyword.merge(Keyword.take(opts, [:pubsub, :blocking_timeout, :poll_interval])) + ) + + headers = [{"a2a-version", "1.0"} | Keyword.get(opts, :headers, [])] + + method + |> Plug.Test.conn("/" <> path) + |> Map.merge(%{body_params: body || %{}, params: body || %{}}) + |> then(fn conn -> + Enum.reduce(headers, conn, fn {k, v}, conn -> Plug.Conn.put_req_header(conn, k, v) end) + end) + |> Phantom.A2A.Plug.call(plug_opts) + end +end diff --git a/lib/phantom/application.ex b/lib/phantom/application.ex index af2d1a8..b68030d 100644 --- a/lib/phantom/application.ex +++ b/lib/phantom/application.ex @@ -4,7 +4,15 @@ defmodule Phantom.Application do @impl Application def start(_type, _args) do - Supervisor.start_link([Phantom.SessionMeta], + :ok = Phantom.A2A.Push.start_profile() + + Supervisor.start_link( + [ + Phantom.SessionMeta, + {Registry, keys: :unique, name: Phantom.A2A.PushRegistry}, + {Task.Supervisor, name: Phantom.A2A.PushTasks}, + {DynamicSupervisor, name: Phantom.A2A.PushSupervisor, max_children: 10_000} + ], strategy: :one_for_one, name: Phantom.Supervisor ) diff --git a/lib/phantom/plug.ex b/lib/phantom/plug.ex index 62b6883..5b8f4eb 100644 --- a/lib/phantom/plug.ex +++ b/lib/phantom/plug.ex @@ -1152,11 +1152,12 @@ defmodule Phantom.Plug do end end - defp valid_origin?(_origin, %{validate_origin: false}), do: true - defp valid_origin?(_origin, %{origins: :all}), do: true - defp valid_origin?(nil, _opts), do: true + @doc false + def valid_origin?(_origin, %{validate_origin: false}), do: true + def valid_origin?(_origin, %{origins: :all}), do: true + def valid_origin?(nil, _opts), do: true - defp valid_origin?(origin, opts) do + def valid_origin?(origin, opts) do case opts[:origins] do :all -> true origins when is_list(origins) -> origin in origins @@ -1165,10 +1166,11 @@ defmodule Phantom.Plug do end end - defp valid_host?(_host, :all), do: true - defp valid_host?(host, {m, f, a}), do: apply(m, f, [host | a]) == true + @doc false + def valid_host?(_host, :all), do: true + def valid_host?(host, {m, f, a}), do: apply(m, f, [host | a]) == true - defp valid_host?(host, hosts) when is_list(hosts), + def valid_host?(host, hosts) when is_list(hosts), do: normalize_host(host) in Enum.map(hosts, &normalize_host/1) # Hosts are case-insensitive, and adapters differ on keeping IPv6 brackets. @@ -1259,10 +1261,9 @@ defmodule Phantom.Plug do iex> Phantom.Plug.www_authenticate(%{ ...> method: "Bearer", - ...> resource_metadata: "https://myapp.com/.well-known/oauth-protected-resource", - ...> max_age: 42000 + ...> resource_metadata: "https://myapp.com/.well-known/oauth-protected-resource" ...> }) - ~s|Bearer max_age="42000", resource_metadata="https://myapp.com/.well-known/oauth-protected-resource"| + ~s|Bearer resource_metadata="https://myapp.com/.well-known/oauth-protected-resource"| https://datatracker.ietf.org/doc/html/rfc9728#name-use-of-www-authenticate-for """ diff --git a/lib/phantom/router.ex b/lib/phantom/router.ex index e90d7f0..cfd8e02 100644 --- a/lib/phantom/router.ex +++ b/lib/phantom/router.ex @@ -19,6 +19,10 @@ defmodule Phantom.Router do `config/runtime.exs`, or `{MyAppWeb.Endpoint, :config, [:secret_key_base]}`. - `:request_state_salt` — required with `:secret_key_base`; a stable string used to derive the `requestState` key. + - `:extensions` — a map of extension identifiers to their settings, added to + `capabilities.extensions` in `initialize` and `server/discover`. Use it for + extensions your router supports that Phantom doesn't advertise itself, such + as `%{"io.modelcontextprotocol/oauth-client-credentials" => %{}}`. ## Telemetry @@ -182,6 +186,27 @@ defmodule Phantom.Router do Session.t()} | {:error, any(), Session.t()} + @doc """ + List the paths of the A2A agents the session may use, a page at a time. + Return the paths of one page and the cursor of the next, or `nil` on the + last page. The cursor is opaque to Phantom. + + The default lists every `agent` route without path params that + `Phantom.Session.allow_agents/2` allows. Override it to add the agents your + routes with path params resolve: + + def list_agents(_cursor, session) do + hosted = for a <- MyApp.Agents.for_org(session.assigns.org), do: "\#{a.org}/\#{a.key}" + {:ok, ["research" | hosted], nil} + end + + Phantom doesn't serve the list over A2A, which has no method for it; use it + to build a directory of your agents, or in tests with + `Phantom.A2A.Test.list_agents/2`. + """ + @callback list_agents(cursor :: String.t() | nil, Session.t()) :: + {:ok, [String.t()], next_cursor :: String.t() | nil} + @doc """ Authorize subscriptions and update notifications for resolved resources. @@ -278,6 +303,8 @@ defmodule Phantom.Router do website_url = Keyword.get(opts, :website_url, nil) secret_key_base = Keyword.get(opts, :secret_key_base, nil) request_state_salt = Keyword.get(opts, :request_state_salt, nil) + extensions = Keyword.get(opts, :extensions, Macro.escape(%{})) + a2a = Keyword.get(opts, :a2a, []) quote location: :keep, generated: true do @behaviour Phantom.Router @@ -293,7 +320,9 @@ defmodule Phantom.Router do prompt: 2, prompt: 3, skill: 2, - skill: 3 + skill: 3, + agent: 2, + agent: 3 ] require Phantom.ClientLogger @@ -312,12 +341,15 @@ defmodule Phantom.Router do @website_url unquote(website_url) @secret_key_base unquote(secret_key_base) @request_state_salt unquote(request_state_salt) + @extensions unquote(extensions) + @a2a unquote(a2a) Module.register_attribute(__MODULE__, :phantom_tools, accumulate: true) + Module.register_attribute(__MODULE__, :phantom_agents, accumulate: true) Module.register_attribute(__MODULE__, :phantom_prompts, accumulate: true) Module.register_attribute(__MODULE__, :phantom_resource_templates, accumulate: true) - def connect(session, _auth_info), do: {:ok, session} + def connect(session, _auth_info), do: {:ok, %{session | anonymous: true}} def disconnect(session), do: {:ok, session} def authorize_resource_subscriptions(resources, _session), do: resources @@ -353,6 +385,8 @@ defmodule Phantom.Router do Phantom.Router.Skills.default_list(__MODULE__, session, cursor) end + def list_agents(_cursor, session), do: Phantom.Router.A2A.default_list(__MODULE__, session) + @doc """ Return the Resource URI for the resource by name and params. @@ -529,6 +563,7 @@ defmodule Phantom.Router do protocolVersion: protocol_version, capabilities: %{elicitation: %{}} + |> Phantom.Router.extensions_capability(@extensions) |> Phantom.Router.tool_capability(__MODULE__, session) |> Phantom.Router.prompt_capability(__MODULE__, session) |> Phantom.Router.resource_capability(__MODULE__, session) @@ -557,6 +592,7 @@ defmodule Phantom.Router do capabilities = %{elicitation: %{}} + |> Phantom.Router.extensions_capability(@extensions) |> Phantom.Router.tool_capability(__MODULE__, session) |> Phantom.Router.prompt_capability(__MODULE__, session) |> Phantom.Router.resource_capability(__MODULE__, session) @@ -745,6 +781,7 @@ defmodule Phantom.Router do @doc false defoverridable list_resources: 2, list_skills: 2, + list_agents: 2, authorize_resource_subscriptions: 2, authorize_task_subscriptions: 2, server_info: 1, @@ -1063,6 +1100,77 @@ defmodule Phantom.Router do ) end + @doc """ + Route an A2A agent to a module that implements `Phantom.A2A.Agent`. + + The path is the agent's ID and its URL under `Phantom.A2A.Plug`'s mount. + `opts` are passed to the agent as `ctx.agent.opts`, so one module can serve + several agents. + + agent "research", MyApp.Agents.Research + agent "support-es", MyApp.Agents.Support, locale: "es" + + A path with params routes to agents found at runtime, such as ones stored + per organization. Name a function of the module that resolves them: + + agent ":org/:agent_key", MyApp.Agents.Hosted, :resolve + + It is called with the path params and the session from + `c:Phantom.Router.connect/2`, and returns `{:ok, %Phantom.A2A.Card{}, state}` + or `{:error, :not_found}`. The handler gets the params and `state` in + `ctx.agent`. A resolved agent's card needs the session, so it isn't public. + Routes without params match first. + + Configure the cards with the `:a2a` option of `use Phantom.Router`: + + * `:well_known` - the path of the agent, without params, served at + `/.well-known/agent-card.json` + * `:provider` - `%{"organization" => ..., "url" => ...}` + * `:security_schemes`, `:security_requirements` - as ProtoJSON maps + + Limit agents per session with `Phantom.Session.allow_agents/2`, by the + route's path or the agent's. See `Phantom.A2A`. + """ + defmacro agent(path, handler, opts_or_resolver \\ []) do + quote generated: true do + @phantom_agents Phantom.Router.agent_route( + unquote(path), + unquote(handler), + unquote(opts_or_resolver) + ) + end + end + + @doc false + defdelegate agent_route(path, handler, opts_or_resolver), to: Phantom.Router.A2A, as: :route + + @doc """ + Call one of the session's router's tools from your own code, such as an A2A + agent answering with the tools it already has, and wait for its result. + + def handle_message(message, ctx) do + {:ok, result} = Phantom.Router.call_tool(ctx.session, :search_studies, query: "churn") + # ... + end + + The tool runs in the calling process with the session's assigns and + allow-lists, and arguments are validated as for `tools/call`. There is no + client, so `Phantom.Session.elicit/3` returns `:not_supported`. Returns + `{:ok, result}` with the tool's result, `{:error, error}` with the JSON-RPC + error, or `{:error, :timeout}`. + + ## Options + + * `:timeout` - how long to wait for a tool that answers later (default 60 seconds) + * `:on_progress` - a function called with `progress, total, message` for + each progress notification the tool sends + * `:tasks` - declare the Tasks extension, so a tool may answer with a + `Phantom.Tasks`, which is returned as `{:ok, task}`. Needs the router's + `c:get_task/2`. + """ + def call_tool(%Session{} = session, name, args \\ %{}, opts \\ []), + do: Phantom.Router.CallTool.call(session, name, args, opts) + @doc false defp maybe_register_app(name, opts, caller) do {app_module, opts} = Keyword.pop(opts, :app) @@ -1318,11 +1426,14 @@ defmodule Phantom.Router do %{ name: @name, version: @vsn, + instructions: @instructions, tools: @phantom_tools, resource_templates: @phantom_resource_templates, prompts: @phantom_prompts, secret_key_base: @secret_key_base, - request_state_salt: @request_state_salt + request_state_salt: @request_state_salt, + a2a: @a2a, + agents: Enum.reverse(@phantom_agents) } end end, @@ -1651,6 +1762,14 @@ defmodule Phantom.Router do Map.put(capabilities, :logging, %{}) end + @doc false + def extensions_capability(capabilities, extensions) when map_size(extensions) == 0, + do: capabilities + + def extensions_capability(capabilities, extensions) do + Map.update(capabilities, :extensions, extensions, &Map.merge(&1, extensions)) + end + @doc false def ui_capability(capabilities, router, session) do resource_templates = Cache.list(session, router, :resource_templates) diff --git a/lib/phantom/router/a2a.ex b/lib/phantom/router/a2a.ex new file mode 100644 index 0000000..083139c --- /dev/null +++ b/lib/phantom/router/a2a.ex @@ -0,0 +1,87 @@ +defmodule Phantom.Router.A2A do + @moduledoc false + # Builds and matches the `agent` routes of a router. + + @segment ~r/\A(:[a-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)\z/ + + # Segments the HTTP+JSON binding routes on. + @reserved ~w[tasks extendedAgentCard pushNotificationConfigs] + + def route(path, handler, resolver) when is_atom(resolver), + do: build(path, handler, [], resolver) + + def route(path, handler, opts) when is_list(opts), do: build(path, handler, opts, nil) + + defp build(path, handler, opts, resolver) when is_binary(path) and is_atom(handler) do + segments = String.split(path, "/") + + if not Enum.all?(segments, &Regex.match?(@segment, &1)) or + List.last(segments) == "agent-card.json" do + raise ArgumentError, + "invalid agent path #{inspect(path)}: segments are letters, digits, - . _ ~, " <> + "or path params" + end + + if reserved = Enum.find(segments, &(&1 in @reserved)) do + raise ArgumentError, + "invalid agent path #{inspect(path)}: #{inspect(reserved)} is reserved for the " <> + "HTTP+JSON binding" + end + + params? = Enum.any?(segments, &String.starts_with?(&1, ":")) + + if params? and is_nil(resolver) do + raise ArgumentError, + "agent #{inspect(path)} has path params, so it needs a resolver: " <> + "agent #{inspect(path)}, #{inspect(handler)}, :resolve" + end + + %{ + id: path, + segments: segments, + handler: handler, + opts: opts, + resolver: resolver, + params?: params? + } + end + + @doc "The default `c:Phantom.Router.list_agents/2`" + def default_list(router, session) do + paths = + for %{params?: false, id: id} <- router.__phantom__(:info).agents, + is_nil(session.allowed_agents) or id in session.allowed_agents, + do: id + + {:ok, paths, nil} + end + + @doc "The route for a request path, static routes first, with its path params" + def match(router, segments) do + with :error <- match(router, segments, false), do: match(router, segments, true) + end + + @doc "The static (`false`) or dynamic (`true`) route for a request path" + def match(router, segments, params?) do + Enum.find_value(router.__phantom__(:info).agents, :error, fn + %{params?: ^params?} = route -> + case match_segments(route.segments, segments, %{}) do + {:ok, params} -> {:ok, route, params} + :error -> nil + end + + _route -> + nil + end) + end + + defp match_segments([], [], params), do: {:ok, params} + + defp match_segments([":" <> name | pattern], [segment | segments], params), + do: match_segments(pattern, segments, Map.put(params, name, segment)) + + defp match_segments([segment | pattern], [segment | segments], params), + do: match_segments(pattern, segments, params) + + defp match_segments(_pattern, _segments, _params), do: :error +end diff --git a/lib/phantom/router/call_tool.ex b/lib/phantom/router/call_tool.ex new file mode 100644 index 0000000..4f62966 --- /dev/null +++ b/lib/phantom/router/call_tool.ex @@ -0,0 +1,119 @@ +defmodule Phantom.Router.CallTool do + @moduledoc false + # Calls a router's tool in the calling process, which stands in for the + # session the tool would answer, like `Phantom.Test.call_tool/4` does. + + alias Phantom.{Cache, Request, Session} + + # Casts a tool may send its session (see `Phantom.Session.handle_cast/2`). + @session_casts [:finish, :ping, :tools_updated, :prompts_updated, :resources_updated] + @session_cast_tags [ + :send, + :notify, + :log, + :log_legacy, + :progress, + :respond, + :release_worker, + :subscribe_resource, + :unsubscribe_resource, + :resource_updated, + :set_log_level + ] + + def call(%Session{router: router} = session, name, args, opts) do + if not Cache.initialized?(router), do: Cache.register(router) + + meta = if opts[:tasks], do: tasks_meta(), else: nil + params = %{"name" => to_string(name), "arguments" => stringify_keys(args)} + params = if meta, do: Map.put(params, "_meta", meta), else: params + + request = %Request{ + id: UUIDv7.generate(), + method: "tools/call", + params: params, + meta: meta, + type: "message" + } + + session = %{session | pid: self(), request: request} + timeout = Keyword.get(opts, :timeout, :timer.seconds(60)) + + try do + router.dispatch_method(["tools/call", params, request, session]) + |> case do + {:reply, result, _session} -> {:ok, result} + {:noreply, _session} -> await(request.id, deadline(timeout), opts[:on_progress]) + {:error, error, _session} -> {:error, error} + {:error, error} -> {:error, error} + end + |> fetch_task(router, session) + after + drain(request.id) + end + end + + # A stateless request that declares the Tasks extension, so a tool may + # answer with a `Phantom.Tasks`. + defp tasks_meta do + %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{ + "extensions" => %{Phantom.Tasks.extension() => %{}} + } + } + end + + # A tool that answered with a task sent its `CreateTaskResult`; return the task. + defp fetch_task({:ok, %{resultType: "task", taskId: id}}, router, session) do + case router.get_task(id, session) do + {:ok, %Phantom.Tasks{} = task} -> {:ok, task} + {:error, reason} -> {:error, reason} + end + end + + defp fetch_task(result, _router, _session), do: result + + defp await(request_id, deadline, on_progress) do + receive do + {:"$gen_cast", {:respond, ^request_id, %{result: result}}} -> + {:ok, result} + + {:"$gen_cast", {:respond, ^request_id, %{error: error}}} -> + {:error, error} + + {:"$gen_cast", {:progress, _token, progress, total, message}} + when is_function(on_progress, 3) -> + on_progress.(progress, total, message) + await(request_id, deadline, on_progress) + after + remaining(deadline) -> {:error, :timeout} + end + end + + defp drain(request_id) do + receive do + {:"$gen_cast", {:respond, ^request_id, _response}} -> + drain(request_id) + + {:"$gen_cast", msg} when msg in @session_casts -> + drain(request_id) + + {:"$gen_cast", msg} when is_tuple(msg) and elem(msg, 0) in @session_cast_tags -> + drain(request_id) + after + 0 -> :ok + end + end + + defp deadline(:infinity), do: :infinity + defp deadline(timeout), do: System.monotonic_time(:millisecond) + timeout + + defp remaining(:infinity), do: :infinity + defp remaining(deadline), do: max(deadline - System.monotonic_time(:millisecond), 0) + + defp stringify_keys(args) when is_list(args), do: args |> Map.new() |> stringify_keys() + + defp stringify_keys(args) when is_map(args), + do: Map.new(args, fn {key, value} -> {to_string(key), value} end) +end diff --git a/lib/phantom/session.ex b/lib/phantom/session.ex index 3883820..11e2530 100644 --- a/lib/phantom/session.ex +++ b/lib/phantom/session.ex @@ -10,6 +10,7 @@ defmodule Phantom.Session do @enforce_keys [:id] defstruct [ + :allowed_agents, :allowed_prompts, :allowed_resource_templates, :allowed_tools, @@ -26,6 +27,7 @@ defmodule Phantom.Session do :subscription_id, :tracker, :transport_pid, + anonymous: false, assigns: %{}, client_info: %{}, client_capabilities: %{ @@ -41,10 +43,12 @@ defmodule Phantom.Session do ] @type t :: %__MODULE__{ + allowed_agents: [String.t()] | nil, allowed_prompts: [String.t()], allowed_resource_templates: [String.t()], allowed_tools: [String.t()], state: term() | nil, + anonymous: boolean(), assigns: map(), close_after_complete: boolean(), id: binary(), @@ -93,6 +97,11 @@ defmodule Phantom.Session do %{session | allowed_resource_templates: resource_templates} end + @doc "Set an allow-list of A2A agents, by the path of their `agent` route" + def allow_agents(%__MODULE__{} = session, agents) do + %{session | allowed_agents: agents} + end + @doc "Set an allow-list of usable Prompts for the session" def allow_prompts(%__MODULE__{} = session, prompts) do %{session | allowed_prompts: prompts} diff --git a/mix.exs b/mix.exs index 10ad3a4..5c4e803 100644 --- a/mix.exs +++ b/mix.exs @@ -28,7 +28,8 @@ defmodule Phantom.MixProject do def application do [ mod: {Phantom.Application, []}, - extra_applications: [:logger | extra_applications(Mix.env())] + # :inets and :ssl deliver A2A push notifications with :httpc. + extra_applications: [:logger, :inets, :ssl | extra_applications(Mix.env())] ] end @@ -36,7 +37,7 @@ defmodule Phantom.MixProject do defp extra_applications(env) when env in [:dev, :test], do: [:mnesia] defp extra_applications(_env), do: [] - defp elixirc_paths(:test), do: ["lib", "test/support", "test/conformance"] + defp elixirc_paths(:test), do: ["lib", "test/support", "test/conformance", "test/acts"] defp elixirc_paths(:stdio), do: ["lib", "test/support/app"] @@ -52,14 +53,15 @@ defmodule Phantom.MixProject do {:phoenix_pubsub, "~> 2.0", optional: true, only: [:dev, :test, :prod, :stdio]}, {:uuid_v7, "~> 0.6"}, {:yamerl, "~> 0.10", optional: true}, + # Phantom.A2A.Client + {:req, "~> 0.5", optional: true}, ## Test {:phoenix_live_view, "~> 1.0", only: [:dev, :test, :stdio]}, {:ex_doc, "~> 0.31", only: :dev, warn_if_outdated: true, runtime: false}, {:tidewave, "~> 0.5", only: [:dev, :test], warn_if_outdated: true}, {:makeup_javascript, "~> 0.1", only: :dev}, {:phoenix_live_reload, "~> 1.5", only: [:dev, :test, :stdio]}, - {:bandit, "~> 1.0", only: [:dev, :test]}, - {:req, "~> 0.5", only: [:dev, :test]} + {:bandit, "~> 1.0", only: [:dev, :test]} ] end @@ -110,7 +112,14 @@ defmodule Phantom.MixProject do defp docs do [ main: "Phantom", - extras: ["guides/mcp_apps.md", "guides/skills.md", "guides/tasks.md", "CHANGELOG.md"], + extras: [ + "guides/mcp_apps.md", + "guides/skills.md", + "guides/tasks.md", + "guides/oauth_client_credentials.md", + "guides/a2a.md", + "CHANGELOG.md" + ], assets: %{"assets" => "assets"}, before_closing_body_tag: %{html: @mermaidjs} ] diff --git a/test/acts/README.md b/test/acts/README.md new file mode 100644 index 0000000..d0bda6e --- /dev/null +++ b/test/acts/README.md @@ -0,0 +1,51 @@ +# A2A conformance suite (ACTS) + +Runs the official A2A conformance suite, ACTS, from +[a2aproject/a2a-itk](https://github.com/a2aproject/a2a-itk) (pinned in +`Phantom.Test.ACTS`) against Phantom's A2A agents. Requires +[uv](https://docs.astral.sh/uv/) and git. + +```sh +mix test --only acts +``` + +The first run clones a2a-itk into `_build/a2a-itk`. Set `A2A_ITK_PATH` to use +another checkout. + +`Phantom.ACTS.Agent` implements the `tck-*` behaviors that the suite's tests +name in their first message, as declared in `sut-behaviors.yaml`. For the +client tests, `tck-client-parse` hands a canned response to +`Phantom.A2A.Client` through a plug and returns what it parsed. ACTS tests it +three ways, each served by `Phantom.ACTS.HTTP`: + +- **default**, with a pubsub, so it streams; +- **reduced**, without streaming, push notifications, or an extended card, + for the tests of an agent that lacks them (`--reduced-sut-url`); +- **auth**, `Phantom.ACTS.AuthRouter`, which requires + `Bearer itk-valid-token` and answers `itk-insufficient-token` with 403 + (`--auth-sut-url`). + +The JSON-RPC and HTTP+JSON bindings run, each with its own report; gRPC +doesn't, because Phantom doesn't serve it. The routers allow private push URLs, because the suite's webhook receiver +listens on 127.0.0.1. + +## Expected failures + +`expected-failures.yml` lists the tests Phantom is known to fail, each with its +cause. The run fails when a test outside the list fails, or a listed one +passes, so update the list as fixes land. + +## Running tests by hand + +Serve the agents, then run the suite from the a2a-itk checkout with any +`run_acts.py` options, such as one test with request logging: + +```sh +MIX_ENV=test mix run test/acts/server.exs + +cd _build/a2a-itk +uv run run_acts.py --sut-url http://127.0.0.1:4130 \ + --reduced-sut-url http://127.0.0.1:4131 --auth-sut-url http://127.0.0.1:4132 \ + --sdk phantom_mcp --behaviors ../../test/acts/sut-behaviors.yaml \ + -t STREAM-SSE-001 -v +``` diff --git a/test/acts/acts_test.exs b/test/acts/acts_test.exs new file mode 100644 index 0000000..29c96e6 --- /dev/null +++ b/test/acts/acts_test.exs @@ -0,0 +1,31 @@ +defmodule Phantom.ACTSTest do + use ExUnit.Case, async: false + + alias Phantom.Test.ACTS + + @moduletag :acts + @moduletag timeout: :infinity + + setup_all do + for child <- ACTS.children(), do: start_supervised!(child) + :ok + end + + test "the JSON-RPC and HTTP+JSON bindings pass the A2A conformance suite" do + {_status, output, reports} = ACTS.run() + assert Map.keys(reports) == ["jsonrpc", "rest"], output + + for {binding, report} <- reports do + results = ACTS.results(report) + failed = Map.get(results, "fail", []) ++ Map.get(results, "error", []) + expected = ACTS.expected_failures() + + assert {binding, failed -- expected, expected -- failed} == {binding, [], []}, """ + Unexpected failures, then expected failures that passed. Update + test/acts/expected-failures.yml as fixes land. + + #{output} + """ + end + end +end diff --git a/test/acts/agent.ex b/test/acts/agent.ex new file mode 100644 index 0000000..9caa7fc --- /dev/null +++ b/test/acts/agent.ex @@ -0,0 +1,266 @@ +defmodule Phantom.ACTS.Agent do + @moduledoc """ + The agent the A2A conformance suite (ACTS) tests. The first message of a + task starts with a `tck-*` prefix that names the outcome the suite expects; + see `sut-behaviors.yaml` and the ACTS SDK integration guide. + """ + + use Phantom.A2A.Agent, + name: "Phantom ACTS agent", + description: "Implements the ACTS tck-* behaviors", + input_modes: ~w[text/plain application/json], + output_modes: ~w[text/plain application/json], + push_notifications: true, + extended_card: true + + alias Phantom.A2A.{Artifact, Card, Client, Message, Part, Task, TaskStatus} + alias Phantom.ACTS.Store + + @description "Produces the outcome a tck-* message names" + skill :tck, tags: ["conformance"], examples: ["tck-complete-task hello"] + + @long_running_ms 1_000 + @chunks ["chunk one ", "chunk two ", "chunk three"] + + # Hands a canned response to Phantom's own client and returns what it parsed. + def handle_message( + %Message{parts: [%Part{text: "tck-client-parse" <> _}, %Part{data: %{} = request} | _]}, + ctx + ) do + parsed = client_parse(request["operation"], request["wire_payload"]) + task = complete_with(Task.submitted(ctx), Artifact.new("parsed", [Part.data(parsed)])) + Store.put(task) + {:reply, task, ctx} + end + + def handle_message(%Message{task_id: nil} = message, ctx) do + case behavior(message) do + "tck-message-response" -> {:reply, Message.agent("Direct message response"), ctx} + behavior -> {:reply, start(behavior, ctx), ctx} + end + end + + # Continuations carry no prefix; the task's first message names the behavior. + def handle_message(%Message{} = message, ctx) do + {:ok, task} = Store.get(message.task_id) + task = %{task | history: task.history ++ [message]} + + task = + case {behavior(hd(task.history)), text(message)} do + {"tck-multi-turn", "done" <> _} -> finish(task, :completed, "Multi-turn complete") + {"tck-multi-turn", _text} -> finish(task, :input_required, "Send more, or done") + {_behavior, _text} -> finish(task, :completed, "Done") + end + + Store.put(task) + {:reply, task, ctx} + end + + defp start(behavior, ctx) do + task = Task.submitted(ctx) + + task = + case behavior do + "tck-complete-task" -> finish(task, :completed, "Task completed") + "tck-input-required" -> finish(task, :input_required, "More input required") + "tck-multi-turn" -> finish(task, :input_required, "Send more, or done") + "tck-auth-required" -> finish(task, :auth_required, "Bearer credentials required") + "tck-reject-task" -> finish(task, :rejected, "Task rejected") + "tck-task-failure" -> finish(task, :failed, "Task failed: simulated error") + "tck-cancel" -> finish(task, :working, "Working until canceled") + "tck-long-running" -> task + "tck-stream-basic" -> task + "tck-stream-chunked" -> task + "tck-artifact-" <> kind -> complete_with(task, artifact(kind)) + unknown -> finish(task, :failed, "Unknown behavior #{inspect(unknown)}") + end + + Store.put(task) + run(behavior, ctx.task_ref) + task + end + + defp run("tck-long-running", ref) do + spawn(fn -> Store.emit(ref, Task.status(:working, "Working")) end) + + {:ok, _timer} = + :timer.apply_after(@long_running_ms, Store, :emit, [ + ref, + Task.status(:completed, "Long-running task completed") + ]) + end + + defp run("tck-stream-basic", ref) do + spawn(fn -> + Store.emit(ref, Task.status(:working)) + + Store.emit( + ref, + Artifact.update("stream", [Part.text("streamed content")], last_chunk: true) + ) + + Store.emit(ref, Task.status(:completed, "Stream complete")) + end) + end + + defp run("tck-stream-chunked", ref) do + spawn(fn -> + Store.emit(ref, Task.status(:working)) + + for {chunk, i} <- Enum.with_index(@chunks) do + Store.emit( + ref, + Artifact.update("chunked", [Part.text(chunk)], + append: i > 0, + last_chunk: i == length(@chunks) - 1 + ) + ) + end + + Store.emit(ref, Task.status(:completed, "Stream complete")) + end) + end + + defp run(_behavior, _ref), do: :ok + + defp client_parse("get_agent_card", payload) do + {:ok, card} = + Client.get_card("http://mock.test/agent-card.json", req_options: [plug: mock(payload)]) + + Card.to_json(card) + end + + defp client_parse("get_extended_agent_card", payload) do + card = mock_card(%{"extendedAgentCard" => true}, "HTTP+JSON") + {:ok, client} = Client.new(card, req_options: [plug: mock(payload)]) + parsed(Client.get_extended_card(client), &Card.to_json/1) + end + + defp client_parse(operation, payload) do + {:ok, client} = Client.new(mock_card(%{}, "JSONRPC"), req_options: [plug: mock(payload)]) + + case operation do + "send_message" -> parsed(Client.send_message(client, "hi"), &Phantom.A2A.event_to_json/1) + "get_task" -> parsed(Client.get_task(client, "t"), &Task.to_json/1) + end + end + + defp parsed({:ok, value}, to_json), do: to_json.(value) + + defp parsed({:error, error}, _to_json), + do: %{"error" => %{"code" => error.code, "message" => error.message}} + + defp mock(payload) do + fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, JSON.encode!(payload)) + end + end + + defp mock_card(capabilities, binding) do + %Card{ + name: "Mock", + description: "Answers with a canned payload", + supported_interfaces: [ + %{"url" => "http://mock.test", "protocolBinding" => binding, "protocolVersion" => "1.0"} + ], + capabilities: capabilities + } + end + + defp artifact("text"), do: Artifact.new("text", [Part.text("generated text content")]) + defp artifact("data"), do: Artifact.new("data", [Part.data(%{"key" => "value", "count" => 1})]) + + defp artifact("file-url") do + Artifact.new("file", [ + Part.url("https://example.com/document.txt", + filename: "document.txt", + media_type: "text/plain" + ) + ]) + end + + defp artifact("file") do + Artifact.new("file", [ + Part.raw("file content", filename: "document.txt", media_type: "text/plain") + ]) + end + + defp complete_with(task, artifact), + do: finish(%{task | artifacts: [artifact]}, :completed, "Task completed") + + defp finish(task, state, text) do + %{ + task + | status: %TaskStatus{ + state: state, + message: Message.agent(text, task_id: task.id, context_id: task.context_id), + timestamp: DateTime.utc_now() + } + } + end + + defp behavior(message) do + case Regex.run(~r/\Atck-[a-z0-9-]+/, text(message)) do + [behavior] -> behavior + nil -> "tck-complete-task" + end + end + + defp text(%Message{parts: [%Part{text: text} | _]}) when is_binary(text), do: String.trim(text) + defp text(_message), do: "" + + def get_task(task_id, _ctx) do + case Store.get(task_id) do + {:ok, task} -> {:ok, task} + :error -> {:error, :not_found} + end + end + + def cancel_task(%Task{} = task, ctx) do + Store.emit(ctx.task_ref, Task.status(:canceled, "Canceled")) + Store.get(task.id) + end + + def create_push_config(config, _ctx) do + Store.put_config(config) + {:ok, config} + end + + def get_push_config(task_id, id, _ctx) do + case Store.configs(task_id) do + %{^id => config} -> {:ok, config} + _configs -> {:error, :not_found} + end + end + + def list_push_configs(task_id, _ctx), do: {:ok, Map.values(Store.configs(task_id))} + + def delete_push_config(task_id, id, _ctx), do: Store.delete_config(task_id, id) + + def push_configs(ref), do: Map.values(Store.configs(ref.task_id)) + + def list_tasks(filter, _ctx) do + tasks = + Store.all() + |> Enum.filter(&(is_nil(filter.context_id) or &1.context_id == filter.context_id)) + |> Enum.filter(&(is_nil(filter.status) or &1.status.state == filter.status)) + |> Enum.filter( + &(is_nil(filter.status_timestamp_after) or + DateTime.compare(&1.status.timestamp, filter.status_timestamp_after) != :lt) + ) + |> Enum.sort_by(& &1.status.timestamp, {:desc, DateTime}) + + offset = String.to_integer(filter.page_token || "0") + page = Enum.slice(tasks, offset, filter.page_size) + next = offset + filter.page_size + + {:ok, + %{ + tasks: page, + next_page_token: if(next < length(tasks), do: Integer.to_string(next)), + total_size: length(tasks) + }} + end +end diff --git a/test/acts/auth_router.ex b/test/acts/auth_router.ex new file mode 100644 index 0000000..172c19f --- /dev/null +++ b/test/acts/auth_router.ex @@ -0,0 +1,26 @@ +defmodule Phantom.ACTS.AuthRouter do + @moduledoc """ + The ACTS agent behind bearer authentication: the suite's `ITK_ACTS_AUTH` + agent. Its card stays public. + """ + + use Phantom.Router, + name: "Phantom ACTS", + vsn: "1.0", + a2a: [ + well_known: "acts", + allow_private_push_urls: true, + security_schemes: %{"bearer" => %{"httpAuthSecurityScheme" => %{"scheme" => "Bearer"}}}, + security_requirements: [%{"schemes" => %{"bearer" => %{"list" => []}}}] + ] + + agent "acts", Phantom.ACTS.Agent + + def connect(session, %Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer itk-valid-token"] -> {:ok, session} + ["Bearer itk-insufficient-token"] -> {:forbidden, "Insufficient credentials"} + _ -> {:unauthorized, %{method: "Bearer", realm: "acts"}} + end + end +end diff --git a/test/acts/expected-failures.yml b/test/acts/expected-failures.yml new file mode 100644 index 0000000..14e2b8a --- /dev/null +++ b/test/acts/expected-failures.yml @@ -0,0 +1,5 @@ +# ACTS tests Phantom is known to fail, each with its cause, as `- TEST-ID` +# lines. The ACTS test fails when a test outside this list fails, or a listed +# one passes. +# +# None: Phantom passes every test on both bindings. diff --git a/test/acts/http.ex b/test/acts/http.ex new file mode 100644 index 0000000..77ff7c6 --- /dev/null +++ b/test/acts/http.ex @@ -0,0 +1,41 @@ +defmodule Phantom.ACTS.HTTP do + @moduledoc """ + Serves the ACTS agent's card at `/.well-known/agent-card.json` and the agent + at `/a2a/acts`. Without `:pubsub`, and with `push_notifications: false` and + `extended_card: false`, it advertises none of those capabilities: that is + the suite's reduced-capabilities agent. + """ + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(opts) do + %{ + a2a: + Phantom.A2A.Plug.init( + router: Keyword.get(opts, :router, Phantom.ACTS.Router), + pubsub: opts[:pubsub], + push_notifications: Keyword.get(opts, :push_notifications, true), + extended_card: Keyword.get(opts, :extended_card, true), + mount: "/a2a", + validate_origin: false + ) + } + end + + @impl Plug + def call(conn, opts) do + case conn.path_info do + [".well-known", "agent-card.json"] -> + Phantom.A2A.Plug.call(conn, opts.a2a) + + ["a2a" | rest] -> + Phantom.A2A.Plug.call(%{conn | path_info: rest, script_name: ["a2a"]}, opts.a2a) + + _ -> + send_resp(conn, 404, "") + end + end +end diff --git a/test/acts/router.ex b/test/acts/router.ex new file mode 100644 index 0000000..44ab1d4 --- /dev/null +++ b/test/acts/router.ex @@ -0,0 +1,18 @@ +defmodule Phantom.ACTS.Router do + @moduledoc false + + use Phantom.Router, + name: "Phantom ACTS", + vsn: "1.0", + a2a: [well_known: "acts", allow_private_push_urls: true] + + agent "acts", Phantom.ACTS.Agent + + # A2A puts the extended card behind authentication, even when nothing else is. + def connect(session, conn) do + case conn.body_params do + %{"method" => "GetExtendedAgentCard"} -> Phantom.ACTS.AuthRouter.connect(session, conn) + _ -> {:ok, session} + end + end +end diff --git a/test/acts/server.exs b/test/acts/server.exs new file mode 100644 index 0000000..a7ebea4 --- /dev/null +++ b/test/acts/server.exs @@ -0,0 +1,11 @@ +# Serves the ACTS agents for running the suite by hand. See test/acts/README.md. +# +# MIX_ENV=test mix run test/acts/server.exs + +{:ok, _} = Supervisor.start_link(Phantom.Test.ACTS.children(), strategy: :one_for_one) + +for name <- [:default, :reduced, :auth] do + IO.puts("ACTS agent (#{name}): #{Phantom.Test.ACTS.url(name)}") +end + +Process.sleep(:infinity) diff --git a/test/acts/store.ex b/test/acts/store.ex new file mode 100644 index 0000000..1201366 --- /dev/null +++ b/test/acts/store.ex @@ -0,0 +1,48 @@ +defmodule Phantom.ACTS.Store do + @moduledoc """ + Tasks and push notification configs of the ACTS agent, in memory. Every + change is saved here before it is published, so a `GetTask` after an event + sees it. + """ + + use Agent + + alias Phantom.A2A.{Events, PushConfig, Task} + + def start_link(_opts), + do: Agent.start_link(fn -> %{tasks: %{}, configs: %{}} end, name: __MODULE__) + + def get(task_id), do: Agent.get(__MODULE__, &Map.fetch(&1.tasks, task_id)) + def all, do: Agent.get(__MODULE__, &Map.values(&1.tasks)) + def put(%Task{} = task), do: Agent.update(__MODULE__, &put_in(&1, [:tasks, task.id], task)) + + @doc "Apply an event to a stored task, then publish it." + def emit(%Phantom.A2A.TaskRef{task_id: task_id} = ref, event) do + event = + Agent.get_and_update(__MODULE__, fn state -> + {:ok, task, event} = Events.apply(Map.fetch!(state.tasks, task_id), event) + {event, put_in(state, [:tasks, task_id], task)} + end) + + Phantom.A2A.publish(ref, event) + end + + def configs(task_id), do: Agent.get(__MODULE__, &Map.get(&1.configs, task_id, %{})) + + def put_config(%PushConfig{} = config) do + Agent.update(__MODULE__, fn state -> + configs = state.configs |> Map.get(config.task_id, %{}) |> Map.put(config.id, config) + put_in(state, [:configs, config.task_id], configs) + end) + end + + def delete_config(task_id, id) do + Agent.update(__MODULE__, fn state -> + update_in( + state, + [:configs], + &Map.update(&1, task_id, %{}, fn configs -> Map.delete(configs, id) end) + ) + end) + end +end diff --git a/test/acts/sut-behaviors.yaml b/test/acts/sut-behaviors.yaml new file mode 100644 index 0000000..2c4482f --- /dev/null +++ b/test/acts/sut-behaviors.yaml @@ -0,0 +1,98 @@ +# The ACTS `tck-*` behaviors Phantom.ACTS.Agent implements (ACTS section 11.1). +# The runner checks this list against the agent by running the tests: a prefix +# listed here but not implemented fails its tests rather than skipping them. + +acts_version: "1.0" + +behaviors: + - prefix: "tck-complete-task" + description: "Complete the task with a text response message" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-input-required" + description: "Return the task in INPUT_REQUIRED" + response_type: task + terminal_state: TASK_STATE_INPUT_REQUIRED + + - prefix: "tck-auth-required" + description: "Return the task in AUTH_REQUIRED" + response_type: task + terminal_state: TASK_STATE_AUTH_REQUIRED + + - prefix: "tck-reject-task" + description: "Reject the task" + response_type: task + terminal_state: TASK_STATE_REJECTED + + - prefix: "tck-task-failure" + description: "Complete with FAILED and an error message" + response_type: task + terminal_state: TASK_STATE_FAILED + + - prefix: "tck-message-response" + description: "Return a direct Message, not a Task" + response_type: message + + - prefix: "tck-multi-turn" + description: "Stay in INPUT_REQUIRED until the user sends 'done'" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-cancel" + description: "Remain in WORKING until canceled" + response_type: task + terminal_state: TASK_STATE_CANCELED + + - prefix: "tck-long-running" + description: "Stay in WORKING briefly, then complete" + response_type: task + terminal_state: TASK_STATE_COMPLETED + delay_ms: 1000 + + - prefix: "tck-artifact-text" + description: "Complete with a text artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - text: "generated text content" + + - prefix: "tck-artifact-data" + description: "Complete with a structured data artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - data: {key: "value", count: 1} + + - prefix: "tck-artifact-file" + description: "Complete with a file artifact carrying inline bytes" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - file: {name: "document.txt", mediaType: "text/plain"} + + - prefix: "tck-artifact-file-url" + description: "Complete with a file artifact carrying a URL" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - fileUrl: + url: "https://example.com/document.txt" + name: "document.txt" + mediaType: "text/plain" + + - prefix: "tck-stream-basic" + description: "Stream working -> artifact -> completed" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "streamed content" + + - prefix: "tck-stream-chunked" + description: "Stream one artifact across several appended chunks" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "chunk one chunk two chunk three" diff --git a/test/phantom/a2a/bridge_conformance_test.exs b/test/phantom/a2a/bridge_conformance_test.exs new file mode 100644 index 0000000..9524526 --- /dev/null +++ b/test/phantom/a2a/bridge_conformance_test.exs @@ -0,0 +1,148 @@ +defmodule Phantom.A2A.BridgeConformanceTest.Tools do + require Phantom.Tool, as: Tool + + def silent(_params, session), do: {:reply, Tool.response([]), session} + + def working(_params, session) do + {:reply, Phantom.A2A.BridgeConformanceTest.Router.working("w1"), session} + end +end + +defmodule Phantom.A2A.BridgeConformanceTest.Fallback do + use Phantom.A2A.Agent, name: "Fallback", description: "Keeps its own tasks" + + alias Phantom.A2A.{Message, Task, TaskStatus} + + def handle_message(%Message{task_id: id} = message, ctx) when is_binary(id) do + {:reply, Message.agent("continued #{message.task_id}"), ctx} + end + + def handle_message(_message, ctx) do + {:reply, Task.submitted(ctx, status: %TaskStatus{state: :input_required}), ctx} + end + + def get_task("fb-" <> _ = id, _ctx) do + {:ok, %Task{id: id, context_id: "fb-ctx", status: %TaskStatus{state: :input_required}}} + end + + def get_task(_id, _ctx), do: {:error, :not_found} + + def cancel_task(task, _ctx), do: {:ok, %{task | status: %TaskStatus{state: :canceled}}} +end + +defmodule Phantom.A2A.BridgeConformanceTest.Router do + use Phantom.Router, name: "Bridge conformance", vsn: "1.0" + + alias Phantom.A2A.BridgeConformanceTest.{Fallback, Tools} + + tool :silent, Tools, description: "Answers with nothing", input_schema: %{} + tool :working, Tools, description: "Keeps working", input_schema: %{} + + agent "tools", Phantom.A2A.Bridge + agent "fallback", Phantom.A2A.Bridge, fallback: Fallback + + def working(id) do + Phantom.Tasks.new(id: id, created_at: DateTime.utc_now(), status: :working) + end + + def get_task("w" <> _ = id, _session), do: {:ok, working(id)} + def get_task(_id, _session), do: {:error, :not_found} +end + +defmodule Phantom.A2A.BridgeConformanceTest do + # Phantom.Tracker is deliberately not running: its name is global. + use ExUnit.Case, async: false + + alias Phantom.A2A.BridgeConformanceTest.Router + + @pubsub Phantom.A2A.BridgeConformanceTest.PubSub + + setup_all do + start_supervised!({Phoenix.PubSub, name: @pubsub}) + :ok + end + + defp rpc(path, method, params, opts \\ []) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + :post + |> Plug.Test.conn(path, JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init([router: Router, pubsub: @pubsub] ++ opts)) + |> Map.fetch!(:resp_body) + |> JSON.decode!() + end + + defp data_message(data, extra \\ %{}) do + Map.merge( + %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"data" => data}]}, + extra + ) + end + + defp text_message(text, extra) do + Map.merge( + %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]}, + extra + ) + end + + test "tool arguments must be an object" do + for arguments <- ["s", [1], 5] do + message = data_message(%{"tool" => "silent", "arguments" => arguments}) + + assert %{"error" => %{"code" => -32602}} = + rpc("/tools", "SendMessage", %{"message" => message}) + end + end + + test "a tool that answers with nothing still sends a part" do + assert %{"result" => %{"message" => %{"parts" => [_ | _]}}} = + rpc("/tools", "SendMessage", %{"message" => data_message(%{"tool" => "silent"})}) + end + + test "a client's contextId is refused when a tool may start a task in its own context" do + message = data_message(%{"tool" => "working"}, %{"contextId" => "mine"}) + + assert %{"error" => %{"code" => -32602}} = + rpc("/tools", "SendMessage", %{"message" => message}) + end + + test "without Phantom.Tracker, a stream can't follow an unfinished MCP task" do + params = %{"message" => data_message(%{"tool" => "working"})} + + assert %{"error" => %{"code" => -32004}} = rpc("/tools", "SendStreamingMessage", params) + assert %{"error" => %{"code" => -32004}} = rpc("/tools", "SubscribeToTask", %{"id" => "w1"}) + end + + test "without Phantom.Tracker, SendMessage still answers at the deadline" do + params = %{"message" => data_message(%{"tool" => "working"})} + + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_WORKING"}}}} = + rpc("/tools", "SendMessage", params, blocking_timeout: 20, poll_interval: 1) + end + + describe "the fallback agent's tasks" do + test "can be fetched" do + assert %{"result" => %{"id" => "fb-1", "contextId" => "fb-ctx"}} = + rpc("/fallback", "GetTask", %{"id" => "fb-1"}) + end + + test "can be canceled" do + assert %{"result" => %{"status" => %{"state" => "TASK_STATE_CANCELED"}}} = + rpc("/fallback", "CancelTask", %{"id" => "fb-1"}) + end + + test "can be continued" do + message = text_message("more", %{"taskId" => "fb-1"}) + + assert %{"result" => %{"message" => %{"parts" => [%{"text" => "continued fb-1"}]}}} = + rpc("/fallback", "SendMessage", %{"message" => message}) + end + + test "are not found without a fallback" do + assert %{"error" => %{"code" => -32001}} = rpc("/tools", "GetTask", %{"id" => "fb-1"}) + end + end +end diff --git a/test/phantom/a2a/bridge_tasks_test.exs b/test/phantom/a2a/bridge_tasks_test.exs new file mode 100644 index 0000000..1982352 --- /dev/null +++ b/test/phantom/a2a/bridge_tasks_test.exs @@ -0,0 +1,369 @@ +defmodule Phantom.A2A.BridgeTasksTest.Tools do + require Phantom.Tool, as: Tool + + @table Phantom.A2A.BridgeTasksTest + + def export(_params, session) do + if Phantom.Session.tasks_supported?(session) do + task = new(status: :working, status_message: "Exporting") + {:reply, task, session} + else + {:reply, Tool.text("exported now"), session} + end + end + + # Keeps working until the test finishes it; see `finishing_slow_export/1`. + def slow_export(_params, session) do + task = new(status: :working, status_message: "Exporting") + :ets.insert(@table, {{:slow, task.id}, true}) + {:reply, task, session} + end + + def confirm(_params, session) do + elicit = + Phantom.Elicit.form(%{ + message: "Sure?", + requested_schema: [%{name: "ok", type: :boolean, required: true}] + }) + + {:reply, new(status: :input_required, input_requests: %{"ok" => elicit}), session} + end + + defp new(attrs) do + task = + Phantom.Tasks.new( + [id: Phantom.Tasks.generate_id(), created_at: DateTime.utc_now()] ++ attrs + ) + + :ets.insert(@table, {task.id, task}) + task + end + + # Asks for input; the answer's work finishes just after the bridge reads it. + def racy_confirm(_params, session) do + elicit = + Phantom.Elicit.form(%{ + message: "Sure?", + requested_schema: [%{name: "ok", type: :boolean, required: true}] + }) + + task = new(status: :input_required, input_requests: %{"ok" => elicit}) + :ets.insert(@table, {{:racy, task.id}, :waiting}) + {:reply, task, session} + end + + def get(id) do + case :ets.lookup(@table, id) do + [{^id, task}] -> + finish_if_racing(id) + report_fetch(id) + {:ok, task} + + [] -> + {:error, :not_found} + end + end + + # Tells the test when a request that listens for the task's updates fetches it. + defp report_fetch(id) do + me = self() + + with [_slow] <- :ets.lookup(@table, {:slow, id}), + [{:observer, pid}] <- :ets.lookup(@table, :observer), + true <- Enum.any?(Phantom.Tracker.list_task_listeners(), &match?({^id, %{pid: ^me}}, &1)) do + send(pid, {:fetched, id}) + end + end + + defp finish_if_racing(id) do + case :ets.lookup(@table, {:racy, id}) do + [{_key, :answered}] -> + :ets.delete(@table, {:racy, id}) + finish(id, "Finished in the gap") + + _other -> + :ok + end + end + + def answered(id) do + if :ets.member(@table, {:racy, id}) do + :ets.insert(@table, {{:racy, id}, :answered}) + {:ok, task} = lookup(id) + put(%{task | status: :working, input_requests: %{}}) + true + end + end + + defp lookup(id) do + [{^id, task}] = :ets.lookup(@table, id) + {:ok, task} + end + + def put(task), do: :ets.insert(@table, {task.id, task}) + + def finish(id, text) do + {:ok, task} = lookup(id) + put(%{task | status: :completed, input_requests: %{}, result: Tool.response(Tool.text(text))}) + end +end + +defmodule Phantom.A2A.BridgeTasksTest.Router do + use Phantom.Router, name: "Tasks", vsn: "1.0" + + alias Phantom.A2A.BridgeTasksTest.Tools + + tool :export, Tools, description: "Export a report", input_schema: %{} + tool :confirm, Tools, description: "Ask before acting", input_schema: %{} + tool :slow_export, Tools, description: "Export a report later", input_schema: %{} + tool :racy_confirm, Tools, description: "Ask, then finish quickly", input_schema: %{} + + agent "tools", Phantom.A2A.Bridge + + def get_task(id, _session), do: Tools.get(id) + + def update_task(task, responses, _session) do + Tools.answered(task.id) || + Tools.finish(task.id, "confirmed: #{responses["ok"]["content"]["ok"]}") + + :ok + end + + def cancel_task(task, _session) do + Tools.put(%{task | status: :cancelled, status_message: "Stopped"}) + :ok + end +end + +defmodule Phantom.A2A.BridgeTasksTest.NoTasksRouter do + use Phantom.Router, name: "No tasks" + + tool :export, Phantom.A2A.BridgeTasksTest.Tools, + description: "Export a report", + input_schema: %{} + + agent "tools", Phantom.A2A.Bridge +end + +defmodule Phantom.A2A.BridgeTasksTest do + use ExUnit.Case, async: false + + import Phantom.A2A.Test + + alias Phantom.A2A.{Message, Part} + alias Phantom.A2A.BridgeTasksTest.{NoTasksRouter, Router, Tools} + + setup_all do + start_supervised!({Agent, fn -> :ets.new(__MODULE__, [:named_table, :public, :set]) end}) + start_supervised!({Phoenix.PubSub, name: Phantom.A2A.BridgeTasksTest.PubSub}) + + start_supervised!( + {Phantom.Tracker, + [name: Phantom.Tracker, pubsub_server: Phantom.A2A.BridgeTasksTest.PubSub]} + ) + + :ok + end + + defp call(router, tool), + do: send_message(router, "tools", Message.user([Part.data(%{"tool" => tool})])) + + test "call_tool/4 with tasks: true returns the tool's task" do + session = Phantom.Session.new(nil, router: Router) + + assert {:ok, %Phantom.Tasks{status: :working}} = + Phantom.Router.call_tool(session, :export, %{}, tasks: true) + + assert {:ok, %{content: [%{text: "exported now"}]}} = + Phantom.Router.call_tool(session, :export) + end + + test "a tool's MCP task becomes an A2A task, in its own context" do + assert {:task, + %{ + "id" => id, + "contextId" => id, + "status" => %{ + "state" => "TASK_STATE_WORKING", + "message" => %{"role" => "ROLE_AGENT", "parts" => [%{"text" => "Exporting"}]} + } + }} = call(Router, "export") + + Tools.finish(id, "Report ready") + + assert {:ok, + %{ + "status" => %{"state" => "TASK_STATE_COMPLETED"}, + "artifacts" => [ + %{"artifactId" => "result", "parts" => [%{"text" => "Report ready"}]} + ] + }} = get_task(Router, "tools", id) + end + + test "input requests are answered with a message on the task" do + assert {:task, + %{ + "id" => id, + "status" => %{ + "state" => "TASK_STATE_INPUT_REQUIRED", + "message" => %{"parts" => parts} + } + }} = call(Router, "confirm") + + assert [%{"data" => %{"inputRequests" => %{"ok" => %{"params" => %{"message" => "Sure?"}}}}}] = + Enum.filter(parts, &Map.has_key?(&1, "data")) + + answer = + Message.user( + [ + Part.data(%{ + "inputResponses" => %{"ok" => %{"action" => "accept", "content" => %{"ok" => true}}} + }) + ], + task_id: id, + context_id: id + ) + + assert {:task, + %{ + "status" => %{"state" => "TASK_STATE_COMPLETED"}, + "artifacts" => [%{"parts" => [%{"text" => "confirmed: true"}]}] + }} = + send_message(Router, "tools", answer) + end + + test "canceling goes to the router" do + {:task, %{"id" => id}} = call(Router, "export") + + assert {:ok, + %{ + "status" => %{ + "state" => "TASK_STATE_CANCELED", + "message" => %{"parts" => [%{"text" => "Stopped"}]} + } + }} = + cancel_task(Router, "tools", id) + end + + test "unknown tasks are not found" do + assert {:error, %{"code" => -32001}} = get_task(Router, "tools", "nope") + end + + test "without the router's task callbacks, tools answer at once" do + assert {:message, %{"parts" => [%{"text" => "exported now"}]}} = call(NoTasksRouter, "export") + end + + describe "following a task the tool updates" do + @plug_opts [ + router: Router, + pubsub: Phantom.A2A.BridgeTasksTest.PubSub, + blocking_timeout: 2_000 + ] + + defp rpc(method, params) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + :post + |> Plug.Test.conn("/tools", JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(@plug_opts)) + end + + defp slow_export, + do: %{"message" => Message.to_json(Message.user([Part.data(%{"tool" => "slow_export"})]))} + + # Runs the request, and finishes the slow export as a job would once the + # request is following it. + defp finishing_slow_export(request) do + :ets.insert(__MODULE__, {:observer, self()}) + pending = Task.async(request) + + assert_receive {:fetched, id}, 2_000 + Tools.finish(id, "Slow report ready") + Phantom.Tracker.notify_task_updated(id) + + Task.await(pending) + end + + defp events(conn) do + for "id: " <> event <- String.split(conn.resp_body, "\n\n", trim: true) do + [_id, "data: " <> data] = String.split(event, "\n") + JSON.decode!(data)["result"] + end + end + + test "SendMessage waits for notify_task_updated/1" do + assert %{ + "result" => %{ + "task" => %{ + "status" => %{"state" => "TASK_STATE_COMPLETED"}, + "artifacts" => [%{"parts" => [%{"text" => "Slow report ready"}]}] + } + } + } = + JSON.decode!( + finishing_slow_export(fn -> rpc("SendMessage", slow_export()) end).resp_body + ) + end + + test "SendStreamingMessage streams the task's changes as update events" do + assert [ + %{"task" => %{"id" => id, "status" => %{"state" => "TASK_STATE_WORKING"}}}, + %{ + "artifactUpdate" => %{ + "taskId" => id, + "contextId" => id, + "lastChunk" => true, + "artifact" => %{"parts" => [%{"text" => "Slow report ready"}]} + } + }, + %{ + "statusUpdate" => %{ + "taskId" => id, + "contextId" => id, + "status" => %{"state" => "TASK_STATE_COMPLETED"} + } + } + ] = + events(finishing_slow_export(fn -> rpc("SendStreamingMessage", slow_export()) end)) + end + + test "a task that finishes before the request listens isn't waited on" do + {:task, %{"id" => id}} = call(Router, "racy_confirm") + + answer = + Message.user( + [Part.data(%{"inputResponses" => %{"ok" => %{"action" => "accept"}}})], + task_id: id + ) + + {elapsed, conn} = + :timer.tc(fn -> rpc("SendMessage", %{"message" => Message.to_json(answer)}) end) + + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}} = + JSON.decode!(conn.resp_body) + + assert elapsed < 1_000_000 + end + + test "SubscribeToTask streams the task's changes as update events" do + {:task, %{"id" => id}} = call(Router, "slow_export") + + assert [ + %{"task" => %{"status" => %{"state" => "TASK_STATE_WORKING"}}}, + %{"artifactUpdate" => %{"artifact" => %{"artifactId" => "result"}}}, + %{"statusUpdate" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}} + ] = events(finishing_slow_export(fn -> rpc("SubscribeToTask", %{"id" => id}) end)) + + assert [] = Phantom.Tracker.list_task_listeners() + end + + test "a missing task leaves nothing listening" do + assert %{"error" => %{"code" => -32001}} = + JSON.decode!(rpc("SubscribeToTask", %{"id" => "nope"}).resp_body) + + assert [] = Phantom.Tracker.list_task_listeners() + end + end +end diff --git a/test/phantom/a2a/bridge_test.exs b/test/phantom/a2a/bridge_test.exs new file mode 100644 index 0000000..03eb88f --- /dev/null +++ b/test/phantom/a2a/bridge_test.exs @@ -0,0 +1,155 @@ +defmodule Phantom.A2A.BridgeTest.Chat do + use Phantom.A2A.Agent, name: "Chat", description: "Answers free text" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("chatting"), ctx} +end + +defmodule Phantom.A2A.BridgeTest.Router do + @moduledoc "Tools for studies" + + use Phantom.Router, name: "Studies", vsn: "2.0", instructions: @moduledoc + + require Phantom.Tool, as: Tool + + tool :echo, description: "Echo text" do + field :text, :string, required: true + end + + tool :stats, + description: "Study statistics", + output_schema: %{properties: %{count: %{type: "integer"}}} + + tool :explode, description: "Fails", input_schema: %{} + tool :secret, description: "Not bridged", input_schema: %{} + tool :leak, description: "Fails with an internal term", input_schema: %{} + + agent "tools", Phantom.A2A.Bridge, tools: ~w[echo stats explode] + agent "leaky", Phantom.A2A.Bridge, tools: ~w[leak] + agent "all", Phantom.A2A.Bridge, name: "Every tool", description: "All of them" + agent "chat", Phantom.A2A.Bridge, tools: ~w[echo], fallback: Phantom.A2A.BridgeTest.Chat + + def echo(%{"text" => text}, session), do: {:reply, Tool.text("echo: #{text}"), session} + def stats(_params, session), do: {:reply, Tool.text(%{count: 3}), session} + def explode(_params, session), do: {:reply, Tool.error("It broke"), session} + def secret(_params, session), do: {:reply, Tool.text("secret"), session} + def leak(_params, session), do: {:error, {:db, "postgres://admin:hunter2@db"}, session} + + def connect(session, conn) do + case Plug.Conn.get_req_header(conn, "x-tools") do + [tools] -> {:ok, Phantom.Session.allow_tools(session, String.split(tools, ","))} + [] -> {:ok, session} + end + end +end + +defmodule Phantom.A2A.BridgeTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.{Message, Part} + alias Phantom.A2A.BridgeTest.Router + + @extension "https://github.com/dbernheisel/phantom_mcp/a2a/extensions/mcp-tools/v1" + + defp call(agent, tool, args, opts \\ []), + do: + send_message( + Router, + agent, + Message.user([Part.data(%{"tool" => tool, "arguments" => args})]), + opts + ) + + test "the card lists the bridged tools as skills, with their schemas" do + card = get_card(Router, "tools") + + assert %{ + "name" => "Studies", + "description" => "Tools for studies", + "version" => "2.0", + "skills" => [ + %{"id" => "echo", "description" => "Echo text"}, + %{"id" => "explode"}, + %{"id" => "stats"} + ], + "capabilities" => %{ + "extensions" => [%{"uri" => @extension, "params" => %{"tools" => tools}}] + } + } = card + + assert [%{"name" => "echo", "inputSchema" => %{"required" => ["text"]}}, _, _] = tools + end + + test "names, descriptions, and every tool by default" do + assert %{"name" => "Every tool", "description" => "All of them", "skills" => skills} = + get_card(Router, "all") + + assert Enum.map(skills, & &1["id"]) == ~w[echo explode leak secret stats] + end + + test "a data part naming a tool calls it" do + assert {:message, %{"role" => "ROLE_AGENT", "parts" => [%{"text" => "echo: hi"}]}} = + call("tools", "echo", %{"text" => "hi"}) + end + + test "structured content is a data part" do + assert {:message, %{"parts" => parts}} = call("tools", "stats", %{}) + assert %{"data" => %{"count" => 3}} in parts + end + + test "a tool error is flagged in the metadata" do + assert {:message, + %{"parts" => [%{"text" => "It broke"}], "metadata" => %{"mcp/isError" => true}}} = + call("tools", "explode", %{}) + end + + test "arguments are validated, and only bridged, allowed tools run" do + assert {:error, %{"code" => -32602}} = call("tools", "echo", %{}) + + assert {:error, %{"code" => -32602, "message" => "Unknown tool: secret"}} = + call("tools", "secret", %{}) + + assert {:error, %{"code" => _}} = + call("tools", "echo", %{"text" => "hi"}, headers: [{"x-tools", "stats"}]) + end + + test "an internal error doesn't reach the client" do + ExUnit.CaptureLog.capture_log(fn -> + assert {:error, %{"code" => -32603} = error} = call("leaky", "leak", %{}) + refute JSON.encode!(error) =~ "hunter2" + end) + end + + test "free text goes to the fallback, or gets the list of tools" do + assert {:message, %{"parts" => [%{"text" => "chatting"}]}} = + send_message(Router, "chat", "hello") + + assert {:message, %{"parts" => [%{"text" => help}]}} = send_message(Router, "tools", "hello") + assert help =~ ~s("tool") + assert help =~ "echo, explode, stats" + end + + test "Phantom's client finds the tools on the card and calls one" do + alias Phantom.A2A.Client + + {:ok, client} = + Client.new("http://agent.test/tools/agent-card.json", + bindings: [:http_json], + req_options: [plug: {Phantom.A2A.Plug, router: Router, mount: ""}] + ) + + %{params: %{"tools" => tools}} = + Enum.find(client.card.extensions, &(&1.uri == Phantom.A2A.Bridge.extension())) + + assert "echo" in Enum.map(tools, & &1["name"]) + + assert {:ok, %Message{parts: [%Part{text: "echo: from the client"}]}} = + Client.send_message( + client, + Message.user([ + Part.data(%{"tool" => "echo", "arguments" => %{"text" => "from the client"}}) + ]) + ) + end +end diff --git a/test/phantom/a2a/call_tool_test.exs b/test/phantom/a2a/call_tool_test.exs new file mode 100644 index 0000000..3887499 --- /dev/null +++ b/test/phantom/a2a/call_tool_test.exs @@ -0,0 +1,87 @@ +defmodule Phantom.A2A.CallToolTest.Router do + use Phantom.Router, name: "Call tool", vsn: "1.0" + + require Phantom.Tool, as: Tool + + tool :echo, description: "Echo text" do + field :text, :string, required: true + end + + tool :async_echo, description: "Echo text from another process" do + field :text, :string, required: true + end + + @description "Ask the user for their name" + tool :ask_name, [] + + agent "assistant", Phantom.A2A.CallToolTest.Assistant + + def echo(%{"text" => text}, session), + do: {:reply, Tool.text("#{session.assigns.user}: #{text}"), session} + + def async_echo(%{"text" => text}, session) do + Task.start(fn -> Phantom.Session.respond(session, Tool.text("later: #{text}")) end) + {:noreply, session} + end + + def ask_name(_params, session) do + elicit = + Phantom.Elicit.form(%{message: "Name?", requested_schema: [%{name: "name", type: :string}]}) + + case Phantom.Session.elicit(session, elicit, await: true) do + :not_supported -> {:reply, Tool.text("Hello stranger"), session} + {:ok, _response} -> {:reply, Tool.text("Hello you"), session} + end + end + + def connect(session, _conn), do: {:ok, Phantom.Session.assign(session, user: "alice")} +end + +defmodule Phantom.A2A.CallToolTest.Assistant do + use Phantom.A2A.Agent, name: "Assistant", description: "Answers with the echo tool" + + alias Phantom.A2A.{Message, Part} + + def handle_message(%Message{parts: [%Part{text: text}]}, ctx) do + {:ok, %{content: [%{text: reply}]}} = Phantom.Router.call_tool(ctx.session, :echo, text: text) + {:reply, Message.agent(reply), ctx} + end +end + +defmodule Phantom.A2A.CallToolTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.CallToolTest.Router + + setup do + session = Phantom.Session.new(nil, router: Router, assigns: %{user: "alice"}) + {:ok, session: session} + end + + test "calls a tool and returns its result", %{session: session} do + assert {:ok, %{content: [%{type: :text, text: "alice: hi"}]}} = + Phantom.Router.call_tool(session, :echo, %{text: "hi"}) + end + + test "waits for a tool that answers from another process", %{session: session} do + assert {:ok, %{content: [%{text: "later: hi"}]}} = + Phantom.Router.call_tool(session, "async_echo", %{"text" => "hi"}) + end + + test "validates arguments and the allow-list", %{session: session} do + assert {:error, %{code: -32602}} = Phantom.Router.call_tool(session, :echo, %{}) + + session = Phantom.Session.allow_tools(session, ["async_echo"]) + assert {:error, %{code: _}} = Phantom.Router.call_tool(session, :echo, %{text: "hi"}) + end + + test "there is no client to elicit from", %{session: session} do + assert {:ok, %{content: [%{text: "Hello stranger"}]}} = + Phantom.Router.call_tool(session, :ask_name) + end + + test "an A2A agent can answer with its router's tools" do + assert {:message, %{"parts" => [%{"text" => "alice: hello"}]}} = + Phantom.A2A.Test.send_message(Router, "assistant", "hello") + end +end diff --git a/test/phantom/a2a/client_conformance_test.exs b/test/phantom/a2a/client_conformance_test.exs new file mode 100644 index 0000000..b7606f0 --- /dev/null +++ b/test/phantom/a2a/client_conformance_test.exs @@ -0,0 +1,247 @@ +defmodule Phantom.A2A.ClientConformanceTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.{Card, Client, Error, Message, StatusUpdate} + + defp card(interfaces, capabilities \\ %{"streaming" => true}) do + %Card{ + name: "Peer", + description: "A peer", + supported_interfaces: interfaces, + capabilities: capabilities + } + end + + defp interface(binding, url \\ "http://peer.test/a", extra \\ %{}) do + Map.merge( + %{"url" => url, "protocolBinding" => binding, "protocolVersion" => "1.0"}, + extra + ) + end + + defp client(interfaces, plug, opts \\ []) do + {:ok, client} = Client.new(card(interfaces), [req_options: [plug: plug]] ++ opts) + client + end + + # Tells the test about each request and answers with `body`. + defp capture(test, status \\ 200, content_type \\ "application/json", body) do + fn conn -> + {:ok, raw, conn} = Plug.Conn.read_body(conn) + send(test, {:request, conn.method, conn.request_path, raw}) + + conn + |> Plug.Conn.put_resp_content_type(content_type, nil) + |> Plug.Conn.send_resp(status, body) + end + end + + @empty_result ~s({"jsonrpc":"2.0","id":1,"result":{"message":{"messageId":"m","role":"ROLE_AGENT","parts":[{"text":"ok"}]}}}) + + describe "choosing an interface" do + test "prefers the card's earlier entries" do + interfaces = [interface("HTTP+JSON"), interface("JSONRPC")] + + assert {:ok, %Client{binding: :http_json}} = + Client.new(card(interfaces), bindings: [:jsonrpc, :http_json]) + end + + test "skips entries it can't use" do + interfaces = [interface("GRPC"), interface("JSONRPC")] + assert {:ok, %Client{binding: :jsonrpc}} = Client.new(card(interfaces)) + end + + test "ignores malformed entries" do + interfaces = ["x", %{"url" => 1, "protocolBinding" => "JSONRPC"}, interface("HTTP+JSON")] + assert {:ok, %Client{binding: :http_json}} = Client.new(card(interfaces)) + + assert {:error, :no_supported_interface} = + Client.new( + card([interface("JSONRPC", "http://x.test", %{"protocolVersion" => 1})]) + ) + end + end + + describe "the interface's tenant" do + test "is in every JSON-RPC request" do + plug = capture(self(), @empty_result) + interface = interface("JSONRPC", "http://peer.test/a", %{"tenant" => "acme"}) + + assert {:ok, %Message{}} = Client.send_message(client([interface], plug), "hi") + + assert_received {:request, "POST", "/a", raw} + assert %{"params" => %{"tenant" => "acme"}} = JSON.decode!(raw) + end + + test "is in every HTTP+JSON path" do + plug = + capture(self(), ~s({"id":"t1","contextId":"c","status":{"state":"TASK_STATE_WORKING"}})) + + interface = interface("HTTP+JSON", "http://peer.test/a", %{"tenant" => "acme corp"}) + + assert {:ok, _task} = Client.get_task(client([interface], plug), "t1") + assert_received {:request, "GET", "/a/acme%20corp/tasks/t1", _raw} + end + + test "is omitted when the interface has none" do + plug = capture(self(), @empty_result) + + assert {:ok, _} = Client.send_message(client([interface("JSONRPC")], plug), "hi") + + assert_received {:request, "POST", "/a", raw} + refute Map.has_key?(JSON.decode!(raw)["params"], "tenant") + end + end + + describe "credentials" do + defp card_plug(interface_url) do + card = card([interface("JSONRPC", interface_url)]) |> Card.to_json() |> JSON.encode!() + capture(self(), card) + end + + test "aren't sent to another origin the fetched card names" do + assert {:error, {:cross_origin_interface, "https://evil.test/a"}} = + Client.new("https://peer.test/card.json", + headers: [{"authorization", "Bearer secret"}], + req_options: [plug: card_plug("https://evil.test/a")] + ) + end + + test "a downgrade to http counts as another origin" do + assert {:error, {:cross_origin_interface, _url}} = + Client.new("https://peer.test/card.json", + headers: [{"authorization", "Bearer secret"}], + req_options: [plug: card_plug("http://peer.test/a")] + ) + end + + test "may go to another origin when allowed" do + assert {:ok, %Client{url: "https://api.test/a"}} = + Client.new("https://peer.test/card.json", + headers: [{"authorization", "Bearer secret"}], + cross_origin_headers: true, + req_options: [plug: card_plug("https://api.test/a")] + ) + end + + test "the same origin needs nothing, nor does sending no headers" do + assert {:ok, _} = + Client.new("https://peer.test/card.json", + headers: [{"authorization", "Bearer secret"}], + req_options: [plug: card_plug("https://peer.test/a")] + ) + + assert {:ok, _} = + Client.new("https://peer.test/card.json", + req_options: [plug: card_plug("https://other.test/a")] + ) + end + end + + describe "subscribing over HTTP+JSON" do + @event "data: {\"task\":{\"id\":\"t1\",\"contextId\":\"c\",\"status\":{\"state\":\"TASK_STATE_COMPLETED\"}}}\n\n" + + test "uses POST" do + plug = capture(self(), 200, "text/event-stream", @event) + + assert {:ok, stream} = Client.subscribe(client([interface("HTTP+JSON")], plug), "t1") + assert [%Phantom.A2A.Task{id: "t1"}] = Enum.to_list(stream) + assert_received {:request, "POST", "/a/tasks/t1:subscribe", _raw} + end + + test "falls back to GET for a server that only routes the proto's verb" do + test = self() + + plug = fn + %{method: "POST"} = conn -> + send(test, :post) + Plug.Conn.send_resp(conn, 405, "") + + conn -> + send(test, :get) + + conn + |> Plug.Conn.put_resp_content_type("text/event-stream", nil) + |> Plug.Conn.send_resp(200, @event) + end + + assert {:ok, stream} = Client.subscribe(client([interface("HTTP+JSON")], plug), "t1") + assert [%Phantom.A2A.Task{id: "t1"}] = Enum.to_list(stream) + assert_received :post + assert_received :get + end + end + + describe "reading a stream" do + defp stream_of(body) do + plug = capture(self(), 200, "text/event-stream", body) + {:ok, stream} = Client.stream_message(client([interface("JSONRPC")], plug), "hi") + stream + end + + @status ~s({"jsonrpc":"2.0","id":1,"result":{"statusUpdate":{"taskId":"t","contextId":"c","status":{"state":"TASK_STATE_WORKING"}}}}) + + test "accepts CRLF line endings" do + body = "event: x\r\ndata: #{@status}\r\n\r\ndata: #{@status}\r\n\r\n" + + assert [%StatusUpdate{}, %StatusUpdate{}] = Enum.to_list(stream_of(body)) + end + + test "accepts CR line endings" do + assert [%StatusUpdate{}] = Enum.to_list(stream_of("data: #{@status}\r\r")) + end + + test "ends at an error" do + error = ~s({"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Internal error"}}) + + assert [%StatusUpdate{}, %Error{code: -32603}] = + Enum.to_list( + stream_of("data: #{@status}\n\ndata: #{error}\n\ndata: #{@status}\n\n") + ) + end + + test "a malformed error ends it too" do + assert [%Error{code: -32006}] = Enum.to_list(stream_of(~s(data: {"error":"x"}\n\n))) + end + + test "stopping early leaves nothing in the caller's mailbox" do + test = self() + + plug = fn conn -> + conn = + conn + |> Plug.Conn.put_resp_content_type("text/event-stream", nil) + |> Plug.Conn.send_chunked(200) + + {:ok, conn} = Plug.Conn.chunk(conn, "data: #{@status}\n\ndata: #{@status}\n\n") + send(test, :chunked) + {:ok, conn} = Plug.Conn.chunk(conn, "data: #{@status}\n\n") + conn + end + + {:ok, stream} = Client.stream_message(client([interface("JSONRPC")], plug), "hi") + assert_receive :chunked + + assert [%StatusUpdate{}] = Enum.take(stream, 1) + + {:messages, messages} = Process.info(self(), :messages) + assert Enum.filter(messages, &match?({_ref, :data, _, _, _}, &1)) == [] + end + end + + describe "a malformed response" do + test "a non-object result is an invalid agent response" do + plug = capture(self(), ~s({"jsonrpc":"2.0","id":1,"result":[1]})) + + assert {:error, %Error{code: -32006}} = + Client.list_tasks(client([interface("JSONRPC")], plug)) + end + + test "a malformed error is an invalid agent response" do + plug = capture(self(), ~s({"jsonrpc":"2.0","id":1,"error":"x"})) + + assert {:error, %Error{code: -32006}} = + Client.send_message(client([interface("JSONRPC")], plug), "hi") + end + end +end diff --git a/test/phantom/a2a/client_test.exs b/test/phantom/a2a/client_test.exs new file mode 100644 index 0000000..e0baf45 --- /dev/null +++ b/test/phantom/a2a/client_test.exs @@ -0,0 +1,282 @@ +defmodule Phantom.A2A.ClientTest.Agent do + use Phantom.A2A.Agent, + name: "Client test", + description: "Answers the client", + push_notifications: true, + extended_card: true + + alias Phantom.A2A.{Artifact, Message, Part, Task, TaskStatus} + + @table Phantom.A2A.ClientTest + + def handle_message(%Message{parts: [%Part{text: "work"}]}, ctx) do + ref = ctx.task_ref + + spawn(fn -> + Phantom.A2A.publish(ref, Task.status(:working, "Working")) + Phantom.A2A.publish(ref, Artifact.update("out", [Part.text("done")])) + Phantom.A2A.publish(ref, Task.status(:completed)) + end) + + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(%Message{parts: [%Part{text: text}]}, ctx), + do: {:reply, Message.agent("echo: #{text}"), ctx} + + def get_task("missing", _ctx), do: {:error, :not_found} + + def get_task("live", %{task_ref: ref}) do + spawn(fn -> Phantom.A2A.publish(ref, Task.status(:completed)) end) + {:ok, task("live", :working)} + end + + def get_task(id, _ctx), do: {:ok, task(id, :working)} + def cancel_task(task, _ctx), do: {:ok, %{task | status: %TaskStatus{state: :canceled}}} + + def list_tasks(filter, _ctx) do + {:ok, + %{ + tasks: [task("a", :completed)], + next_page_token: if(filter.page_token, do: nil, else: "2"), + total_size: 2 + }} + end + + def create_push_config(config, _ctx) do + :ets.insert(@table, {config.task_id, config}) + {:ok, config} + end + + def get_push_config(task_id, id, _ctx) do + case for({^task_id, %{id: ^id} = c} <- :ets.lookup(@table, task_id), do: c) do + [config] -> {:ok, config} + [] -> {:error, :not_found} + end + end + + def list_push_configs(task_id, _ctx), + do: {:ok, for({_, c} <- :ets.lookup(@table, task_id), do: c)} + + def delete_push_config(task_id, id, _ctx) do + :ets.match_delete(@table, {task_id, %{id: id}}) + :ok + end + + def extended_card(card, _ctx), do: {:ok, %{card | name: "Client test (extended)"}} + + defp task(id, state) do + %Task{ + id: id, + context_id: "ctx-1", + status: %TaskStatus{state: state}, + artifacts: [Artifact.new("out", [Part.text(id)])], + history: [Message.user("one"), Message.user("two")] + } + end +end + +defmodule Phantom.A2A.ClientTest.Plain do + use Phantom.A2A.Agent, name: "Plain", description: "No optional capabilities" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("plain"), ctx} +end + +defmodule Phantom.A2A.ClientTest.Router do + use Phantom.Router, name: "Client test", vsn: "1.0", a2a: [well_known: "agent"] + + agent "agent", Phantom.A2A.ClientTest.Agent + agent "plain", Phantom.A2A.ClientTest.Plain + + def connect(session, _conn), do: {:ok, session} +end + +defmodule Phantom.A2A.ClientTest do + use ExUnit.Case, async: false + + alias Phantom.A2A.{Client, Error, Message, PushConfig, StatusUpdate, Task} + alias Phantom.A2A.ClientTest.Router + + setup_all do + start_supervised!({Agent, fn -> :ets.new(__MODULE__, [:named_table, :public, :bag]) end}) + start_supervised!({Phoenix.PubSub, name: Phantom.A2A.ClientTest.PubSub}) + :ok + end + + setup do + :ets.delete_all_objects(__MODULE__) + :ok + end + + @plug {Phantom.A2A.Plug, router: Router, pubsub: Phantom.A2A.ClientTest.PubSub, mount: ""} + + defp client(binding, path \\ "agent", plug \\ @plug) do + {:ok, client} = + Client.new("http://agent.test/#{path}/agent-card.json", + bindings: [binding], + req_options: [plug: plug] + ) + + client + end + + test "finds the card at the well-known path of a URL" do + assert {:ok, %Client{card: %{name: "Client test"}, url: "http://agent.test/agent"}} = + Client.new("http://agent.test", req_options: [plug: @plug]) + end + + test "prefers the first binding the card lists that it was asked for" do + assert %Client{binding: :jsonrpc} = client(:jsonrpc) + assert %Client{binding: :http_json} = client(:http_json) + + {:ok, card} = + Client.get_card("http://agent.test/agent/agent-card.json", req_options: [plug: @plug]) + + assert {:error, :no_supported_interface} = Client.new(card, bindings: [:grpc]) + end + + for binding <- [:jsonrpc, :http_json] do + describe "over #{binding}" do + @describetag binding: binding + + test "send_message/3 answers with a message or a task", %{binding: binding} do + client = client(binding) + + assert {:ok, %Message{role: :agent, parts: [%{text: "echo: hi"}]}} = + Client.send_message(client, "hi") + + assert {:ok, %Task{status: %{state: :submitted}}} = + Client.send_message(client, Message.user("work"), return_immediately: true) + + assert {:ok, %Task{status: %{state: :completed}, artifacts: [%{artifact_id: "out"}]}} = + Client.send_message(client, "work") + end + + test "stream_message/3 streams events", %{binding: binding} do + {:ok, stream} = Client.stream_message(client(binding), "work") + + assert [ + %Task{status: %{state: :submitted}}, + %StatusUpdate{status: %{state: :working}}, + %Phantom.A2A.ArtifactUpdate{artifact: %{artifact_id: "out"}}, + %StatusUpdate{status: %{state: :completed}} + ] = Enum.to_list(stream) + end + + test "tasks: get, list, cancel, and subscribe", %{binding: binding} do + client = client(binding) + + assert {:ok, %Task{id: "t1", history: [_]}} = + Client.get_task(client, "t1", history_length: 1) + + assert {:ok, + %{tasks: [%Task{id: "a", artifacts: [_]}], next_page_token: "2", total_size: 2}} = + Client.list_tasks(client, + context_id: "ctx-1", + status: :completed, + include_artifacts: true + ) + + assert {:ok, %{next_page_token: nil}} = Client.list_tasks(client, page_token: "2") + assert {:ok, %Task{status: %{state: :canceled}}} = Client.cancel_task(client, "t1") + + {:ok, stream} = Client.subscribe(client, "live") + + assert [%Task{id: "live"}, %StatusUpdate{status: %{state: :completed}}] = + Enum.to_list(stream) + end + + test "push notification configs", %{binding: binding} do + client = client(binding) + + assert {:ok, %PushConfig{id: id, task_id: "t1"}} = + Client.create_push_config(client, %PushConfig{ + task_id: "t1", + url: "https://example.com/hook" + }) + + assert {:ok, %PushConfig{id: ^id}} = Client.get_push_config(client, "t1", id) + assert {:ok, [%PushConfig{id: ^id}]} = Client.list_push_configs(client, "t1") + assert :ok = Client.delete_push_config(client, "t1", id) + assert {:error, %Error{code: -32001}} = Client.get_push_config(client, "t1", id) + end + + test "get_extended_card/1", %{binding: binding} do + assert {:ok, %{name: "Client test (extended)"}} = + Client.get_extended_card(client(binding)) + end + + test "errors come back as Phantom.A2A.Error", %{binding: binding} do + assert {:error, %Error{code: -32001, message: "Task not found: missing"}} = + Client.get_task(client(binding), "missing") + end + + test "optional operations need the card's capability", %{binding: binding} do + # No pubsub, so not even streaming is advertised. + client = client(binding, "plain", {Phantom.A2A.Plug, router: Router, mount: ""}) + + assert {:error, %Error{code: -32004}} = Client.stream_message(client, "hi") + assert {:error, %Error{code: -32004}} = Client.subscribe(client, "t1") + assert {:error, %Error{code: -32003}} = Client.list_push_configs(client, "t1") + assert {:error, %Error{code: -32004}} = Client.get_extended_card(client) + end + end + end + + test "streams over a real connection" do + {:ok, server} = + Bandit.start_link(plug: @plug, ip: :loopback, port: 0, startup_log: false) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + + {:ok, client} = + Client.new("http://127.0.0.1:#{port}/agent/agent-card.json", bindings: [:http_json]) + + assert client.url == "http://127.0.0.1:#{port}/agent" + {:ok, stream} = Client.stream_message(client, "work") + + assert [%Task{}, %StatusUpdate{}, _artifact, %StatusUpdate{status: %{state: :completed}}] = + Enum.to_list(stream) + end + + test "sends headers and extensions" do + test = self() + + plug = fn conn -> + send(test, {:headers, conn.req_headers}) + + Plug.Conn.send_resp( + conn, + 200, + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => 1, + "result" => %{ + "message" => %{ + "messageId" => "m", + "role" => "ROLE_AGENT", + "parts" => [%{"text" => "ok"}] + } + } + }) + ) + end + + {:ok, card} = + Client.get_card("http://agent.test/agent/agent-card.json", req_options: [plug: @plug]) + + {:ok, client} = + Client.new(card, + bindings: [:jsonrpc], + headers: [{"authorization", "Bearer t"}], + extensions: ["https://x.test/ext"], + req_options: [plug: plug] + ) + + assert {:ok, %Message{}} = Client.send_message(client, "hi") + assert_received {:headers, headers} + assert {"a2a-version", "1.0"} in headers + assert {"authorization", "Bearer t"} in headers + assert {"a2a-extensions", "https://x.test/ext"} in headers + end +end diff --git a/test/phantom/a2a/codec_test.exs b/test/phantom/a2a/codec_test.exs new file mode 100644 index 0000000..47c1302 --- /dev/null +++ b/test/phantom/a2a/codec_test.exs @@ -0,0 +1,292 @@ +defmodule Phantom.A2A.CodecTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.{Artifact, ArtifactUpdate, Message, Part, StatusUpdate, Task, TaskStatus} + + describe "Part" do + test "decodes each kind of content" do + assert {:ok, %Part{text: "hi"}} = Part.from_json(%{"text" => "hi"}) + + assert {:ok, %Part{raw: "bytes", media_type: "application/octet-stream", filename: "a.bin"}} = + Part.from_json(%{ + "raw" => Base.encode64("bytes"), + "mediaType" => "application/octet-stream", + "filename" => "a.bin" + }) + + assert {:ok, %Part{url: "https://example.com/a.pdf"}} = + Part.from_json(%{"url" => "https://example.com/a.pdf"}) + + assert {:ok, %Part{data: %{"study_id" => 42}}} = + Part.from_json(%{"data" => %{"study_id" => 42}}) + end + + test "rejects a part without content or with invalid raw bytes" do + assert {:error, _} = Part.from_json(%{"mediaType" => "text/plain"}) + assert {:error, _} = Part.from_json(%{"raw" => "not base64!"}) + assert {:error, _} = Part.from_json("text") + end + + test "encodes only the fields that are set" do + assert %{"text" => "hi"} == Part.to_json(Part.text("hi")) + + assert %{"raw" => "Ynl0ZXM=", "mediaType" => "image/png"} == + Part.to_json(Part.raw("bytes", media_type: "image/png")) + + assert %{"data" => %{"ok" => true}} == Part.to_json(Part.data(%{"ok" => true})) + end + end + + describe "Message" do + test "decodes a client message" do + json = %{ + "messageId" => "m1", + "role" => "ROLE_USER", + "contextId" => "ctx", + "taskId" => "t1", + "parts" => [%{"text" => "hello"}], + "metadata" => %{"k" => "v"}, + "referenceTaskIds" => ["t0"] + } + + assert {:ok, + %Message{ + message_id: "m1", + role: :user, + context_id: "ctx", + task_id: "t1", + parts: [%Part{text: "hello"}], + metadata: %{"k" => "v"}, + reference_task_ids: ["t0"] + }} = Message.from_json(json) + end + + test "requires messageId, a role, and parts" do + assert {:error, _} = Message.from_json(%{"role" => "ROLE_USER", "parts" => []}) + assert {:error, _} = Message.from_json(%{"messageId" => "m1", "parts" => []}) + + assert {:error, _} = + Message.from_json(%{"messageId" => "m1", "role" => "ROLE_BOSS", "parts" => []}) + + assert {:error, _} = Message.from_json(%{"messageId" => "m1", "role" => "ROLE_USER"}) + + assert {:error, _} = + Message.from_json(%{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{}]}) + end + + test "builds an agent message from text or parts" do + assert %Message{role: :agent, parts: [%Part{text: "pong"}], message_id: id} = + Message.agent("pong") + + assert is_binary(id) + + assert %Message{parts: [%Part{data: %{"a" => 1}}]} = Message.agent([Part.data(%{"a" => 1})]) + end + + test "encodes to ProtoJSON" do + message = %Message{ + message_id: "m2", + role: :agent, + context_id: "ctx", + parts: [Part.text("pong")] + } + + assert %{ + "messageId" => "m2", + "role" => "ROLE_AGENT", + "contextId" => "ctx", + "parts" => [%{"text" => "pong"}] + } == Message.to_json(message) + end + end + + describe "Task" do + test "encodes to ProtoJSON" do + task = %Task{ + id: "t1", + context_id: "ctx", + status: %TaskStatus{ + state: :input_required, + message: %Message{message_id: "m3", role: :agent, parts: [Part.text("More?")]}, + timestamp: ~U[2026-10-27 12:00:00Z] + }, + artifacts: [Artifact.new("review", [Part.text("## Gaps")], name: "Review")], + history: [%Message{message_id: "m1", role: :user, parts: [Part.text("Review")]}] + } + + assert %{ + "id" => "t1", + "contextId" => "ctx", + "status" => %{ + "state" => "TASK_STATE_INPUT_REQUIRED", + "message" => %{"messageId" => "m3", "role" => "ROLE_AGENT"}, + "timestamp" => "2026-10-27T12:00:00Z" + }, + "artifacts" => [ + %{ + "artifactId" => "review", + "name" => "Review", + "parts" => [%{"text" => "## Gaps"}] + } + ], + "history" => [%{"messageId" => "m1", "role" => "ROLE_USER"}] + } = Task.to_json(task) + end + + test "trims history to the requested length" do + history = for i <- 1..4, do: %Message{message_id: "m#{i}", role: :user, parts: []} + + task = %Task{ + id: "t1", + context_id: "c", + status: %TaskStatus{state: :working}, + history: history + } + + assert %{"history" => [%{"messageId" => "m3"}, %{"messageId" => "m4"}]} = + Task.to_json(task, history_length: 2) + + refute Map.has_key?(Task.to_json(task, history_length: 0), "history") + assert %{"history" => [_, _, _, _]} = Task.to_json(task) + end + + test "status updates and artifact updates encode as stream events" do + status = Task.status(:working, "Reading study…") + + assert %StatusUpdate{status: %TaskStatus{state: :working, message: %Message{role: :agent}}} = + status + + update = Artifact.update("review", [Part.text("- More")], append: true) + assert %ArtifactUpdate{append: true, artifact: %Artifact{artifact_id: "review"}} = update + + status = %{status | task_id: "t1", context_id: "c"} + update = %{update | task_id: "t1", context_id: "c"} + + assert %{ + "statusUpdate" => %{ + "taskId" => "t1", + "contextId" => "c", + "status" => %{"state" => "TASK_STATE_WORKING"} + } + } = Phantom.A2A.event_to_json(status) + + assert %{ + "artifactUpdate" => %{ + "taskId" => "t1", + "append" => true, + "artifact" => %{"artifactId" => "review"} + } + } = Phantom.A2A.event_to_json(update) + end + end + + describe "decoding what a server sends" do + alias Phantom.A2A.{AgentSkill, Card, Error} + + test "a task round-trips through ProtoJSON" do + task = %Task{ + id: "t1", + context_id: "c", + status: %TaskStatus{ + state: :input_required, + message: %Message{ + message_id: "m", + role: :agent, + task_id: "t1", + context_id: "c", + parts: [Part.text("More?")] + }, + timestamp: ~U[2026-10-27 12:00:00Z] + }, + artifacts: [ + Artifact.new("a", [Part.text("x"), Part.data(%{"k" => 1})], name: "A"), + Artifact.new("f", [Part.raw("pdf", media_type: "application/pdf", filename: "r.pdf")]) + ], + history: [%Message{message_id: "m0", role: :user, parts: [Part.url("https://x.test/f")]}], + metadata: %{"m" => true} + } + + assert {:ok, ^task} = task |> Task.to_json() |> Task.from_json() + assert {:error, _} = Task.from_json(%{"id" => "t1"}) + + assert {:error, _} = + Task.from_json(%{ + "id" => "t1", + "contextId" => "c", + "status" => %{"state" => "NOPE"} + }) + end + + test "stream events" do + status = %{Task.status(:working, "Hi") | task_id: "t1", context_id: "c", sequence: nil} + status = put_in(status.status.message.task_id, "t1") + status = put_in(status.status.message.context_id, "c") + + update = %{ + Artifact.update("a", [Part.text("x")], append: true, last_chunk: true) + | task_id: "t1", + context_id: "c" + } + + message = Message.agent("hi") + task = %Task{id: "t1", context_id: "c", status: %TaskStatus{state: :completed}} + + for event <- [status, update, message, task] do + assert {:ok, ^event} = + event |> Phantom.A2A.event_to_json() |> Phantom.A2A.event_from_json() + end + + assert {:error, _} = Phantom.A2A.event_from_json(%{"nope" => %{}}) + end + + test "an agent card" do + json = %{ + "name" => "Agent", + "description" => "Does things", + "version" => "1.0", + "supportedInterfaces" => [ + %{ + "url" => "https://x.test/a2a", + "protocolBinding" => "JSONRPC", + "protocolVersion" => "1.0" + } + ], + "capabilities" => %{ + "streaming" => true, + "extensions" => [%{"uri" => "https://x.test/ext", "required" => true}] + }, + "defaultInputModes" => ["text/plain"], + "defaultOutputModes" => ["text/plain"], + "skills" => [%{"id" => "s", "name" => "S", "description" => "Skill", "tags" => ["t"]}], + "securitySchemes" => %{"bearer" => %{}} + } + + assert {:ok, + %Card{ + name: "Agent", + skills: [%AgentSkill{id: "s", tags: ["t"]}], + capabilities: %{"streaming" => true}, + extensions: [%{uri: "https://x.test/ext", required: true}], + supported_interfaces: [%{"protocolBinding" => "JSONRPC"}] + } = card} = Card.from_json(json) + + assert Card.to_json(card)["name"] == "Agent" + assert {:error, _} = Card.from_json(%{"description" => "no name"}) + end + + test "errors from either binding" do + assert %Error{code: -32001, message: "Task not found"} = + Error.from_json(%{"code" => -32001, "message" => "Task not found"}) + + assert %Error{code: -32001, message: "gone"} = + Error.from_json(%{ + "code" => 404, + "status" => "NOT_FOUND", + "message" => "gone", + "details" => [%{"reason" => "TASK_NOT_FOUND"}] + }) + + assert %Error{code: -32603} = Error.from_json(%{"code" => 500, "message" => "boom"}) + end + end +end diff --git a/test/phantom/a2a/conformance_test.exs b/test/phantom/a2a/conformance_test.exs new file mode 100644 index 0000000..539fc00 --- /dev/null +++ b/test/phantom/a2a/conformance_test.exs @@ -0,0 +1,631 @@ +defmodule Phantom.A2A.ConformanceTest.Agent do + use Phantom.A2A.Agent, + name: "Conformance", + description: "Exercises the protocol's edges", + push_notifications: true, + extended_card: true + + alias Phantom.A2A.{Artifact, Message, Part, Task, TaskStatus} + + skill :untagged, description: "A skill declared without tags" + + def handle_message(%Message{parts: [%Part{text: "poison"}]}, ctx) do + ref = ctx.task_ref + + spawn(fn -> + Phantom.A2A.publish(ref, Task.status(:working, nil, metadata: %{bad: {:tuple}})) + end) + + {:reply, Task.submitted(ctx), ctx} + end + + # Waits for a credential sent out of band, then carries on (§7.6.1). + def handle_message(%Message{parts: [%Part{text: "auth"}]}, ctx) do + ref = ctx.task_ref + + spawn(fn -> + Phantom.A2A.publish(ref, Task.status(:auth_required, "Sign in")) + Phantom.A2A.publish(ref, Task.status(:completed, "Done")) + end) + + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(%Message{parts: [%Part{text: "say"}]}, ctx) do + ref = ctx.task_ref + spawn(fn -> Phantom.A2A.publish(ref, Task.status(:completed, "Done")) end) + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(%Message{parts: [%Part{text: "history"}]}, ctx) do + {:reply, + Task.submitted(ctx, + status: %TaskStatus{state: :completed}, + history: [Message.user("one"), Message.user("two")] + ), ctx} + end + + def handle_message(%Message{parts: [%Part{text: "poll"}]} = _message, ctx) do + send(self(), :handled) + :persistent_term.put({__MODULE__, ctx.task_ref.task_id}, :completed) + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(_message, ctx) do + send(self(), :handled) + {:reply, Task.submitted(ctx), ctx} + end + + def get_task("authing", %{task_ref: %Phantom.A2A.TaskRef{} = ref}) do + spawn(fn -> Phantom.A2A.publish(ref, Task.status(:completed)) end) + {:ok, task("authing", :auth_required)} + end + + def get_task("stored-status", _ctx) do + {:ok, + %Task{ + id: "stored-status", + context_id: "ctx-1", + status: %TaskStatus{state: :working, message: Message.agent("Reading")} + }} + end + + def get_task(id, _ctx) do + case :persistent_term.get({__MODULE__, id}, nil) do + nil when id in ["a:b", "a:subscribe", "working"] -> {:ok, task(id, :working)} + nil -> {:error, :not_found} + state -> {:ok, task(id, state)} + end + end + + def list_tasks(%{page_token: nil}, _ctx), + do: {:ok, %{tasks: [task("a", :completed)], next_page_token: "2"}} + + def list_tasks(_filter, _ctx), do: {:ok, %{tasks: [task("b", :completed)]}} + + def list_push_configs(_task_id, _ctx), do: {:ok, []} + + defp task(id, state), + do: %Task{ + id: id, + context_id: "ctx-1", + status: %TaskStatus{state: state}, + artifacts: [Artifact.new("out", [Part.text(id)])] + } +end + +defmodule Phantom.A2A.ConformanceTest.NoStore do + use Phantom.A2A.Agent, name: "No store", description: "Keeps no tasks" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Task.submitted(ctx), ctx} +end + +defmodule Phantom.A2A.ConformanceTest.Resolved do + use Phantom.A2A.Agent, name: "Resolved", description: "Resolved per organization" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("ok"), ctx} + + def resolve(%{"org" => org}, _session) do + {:ok, + %Phantom.A2A.Card{ + name: "Private to #{org}", + description: "Only for #{org}", + default_input_modes: ["text/plain"], + default_output_modes: ["text/plain"] + }, org} + end +end + +defmodule Phantom.A2A.ConformanceTest.Router do + use Phantom.Router, name: "Conformance", vsn: "1.0" + + agent "agent", Phantom.A2A.ConformanceTest.Agent + agent "nostore", Phantom.A2A.ConformanceTest.NoStore + agent "orgs/:org", Phantom.A2A.ConformanceTest.Resolved, :resolve + + def connect(session, _conn), do: {:ok, session} +end + +defmodule Phantom.A2A.ConformanceTest.AnonymousRouter do + use Phantom.Router, name: "Anonymous", vsn: "1.0" + + agent "agent", Phantom.A2A.ConformanceTest.Agent +end + +defmodule Phantom.A2A.ConformanceTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.{Card, Error, Message, Part, PushConfig, Task, TaskStatus} + alias Phantom.A2A.ConformanceTest.{AnonymousRouter, Router} + + @pubsub Phantom.A2A.ConformanceTest.PubSub + + setup_all do + start_supervised!({Phoenix.PubSub, name: @pubsub}) + :ok + end + + defp plug(opts), do: Phantom.A2A.Plug.init([router: Router, pubsub: @pubsub] ++ opts) + + defp rpc(method, params, opts \\ []) do + {path, opts} = Keyword.pop(opts, :path, "/agent") + {version, opts} = Keyword.pop(opts, :version, "1.0") + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + :post + |> Plug.Test.conn(path, JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> then(&if version, do: Plug.Conn.put_req_header(&1, "a2a-version", version), else: &1) + |> Phantom.A2A.Plug.call(plug(opts)) + end + + defp rpc_json(method, params, opts \\ []), + do: JSON.decode!(rpc(method, params, opts).resp_body) + + defp rest(verb, path, opts \\ []) do + verb + |> Plug.Test.conn(path) + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(plug(opts)) + end + + defp user_message(text \\ "go", extra \\ %{}) do + Map.merge( + %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]}, + extra + ) + end + + defp frames(conn) do + for frame <- String.split(conn.resp_body, "\n\n", trim: true), + "data: " <> data <- String.split(frame, "\n") do + JSON.decode!(data) + end + end + + describe "events in a shared process" do + test "an event for another task doesn't change this request's task" do + send(self(), {:phantom_a2a_event, "another-task", Task.status(:completed, "Not yours")}) + + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_SUBMITTED"}}}} = + rpc_json("SendMessage", %{"message" => user_message()}, blocking_timeout: 30) + end + + test "a request leaves no events behind for the next one on the connection" do + send(self(), {:phantom_a2a_event, "another-task", Task.status(:completed)}) + send(self(), {:"$gen_cast", {:task_updated, "another-task"}}) + + rpc_json("SendMessage", %{"message" => user_message()}, blocking_timeout: 1) + + refute_received {:phantom_a2a_event, _task_id, _event} + refute_received {:"$gen_cast", {:task_updated, _task_id}} + end + end + + describe "a failure while streaming" do + @tag :capture_log + test "ends the stream with an error event, not a second response" do + conn = rpc("SendStreamingMessage", %{"message" => user_message("poison")}) + + assert conn.status == 200 + + # The rescue only holds the conn from before the stream, so this is all + # the test adapter reports. A second response would have raised instead. + assert [%{"error" => %{"code" => -32603}}] = frames(conn) + end + end + + describe "a failure while streaming, over a real connection" do + @tag :capture_log + test "writes the task, then the error, in one response" do + body = + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "SendStreamingMessage", + "params" => %{"message" => user_message("poison")} + }) + + server = + start_supervised!( + {Bandit, + plug: {Phantom.A2A.Plug, router: Router, pubsub: @pubsub}, + ip: :loopback, + port: 0, + startup_log: false} + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) + + :ok = + :gen_tcp.send(socket, [ + "POST /agent HTTP/1.1\r\nhost: localhost\r\nconnection: close\r\n", + "a2a-version: 1.0\r\ncontent-type: application/json\r\n", + "content-length: #{byte_size(body)}\r\n\r\n", + body + ]) + + response = read_all(socket, "") + + assert length(String.split(response, "HTTP/1.1 ")) == 2 + assert response =~ ~s("task":) + assert response =~ ~s("code":-32603) + + assert elem(:binary.match(response, ~s("task":)), 0) < + elem(:binary.match(response, ~s("code":-32603)), 0) + end + + defp read_all(socket, acc) do + case :gen_tcp.recv(socket, 0, 2_000) do + {:ok, data} -> read_all(socket, acc <> data) + {:error, :closed} -> acc + end + end + end + + describe "agent card caching" do + test "a resolved card is private to the client" do + conn = rest(:get, "/orgs/acme/agent-card.json") + + assert conn.status == 200 + assert [cache_control] = Plug.Conn.get_resp_header(conn, "cache-control") + assert String.starts_with?(cache_control, "private") + end + + test "a static card stays public" do + conn = rest(:get, "/agent/agent-card.json") + assert [cache_control] = Plug.Conn.get_resp_header(conn, "cache-control") + assert String.starts_with?(cache_control, "public") + end + end + + describe "agent card contents" do + test "required arrays have at least one element" do + for path <- ["/nostore/agent-card.json", "/agent/agent-card.json"] do + card = JSON.decode!(rest(:get, path).resp_body) + + assert [_ | _] = card["skills"] + assert Enum.all?(card["skills"], &match?([_ | _], &1["tags"])) + end + end + end + + describe "HTTP+JSON path segments" do + test "task IDs are percent-decoded" do + assert %{"id" => "a:b"} = JSON.decode!(rest(:get, "/agent/tasks/a%3Ab").resp_body) + end + + test "an encoded colon in an ID isn't read as a verb" do + assert %{"id" => "a:subscribe"} = + JSON.decode!(rest(:get, "/agent/tasks/a%3Asubscribe").resp_body) + end + + test "a malformed escape doesn't raise" do + assert rest(:get, "/agent/tasks/a%zz").status == 404 + end + end + + describe "invalid parameters" do + test "a non-scalar query parameter is rejected" do + assert rest(:get, "/agent/tasks?pageSize[a]=1").status == 400 + assert rest(:get, "/agent/tasks/working?historyLength[a]=1").status == 400 + end + + for {name, configuration} <- [ + {"configuration", "x"}, + {"historyLength", %{"historyLength" => "x"}}, + {"negative historyLength", %{"historyLength" => -1}}, + {"returnImmediately", %{"returnImmediately" => "yes"}}, + {"acceptedOutputModes", %{"acceptedOutputModes" => "text/plain"}}, + {"taskPushNotificationConfig", %{"taskPushNotificationConfig" => "x"}} + ] do + test "SendMessage rejects #{name} before the handler runs" do + params = %{ + "message" => user_message(), + "configuration" => unquote(Macro.escape(configuration)) + } + + assert %{"error" => %{"code" => -32602}} = rpc_json("SendMessage", params) + refute_received :handled + end + end + + test "GetTask rejects a non-integer historyLength" do + assert %{"error" => %{"code" => -32602}} = + rpc_json("GetTask", %{"id" => "working", "historyLength" => "x"}) + end + + test "ListTasks rejects non-string filters" do + assert %{"error" => %{"code" => -32602}} = rpc_json("ListTasks", %{"contextId" => 1}) + assert %{"error" => %{"code" => -32602}} = rpc_json("ListTasks", %{"pageToken" => %{}}) + end + + for {name, extra} <- [ + {"empty parts", %{"parts" => []}}, + {"a part with two kinds of content", + %{"parts" => [%{"text" => "x", "url" => "https://x.test"}]}}, + {"a non-string contextId", %{"contextId" => 1}}, + {"a non-string taskId", %{"taskId" => %{}}}, + {"non-object metadata", %{"metadata" => "x"}}, + {"non-list extensions", %{"extensions" => "x"}} + ] do + test "SendMessage rejects #{name}" do + message = user_message("go", unquote(Macro.escape(extra))) + + assert %{"error" => %{"code" => -32602}} = + rpc_json("SendMessage", %{"message" => message}) + + refute_received :handled + end + end + end + + describe "A2A-Version" do + test "must match the major and minor version" do + assert %{"error" => %{"code" => -32009}} = + rpc_json("GetTask", %{"id" => "working"}, version: "1.9") + + assert %{"result" => %{"id" => "working"}} = + rpc_json("GetTask", %{"id" => "working"}, version: "1.0") + end + + test "may be a request parameter" do + assert %{"result" => %{"id" => "working"}} = + rpc_json("GetTask", %{"id" => "working"}, + path: "/agent?A2A-Version=1.0", + version: nil + ) + + assert %{"error" => %{"code" => -32009}} = + rpc_json("GetTask", %{"id" => "working"}, + path: "/agent?A2A-Version=0.3", + version: nil + ) + end + end + + describe "history" do + test "historyLength applies to the task a stream starts with" do + conn = + rpc("SendStreamingMessage", %{ + "message" => user_message("history"), + "configuration" => %{"historyLength" => 1} + }) + + assert [%{"result" => %{"task" => %{"history" => [%{"parts" => [%{"text" => "two"}]}]}}}] = + frames(conn) + end + + test "is omitted when there is none to return" do + assert %{"result" => %{"task" => task}} = + rpc_json("SendMessage", %{ + "message" => user_message("history"), + "configuration" => %{"historyLength" => 0} + }) + + refute Map.has_key?(task, "history") + end + end + + describe "AUTH_REQUIRED" do + test "a stream stays open until the task finishes" do + conn = rpc("SendStreamingMessage", %{"message" => user_message("auth")}) + + assert [ + %{"result" => %{"task" => _}}, + %{ + "result" => %{ + "statusUpdate" => %{"status" => %{"state" => "TASK_STATE_AUTH_REQUIRED"}} + } + }, + %{ + "result" => %{ + "statusUpdate" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}} + } + } + ] = frames(conn) + end + + test "a subscription to a task waiting on auth stays open" do + conn = rpc("SubscribeToTask", %{"id" => "authing"}) + + assert [ + %{ + "result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_AUTH_REQUIRED"}}} + }, + %{ + "result" => %{ + "statusUpdate" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}} + } + } + ] = frames(conn) + end + + test "a blocking SendMessage returns at once so the client can authenticate" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_AUTH_REQUIRED"}}}} = + rpc_json("SendMessage", %{"message" => user_message("auth")}) + end + end + + describe "status messages" do + test "carry the task and context IDs in a stream" do + conn = rpc("SendStreamingMessage", %{"message" => user_message("say")}) + + assert [ + %{"result" => %{"task" => %{"id" => id, "contextId" => context_id}}}, + %{ + "result" => %{ + "statusUpdate" => %{ + "status" => %{"message" => %{"taskId" => id, "contextId" => context_id}} + } + } + } + ] = frames(conn) + end + + test "carry the task and context IDs on a stored task" do + assert %{ + "result" => %{ + "status" => %{ + "message" => %{"taskId" => "stored-status", "contextId" => "ctx-1"} + } + } + } = rpc_json("GetTask", %{"id" => "stored-status"}) + end + end + + describe "blocking SendMessage without pubsub" do + test "polls the agent's store until the task settles" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}} = + rpc_json("SendMessage", %{"message" => user_message("poll")}, + pubsub: nil, + poll_interval: 1, + blocking_timeout: 1_000 + ) + end + + test "returns at once when the agent stores no tasks" do + {elapsed, body} = + :timer.tc(fn -> + rpc_json("SendMessage", %{"message" => user_message()}, + path: "/nostore", + pubsub: nil, + poll_interval: 1, + blocking_timeout: 5_000 + ) + end) + + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_SUBMITTED"}}}} = + body + + assert elapsed < 1_000_000 + end + + test "polls once more at the deadline" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}} = + rpc_json("SendMessage", %{"message" => user_message("poll")}, + pubsub: nil, + poll_interval: 60_000, + blocking_timeout: 10 + ) + end + end + + describe "blocking_timeout: :infinity" do + test "waits on events with no deadline" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}} = + rpc_json("SendMessage", %{"message" => user_message("say")}, + blocking_timeout: :infinity + ) + end + + test "polls with no deadline" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}} = + rpc_json("SendMessage", %{"message" => user_message("poll")}, + pubsub: nil, + poll_interval: 1, + blocking_timeout: :infinity + ) + end + end + + describe "ListTasks totalSize" do + test "is not guessed from a page when more pages follow" do + assert %{"error" => %{"code" => -32006}} = rpc_json("ListTasks", %{}) + end + + test "is the page's length on the last page" do + assert %{"result" => %{"totalSize" => 1}} = rpc_json("ListTasks", %{"pageToken" => "2"}) + end + end + + describe "push config pageSize" do + test "isn't capped as ListTasks is" do + assert %{"result" => %{"configs" => []}} = + rpc_json("ListTaskPushNotificationConfigs", %{ + "taskId" => "working", + "pageSize" => 500 + }) + end + end + + describe "GetExtendedAgentCard" do + test "needs a router that authenticates" do + assert %{"error" => %{"code" => -32007}} = + rpc_json("GetExtendedAgentCard", %{}, router: AnonymousRouter) + end + + test "is served once connect/2 is implemented" do + assert %{"result" => %{"name" => "Conformance"}} = rpc_json("GetExtendedAgentCard", %{}) + end + end + + describe "decoding what a peer sends" do + test "a task may have no contextId" do + assert {:ok, %Task{id: "t1", context_id: nil}} = + Task.from_json(%{"id" => "t1", "status" => %{"state" => "TASK_STATE_WORKING"}}) + end + + test "a task with a non-string contextId is an error" do + assert {:error, _} = + Task.from_json(%{ + "id" => "t1", + "contextId" => 1, + "status" => %{"state" => "TASK_STATE_WORKING"} + }) + end + + test "a non-string timestamp is an error" do + assert {:error, _} = + TaskStatus.from_json(%{"state" => "TASK_STATE_WORKING", "timestamp" => 5}) + end + + test "malformed errors are invalid agent responses" do + for error <- ["x", %{}, %{"code" => "x", "message" => 1}, %{"message" => 1}, 5] do + assert %Error{code: -32006} = Error.from_json(error) + end + end + + test "error details that aren't a list are ignored" do + assert %Error{code: -32603, message: "m"} = + Error.from_json(%{"message" => "m", "details" => "x"}) + end + + test "a card with malformed fields is an error" do + for field <- [ + %{"supportedInterfaces" => "x"}, + %{"capabilities" => "x"}, + %{"capabilities" => %{"extensions" => "x"}}, + %{"capabilities" => %{"extensions" => ["x"]}} + ] do + assert {:error, _} = Card.from_json(Map.put(field, "name", "n")) + end + end + + test "push configs need string fields" do + url = "https://x.test/hook" + + for field <- [%{"token" => %{}}, %{"id" => 1}, %{"taskId" => 1}, %{"tenant" => 1}] do + assert {:error, _} = PushConfig.from_json(Map.put(field, "url", url)) + end + + assert {:error, _} = + PushConfig.from_json(%{ + "url" => url, + "authentication" => %{"scheme" => "Bearer", "credentials" => 1} + }) + end + + test "a part has exactly one kind of content" do + assert {:error, _} = Part.from_json(%{"text" => "a", "data" => %{}}) + assert {:error, _} = Part.from_json(%{}) + assert {:ok, %Part{text: "a"}} = Part.from_json(%{"text" => "a", "data" => nil}) + end + + test "a message needs at least one part" do + assert {:error, _} = + Message.from_json(%{"messageId" => "m", "role" => "ROLE_USER", "parts" => []}) + end + end +end diff --git a/test/phantom/a2a/distributed_test.exs b/test/phantom/a2a/distributed_test.exs new file mode 100644 index 0000000..33c7e4e --- /dev/null +++ b/test/phantom/a2a/distributed_test.exs @@ -0,0 +1,86 @@ +defmodule Phantom.A2A.DistributedTest do + use Phantom.Test.NodeCase + + alias Phantom.A2A.{Task, TaskRef} + + @node1 :"node1@127.0.0.1" + @node2 :"node2@127.0.0.1" + @port 4140 + + # `:rpc.block_call` runs in the peer's long-lived rex process, so what it + # links to keeps running. Both nodes store push configs; node1 serves the agent. + setup_all do + {:ok, _} = :rpc.block_call(@node2, Phantom.ACTS.Store, :start_link, [[]]) + + {:ok, _} = + :rpc.block_call(@node1, Supervisor, :start_link, [ + [ + Phantom.ACTS.Store, + {Bandit, + plug: {Phantom.ACTS.HTTP, pubsub: Phantom.Test.PubSub}, + ip: {127, 0, 0, 1}, + port: @port, + startup_log: false} + ], + [strategy: :one_for_one] + ]) + + :ok + end + + defp rpc(method, params, opts \\ []) do + Req.post!("http://127.0.0.1:#{@port}/a2a/acts", + json: %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params}, + headers: [{"a2a-version", "1.0"}] ++ Keyword.get(opts, :headers, []), + into: Keyword.get(opts, :into), + receive_timeout: @timeout + ) + end + + test "an event published on one node reaches a stream on another" do + %{body: %{"result" => %{"task" => %{"id" => task_id, "contextId" => context_id}}}} = + rpc("SendMessage", %{ + "message" => %{ + "messageId" => "m1", + "role" => "ROLE_USER", + "parts" => [%{"text" => "tck-cancel"}] + }, + "configuration" => %{"returnImmediately" => true} + }) + + stream = + rpc("SubscribeToTask", %{"id" => task_id}, + headers: [{"accept", "text/event-stream"}], + into: :self + ) + + # The current task comes first, so the stream is subscribed. + assert poll_for_sse_event(stream, @timeout, &match?(%{"result" => %{"task" => _}}, &1)) + + ref = %TaskRef{ + pubsub: Phantom.Test.PubSub, + router: Phantom.ACTS.Router, + agent_id: "acts", + task_id: task_id, + context_id: context_id + } + + :ok = + :rpc.call(@node2, Phantom.A2A, :publish, [ref, Task.status(:completed, "Done on node2")]) + + assert %{ + "result" => %{ + "statusUpdate" => %{ + "taskId" => ^task_id, + "contextId" => ^context_id, + "status" => %{"state" => "TASK_STATE_COMPLETED"} + } + } + } = + poll_for_sse_event( + stream, + @timeout, + &match?(%{"result" => %{"statusUpdate" => _}}, &1) + ) + end +end diff --git a/test/phantom/a2a/dynamic_test.exs b/test/phantom/a2a/dynamic_test.exs new file mode 100644 index 0000000..75c6ddd --- /dev/null +++ b/test/phantom/a2a/dynamic_test.exs @@ -0,0 +1,127 @@ +defmodule Phantom.A2A.DynamicTest.Static do + use Phantom.A2A.Agent, name: "Static", description: "Declared at compile time" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("static"), ctx} +end + +defmodule Phantom.A2A.DynamicTest.Hosted do + use Phantom.A2A.Agent, name: "Hosted", description: "Agents stored per organization" + + alias Phantom.A2A.{AgentSkill, Card, Message} + + @agents %{"triage" => "Device triage", "bench" => "Benchmarking"} + + def resolve(%{"org" => org, "agent_key" => key}, session) do + case @agents do + %{^key => name} when org == session.assigns.org -> + card = %Card{ + name: "#{org} #{name}", + description: "#{name} for #{org}", + default_input_modes: ["text/plain"], + default_output_modes: ["text/plain"], + skills: [AgentSkill.build(key, description: name)] + } + + {:ok, card, %{key: key}} + + _ -> + {:error, :not_found} + end + end + + def handle_message(_message, ctx) do + text = + "#{ctx.agent.id} #{inspect(ctx.agent.params)} #{inspect(ctx.agent.state)} #{ctx.task_ref.agent_id}" + + {:reply, Message.agent(text), ctx} + end +end + +defmodule Phantom.A2A.DynamicTest.Router do + use Phantom.Router, name: "Dynamic", vsn: "1.0", a2a: [well_known: "static"] + + agent "static", Phantom.A2A.DynamicTest.Static + agent ":org/:agent_key", Phantom.A2A.DynamicTest.Hosted, :resolve + # Declared after the dynamic route, but static routes match first. + agent "acme/static", Phantom.A2A.DynamicTest.Static + + def connect(session, conn) do + case Plug.Conn.get_req_header(conn, "x-org") do + [org] -> {:ok, session |> Phantom.Session.assign(org: org) |> allow(conn)} + [] -> {:unauthorized, %{method: "Bearer"}} + end + end + + defp allow(session, conn) do + case Plug.Conn.get_req_header(conn, "x-allow") do + [] -> session + [allowed] -> Phantom.Session.allow_agents(session, String.split(allowed, ",")) + end + end +end + +defmodule Phantom.A2A.DynamicTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.DynamicTest.Router + + defp as(org, extra \\ []), do: [headers: [{"x-org", org} | extra]] + + describe "agent cards" do + test "are resolved per request" do + assert %{ + "name" => "acme Device triage", + "skills" => [%{"id" => "triage"}], + "supportedInterfaces" => [%{"url" => "http://www.example.com/acme/triage"} | _] + } = get_card(Router, "acme/triage", as("acme")) + end + + test "need the session the resolver checks" do + assert %{"error" => %{"message" => "Unauthorized"}} = get_card(Router, "acme/triage") + + assert %{"error" => %{"message" => "Not found"}} = + get_card(Router, "acme/triage", as("globex")) + + assert %{"error" => %{"message" => "Not found"}} = get_card(Router, "acme/nope", as("acme")) + end + + test "static routes match first and stay public" do + assert %{"name" => "Static"} = get_card(Router, "acme/static") + assert %{"name" => "Static"} = get_card(Router, "static") + end + end + + test "handlers get the agent's path, params, and state" do + assert {:message, %{"parts" => [%{"text" => text}]}} = + send_message(Router, "acme/bench", "hi", as("acme")) + + assert text == + ~s(acme/bench %{"agent_key" => "bench", "org" => "acme"} %{key: "bench"} acme/bench) + end + + test "an unresolved agent is not found" do + assert {:error, %{"message" => "Not found"}} = + send_message(Router, "acme/bench", "hi", as("globex")) + end + + test "allow_agents/2 takes the route's path or the agent's" do + for allowed <- [":org/:agent_key", "acme/bench"] do + assert {:message, _} = + send_message(Router, "acme/bench", "hi", as("acme", [{"x-allow", allowed}])) + end + + assert {:error, %{"message" => "Not found"}} = + send_message(Router, "acme/bench", "hi", as("acme", [{"x-allow", "acme/triage"}])) + end + + test "routes with params need a resolver" do + assert_raise ArgumentError, ~r/needs a resolver/, fn -> + Phantom.Router.agent_route(":org/agent", __MODULE__, []) + end + + assert %{params?: true, resolver: :resolve} = + Phantom.Router.agent_route(":org/agent", __MODULE__, :resolve) + end +end diff --git a/test/phantom/a2a/events_test.exs b/test/phantom/a2a/events_test.exs new file mode 100644 index 0000000..56b0ed2 --- /dev/null +++ b/test/phantom/a2a/events_test.exs @@ -0,0 +1,79 @@ +defmodule Phantom.A2A.EventsTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.{ + Artifact, + ArtifactUpdate, + Events, + Message, + Part, + StatusUpdate, + Task, + TaskStatus + } + + setup do + task = %Task{id: "t1", context_id: "c", status: %TaskStatus{state: :submitted}} + {:ok, task: task} + end + + test "a status update replaces the status", %{task: task} do + assert {:ok, %Task{status: %TaskStatus{state: :working}}, %StatusUpdate{}} = + Events.apply(task, Task.status(:working)) + end + + test "a task replaces the whole task", %{task: task} do + replacement = %{task | status: %TaskStatus{state: :completed}, metadata: %{"a" => 1}} + + assert {:ok, + %Task{status: %TaskStatus{state: :completed}, metadata: %{"a" => 1}, sequence: 1}, + %Task{sequence: 1}} = Events.apply(task, replacement) + end + + test "an artifact update adds, replaces, or appends", %{task: task} do + {:ok, task, _} = Events.apply(task, Artifact.update("a", [Part.text("one")])) + {:ok, task, _} = Events.apply(task, Artifact.update("b", [Part.text("x")])) + assert [%Artifact{artifact_id: "a"}, %Artifact{artifact_id: "b"}] = task.artifacts + + {:ok, task, _} = Events.apply(task, Artifact.update("a", [Part.text("two")], append: true)) + assert [%Artifact{parts: [%Part{text: "one"}, %Part{text: "two"}]}, _] = task.artifacts + + {:ok, task, _} = Events.apply(task, Artifact.update("a", [Part.text("new")])) + assert [%Artifact{parts: [%Part{text: "new"}]}, _] = task.artifacts + end + + test "appending to an unknown artifact is an invalid agent response", %{task: task} do + assert {:error, :unknown_artifact} = + Events.apply(task, Artifact.update("nope", [Part.text("x")], append: true)) + end + + test "a message leaves the task unchanged", %{task: task} do + message = Message.agent("hi") + assert {:ok, ^task, ^message} = Events.apply(task, message) + end + + test "numbers each event after the task's last one", %{task: task} do + {:ok, task, %StatusUpdate{sequence: 1}} = Events.apply(task, Task.status(:working)) + + {:ok, task, %ArtifactUpdate{sequence: 2}} = + Events.apply(task, Artifact.update("a", [Part.text("x")])) + + assert task.sequence == 2 + end + + test "keeps the number of an event that has one", %{task: task} do + event = %{Task.status(:working) | sequence: 7} + assert {:ok, %Task{sequence: 7}, %StatusUpdate{sequence: 7}} = Events.apply(task, event) + end + + test "terminal and interrupted states" do + for state <- [:completed, :failed, :canceled, :rejected], do: assert(Events.terminal?(state)) + + for state <- [:submitted, :working, :input_required, :auth_required], + do: refute(Events.terminal?(state)) + + assert Events.interrupted?(:input_required) + assert Events.interrupted?(:auth_required) + refute Events.interrupted?(:working) + end +end diff --git a/test/phantom/a2a/extended_card_test.exs b/test/phantom/a2a/extended_card_test.exs new file mode 100644 index 0000000..4e1b677 --- /dev/null +++ b/test/phantom/a2a/extended_card_test.exs @@ -0,0 +1,104 @@ +defmodule Phantom.A2A.ExtendedCardTest.Agent do + use Phantom.A2A.Agent, + name: "Extended", + description: "Shows more to members", + extended_card: true + + alias Phantom.A2A.AgentSkill + + @description "Anyone can ask" + skill(:public) + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("ok"), ctx} + + def extended_card(_card, %{assigns: %{role: :guest}}), + do: {:error, Phantom.A2A.Error.invalid_request("Members only")} + + def extended_card(card, ctx) do + skill = AgentSkill.build(:admin, description: "Only for #{ctx.assigns.role}s") + {:ok, %{card | skills: card.skills ++ [skill]}} + end +end + +defmodule Phantom.A2A.ExtendedCardTest.Default do + use Phantom.A2A.Agent, name: "Default", description: "Same card", extended_card: true + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("ok"), ctx} +end + +defmodule Phantom.A2A.ExtendedCardTest.Router do + use Phantom.Router, name: "Extended", vsn: "1.0" + + agent "extended", Phantom.A2A.ExtendedCardTest.Agent + agent "default", Phantom.A2A.ExtendedCardTest.Default + + def connect(session, conn) do + role = conn |> Plug.Conn.get_req_header("x-role") |> List.first("member") + {:ok, Phantom.Session.assign(session, role: String.to_existing_atom(role))} + end +end + +defmodule Phantom.A2A.ExtendedCardTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.ExtendedCardTest.Router + + defp extended(agent, opts \\ []) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => "GetExtendedAgentCard", "params" => %{}} + headers = Keyword.get(opts, :headers, []) + + :post + |> Plug.Test.conn("/" <> agent, JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> then( + &Enum.reduce(headers, &1, fn {k, v}, conn -> Plug.Conn.put_req_header(conn, k, v) end) + ) + |> Phantom.A2A.Plug.call( + Phantom.A2A.Plug.init([router: Router] ++ Keyword.get(opts, :plug, [])) + ) + |> Map.fetch!(:resp_body) + |> JSON.decode!() + end + + test "the public card advertises it" do + assert %{"capabilities" => %{"extendedAgentCard" => true}} = get_card(Router, "extended") + end + + test "returns the card the agent builds for the session" do + assert %{ + "result" => %{ + "name" => "Extended", + "skills" => [ + %{"id" => "public"}, + %{"id" => "admin", "description" => "Only for members"} + ], + "supportedInterfaces" => [%{"url" => "http://www.example.com/extended"} | _], + "capabilities" => %{"extendedAgentCard" => true} + } + } = extended("extended") + end + + test "the agent can refuse it" do + assert %{"error" => %{"message" => "Members only"}} = + extended("extended", headers: [{"x-role", "guest"}]) + end + + test "defaults to the public card" do + assert %{"result" => %{"name" => "Default"}} = extended("default") + end + + test "a mount can turn it off" do + assert %{"error" => %{"code" => -32004}} = + extended("extended", plug: [extended_card: false]) + + assert %{"capabilities" => %{"extendedAgentCard" => false}} = + :get + |> Plug.Test.conn("/extended/agent-card.json") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router, extended_card: false)) + |> Map.fetch!(:resp_body) + |> JSON.decode!() + end +end diff --git a/test/phantom/a2a/extensions_test.exs b/test/phantom/a2a/extensions_test.exs new file mode 100644 index 0000000..fe86aa5 --- /dev/null +++ b/test/phantom/a2a/extensions_test.exs @@ -0,0 +1,84 @@ +defmodule Phantom.A2A.ExtensionsTest.Agent do + use Phantom.A2A.Agent, + name: "Extensions", + description: "Uses extensions", + extensions: [ + %{ + uri: "https://example.com/ext/required/v1", + description: "Must be understood", + required: true + }, + %{uri: "https://example.com/ext/optional/v1", params: %{"level" => 2}} + ] + + def handle_message(_message, ctx), + do: {:reply, Phantom.A2A.Message.agent(Enum.join(ctx.extensions, ",")), ctx} +end + +defmodule Phantom.A2A.ExtensionsTest.Router do + use Phantom.Router, name: "Extensions", vsn: "1.0" + + agent "ext", Phantom.A2A.ExtensionsTest.Agent +end + +defmodule Phantom.A2A.ExtensionsTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.ExtensionsTest.Router + + @required "https://example.com/ext/required/v1" + @optional "https://example.com/ext/optional/v1" + + defp send_ext(headers) do + body = %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "SendMessage", + "params" => %{ + "message" => %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => "hi"}]} + } + } + + conn = + :post + |> Plug.Test.conn("/ext", JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> then( + &Enum.reduce(headers, &1, fn {k, v}, conn -> Plug.Conn.put_req_header(conn, k, v) end) + ) + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router)) + + {conn, JSON.decode!(conn.resp_body)} + end + + test "the card lists the agent's extensions" do + assert %{ + "capabilities" => %{ + "extensions" => [ + %{"uri" => @required, "description" => "Must be understood", "required" => true}, + %{"uri" => @optional, "params" => %{"level" => 2}} = optional + ] + } + } = get_card(Router, "ext") + + refute Map.has_key?(optional, "required") + end + + test "a request must name the required extensions" do + {_conn, body} = send_ext([]) + assert %{"error" => %{"code" => -32008, "message" => message}} = body + assert message =~ @required + end + + test "the handler sees the requested extensions, and the response names the supported ones" do + {conn, body} = + send_ext([{"a2a-extensions", "#{@required}, https://example.com/unknown , #{@optional}"}]) + + assert %{"result" => %{"message" => %{"parts" => [%{"text" => text}]}}} = body + assert text == "#{@required},https://example.com/unknown,#{@optional}" + assert Plug.Conn.get_resp_header(conn, "a2a-extensions") == ["#{@required}, #{@optional}"] + end +end diff --git a/test/phantom/a2a/list_agents_test.exs b/test/phantom/a2a/list_agents_test.exs new file mode 100644 index 0000000..773d5f4 --- /dev/null +++ b/test/phantom/a2a/list_agents_test.exs @@ -0,0 +1,51 @@ +defmodule Phantom.A2A.ListAgentsTest.Agent do + use Phantom.A2A.Agent, name: "Listed", description: "Listed agent" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("ok"), ctx} + def resolve(_params, _session), do: {:error, :not_found} +end + +defmodule Phantom.A2A.ListAgentsTest.DefaultRouter do + use Phantom.Router, name: "Default" + + agent "research", Phantom.A2A.ListAgentsTest.Agent + agent "teams/billing", Phantom.A2A.ListAgentsTest.Agent + agent ":org/:key", Phantom.A2A.ListAgentsTest.Agent, :resolve +end + +defmodule Phantom.A2A.ListAgentsTest.CustomRouter do + use Phantom.Router, name: "Custom" + + agent "research", Phantom.A2A.ListAgentsTest.Agent + agent ":org/:key", Phantom.A2A.ListAgentsTest.Agent, :resolve + + def list_agents(nil, session), + do: {:ok, ["research", "#{session.assigns.org}/triage"], "page-2"} + + def list_agents("page-2", session), do: {:ok, ["#{session.assigns.org}/bench"], nil} +end + +defmodule Phantom.A2A.ListAgentsTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.ListAgentsTest.{CustomRouter, DefaultRouter} + + test "lists the agent routes without path params by default" do + assert {:ok, ["research", "teams/billing"], nil} = list_agents(DefaultRouter) + end + + test "the default respects allow_agents/2" do + assert {:ok, ["teams/billing"], nil} = + list_agents(DefaultRouter, allowed_agents: ["teams/billing"]) + end + + test "a router lists the agents it resolves, a page at a time" do + assert {:ok, ["research", "acme/triage"], "page-2"} = + list_agents(CustomRouter, assigns: %{org: "acme"}) + + assert {:ok, ["acme/bench"], nil} = + list_agents(CustomRouter, assigns: %{org: "acme"}, cursor: "page-2") + end +end diff --git a/test/phantom/a2a/plug_test.exs b/test/phantom/a2a/plug_test.exs new file mode 100644 index 0000000..b5c2ca7 --- /dev/null +++ b/test/phantom/a2a/plug_test.exs @@ -0,0 +1,1028 @@ +defmodule Phantom.A2A.PlugTest.Research do + use Phantom.A2A.Agent, + name: "Research", + description: "Designs and reviews research studies", + input_modes: ~w[text/plain application/json], + output_modes: ~w[text/markdown] + + alias Phantom.A2A.{Artifact, Message, Part, Task, TaskStatus} + + @description "Review a study and suggest missing questions" + skill :study_review, tags: ~w[research], examples: ["Review study 42"] + + skill :quick_answer, name: "Quick answer", description: "Answer a short question", tags: [] + + def handle_message(%Message{task_id: nil, parts: [%Part{text: "ping"}]}, ctx), + do: {:reply, Message.agent("pong"), ctx} + + def handle_message(%Message{parts: [%Part{text: "agent"}]}, ctx), + do: {:reply, Message.agent("#{ctx.agent.id} #{inspect(ctx.agent.opts)}"), ctx} + + def handle_message(%Message{parts: [%Part{text: "tenant"}]}, ctx), + do: {:reply, Message.agent("#{ctx.assigns.tenant}/#{ctx.tenant}"), ctx} + + def handle_message(%Message{parts: [%Part{text: "done"}]}, ctx), + do: {:reply, Task.submitted(ctx, status: %TaskStatus{state: :completed}), ctx} + + def handle_message(%Message{parts: [%Part{text: "explode"}]}, _ctx), do: raise("boom") + def handle_message(%Message{parts: [%Part{text: "invalid"}]}, _ctx), do: :nope + + def handle_message(%Message{parts: [%Part{text: "refuse"}]}, ctx), + do: {:error, Phantom.A2A.Error.unsupported_operation("Not today"), ctx} + + def handle_message(%Message{parts: [%Part{text: "work"}]}, ctx) do + ref = ctx.task_ref + + spawn(fn -> + Phantom.A2A.publish(ref, Task.status(:working, "Reading study…")) + Phantom.A2A.publish(ref, Artifact.update("review", [Part.text("## Gaps")])) + Phantom.A2A.publish(ref, Artifact.update("review", [Part.text("- Ages")], append: true)) + Phantom.A2A.publish(ref, Task.status(:input_required, "Include demographics?")) + end) + + {:reply, Task.submitted(ctx), ctx} + end + + # Answers with a task whose ID another system chose. + def handle_message(%Message{parts: [%Part{text: "external"}]}, ctx) do + id = "external-#{System.unique_integer([:positive])}" + ref = %{ctx.task_ref | task_id: id} + # Saved before it is published, as an agent's store would. + spawn(fn -> + :persistent_term.put({__MODULE__, id}, :completed) + Phantom.A2A.publish(ref, Task.status(:completed, "Done elsewhere")) + end) + + {:reply, %Task{id: id, context_id: ctx.context_id, status: %TaskStatus{state: :working}}, ctx} + end + + def handle_message(%Message{task_id: task_id} = message, ctx) when is_binary(task_id) do + send(ctx.assigns.test, {:continued, ctx.task_ref.task_id, ctx.context_id}) + {:ok, task} = get_task(task_id, ctx) + + {:reply, %{task | status: %TaskStatus{state: :completed}, history: task.history ++ [message]}, + ctx} + end + + def get_task("missing", _ctx), do: {:error, :not_found} + + def get_task("external-" <> _ = id, ctx) do + state = :persistent_term.get({__MODULE__, id}, :working) + {:ok, %Task{id: id, context_id: ctx.context_id || "ctx-1", status: %TaskStatus{state: state}}} + end + + # Works until the test publishes its completion. + def get_task("idle", %{task_ref: %Phantom.A2A.TaskRef{} = ref} = ctx) do + send(ctx.assigns.test, {:fetched, ref}) + {:ok, %Task{id: "idle", context_id: "ctx-1", status: %TaskStatus{state: :working}}} + end + + # Event 2 is already in the snapshot when it is published, as when it is + # saved between the subscriber listening and fetching the task. + def get_task("raced", %{task_ref: %Phantom.A2A.TaskRef{} = ref}) do + spawn(fn -> + Phantom.A2A.publish(ref, %{Artifact.update("out", [Part.text("a")]) | sequence: 2}) + Phantom.A2A.publish(ref, %{Task.status(:completed) | sequence: 3}) + end) + + {:ok, + %Task{ + id: "raced", + context_id: "ctx-1", + status: %TaskStatus{state: :working}, + artifacts: [Artifact.new("out", [Part.text("a")])], + sequence: 2 + }} + end + + # A subscriber is listening by the time Phantom fetches the task. + def get_task("live", %{task_ref: %Phantom.A2A.TaskRef{} = ref}) do + spawn(fn -> + Phantom.A2A.publish(ref, Artifact.update("out", [Part.text("streamed")])) + Phantom.A2A.publish(ref, Task.status(:completed)) + end) + + {:ok, %Task{id: "live", context_id: "ctx-1", status: %TaskStatus{state: :working}}} + end + + def get_task(id, _ctx) do + state = + case id do + "finished" -> :completed + "was-canceled" -> :canceled + "stuck" -> :working + _ -> :input_required + end + + history = + for i <- 1..3, do: %Message{message_id: "m#{i}", role: :user, parts: [Part.text("#{i}")]} + + {:ok, %Task{id: id, context_id: "ctx-1", status: %TaskStatus{state: state}, history: history}} + end + + def cancel_task(%Task{id: "stuck"}, _ctx), do: {:error, :not_cancelable} + def cancel_task(task, _ctx), do: {:ok, %{task | status: %TaskStatus{state: :canceled}}} + + def list_tasks(filter, ctx) do + send(ctx.assigns.test, {:list_filter, filter}) + + tasks = + for id <- ["a", "b"] do + %Task{ + id: id, + context_id: "ctx-1", + status: %TaskStatus{state: :completed}, + artifacts: [Artifact.new("out", [Part.text(id)])], + history: [Message.user("one"), Message.user("two")] + } + end + + {:ok, + %{ + tasks: tasks, + next_page_token: if(filter.page_token, do: nil, else: "page-2"), + total_size: 3 + }} + end +end + +defmodule Phantom.A2A.PlugTest.Minimal do + use Phantom.A2A.Agent, name: "Minimal", description: "Does little" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Message.agent("ok"), ctx} +end + +defmodule Phantom.A2A.PlugTest.Router do + use Phantom.Router, + name: "A2A test", + vsn: "2.0", + a2a: [ + well_known: "research", + provider: %{"organization" => "Phantom", "url" => "https://phantom.example"}, + security_schemes: %{"bearer" => %{"httpAuthSecurityScheme" => %{"scheme" => "Bearer"}}}, + security_requirements: [%{"schemes" => %{"bearer" => %{"list" => []}}}] + ] + + agent "research", Phantom.A2A.PlugTest.Research + agent "research-es", Phantom.A2A.PlugTest.Research, locale: "es" + agent "teams/minimal", Phantom.A2A.PlugTest.Minimal + + def connect(_session, %Plug.Conn{body_params: %{"params" => %{"tenant" => "other-org"}}}), + do: {:forbidden, "Not a member of other-org"} + + def connect(session, %Plug.Conn{} = conn) do + session = Phantom.Session.assign(session, tenant: tenant(conn)) + + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer limited"] -> + {:ok, session |> Phantom.Session.assign(test: test_pid(conn)) |> allow(["research"])} + + ["Bearer " <> _] -> + {:ok, Phantom.Session.assign(session, test: test_pid(conn))} + + _ -> + {:unauthorized, %{method: "Bearer", resource_metadata: "https://example.com/meta"}} + end + end + + defp tenant(%Plug.Conn{body_params: %{"params" => %{"tenant" => tenant}}}), do: tenant + defp tenant(_conn), do: nil + + defp allow(session, agents), do: Phantom.Session.allow_agents(session, agents) + + defp test_pid(conn) do + case Plug.Conn.get_req_header(conn, "x-test-pid") do + [pid] -> pid |> Base.decode64!() |> :erlang.binary_to_term() + [] -> nil + end + end +end + +defmodule Phantom.A2A.PlugTest.ConfiguredRouter do + use Phantom.Router, name: "Configured" + + agent "research", Phantom.A2A.PlugTest.Research +end + +defmodule Phantom.A2A.PlugTest.HTTP do + use Plug.Router + + plug :match + + plug Plug.Parsers, + parsers: [{:json, length: 1_000_000}], + pass: ["application/json"], + json_decoder: JSON + + plug :dispatch + + get("/.well-known/agent-card.json", + to: Phantom.A2A.Plug, + init_opts: [router: Phantom.A2A.PlugTest.Router, mount: "/a2a"] + ) + + forward "/a2a", + to: Phantom.A2A.Plug, + init_opts: [router: Phantom.A2A.PlugTest.Router, validate_origin: false] + + forward "/configured/a2a", + to: Phantom.A2A.Plug, + init_opts: [router: Phantom.A2A.PlugTest.ConfiguredRouter] + + forward "/keepalive/a2a", + to: Phantom.A2A.Plug, + init_opts: [ + router: Phantom.A2A.PlugTest.Router, + pubsub: Phantom.A2A.PlugTest.PubSub, + keepalive: 10 + ] + + forward "/pubsub/a2a", + to: Phantom.A2A.Plug, + init_opts: [ + router: Phantom.A2A.PlugTest.Router, + pubsub: Phantom.A2A.PlugTest.PubSub, + blocking_timeout: 1_000 + ] +end + +defmodule Phantom.A2A.PlugTest do + use ExUnit.Case, async: true + + import Plug.Test + import Plug.Conn + + alias Phantom.A2A.PlugTest.HTTP + + setup_all do + start_supervised!({Phoenix.PubSub, name: Phantom.A2A.PlugTest.PubSub}) + :ok + end + + defp get_json(path) do + conn = :get |> conn(path) |> HTTP.call(HTTP.init([])) + {conn.status, JSON.decode!(conn.resp_body)} + end + + defp rpc(path, method, params, opts \\ []) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + conn = + :post + |> conn(path, JSON.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("x-test-pid", self() |> :erlang.term_to_binary() |> Base.encode64()) + + conn = + Enum.reduce( + Keyword.get(opts, :headers, [ + {"a2a-version", "1.0"}, + {"authorization", "Bearer token"} + ]), + conn, + fn {k, v}, conn -> put_req_header(conn, k, v) end + ) + + conn = HTTP.call(conn, HTTP.init([])) + {conn, JSON.decode!(conn.resp_body)} + end + + # Sends a request answered with SSE, and returns its events' ids and data. + defp stream(path, method, params) do + body = %{"jsonrpc" => "2.0", "id" => 7, "method" => method, "params" => params} + + conn = + :post + |> conn(path, JSON.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("accept", "text/event-stream") + |> put_req_header("a2a-version", "1.0") + |> put_req_header("authorization", "Bearer token") + |> put_req_header("x-test-pid", self() |> :erlang.term_to_binary() |> Base.encode64()) + |> HTTP.call(HTTP.init([])) + + events = + for event <- String.split(conn.resp_body, "\n\n", trim: true), + not String.starts_with?(event, ":") do + fields = + for line <- String.split(event, "\n"), into: %{} do + [key, value] = String.split(line, ": ", parts: 2) + {key, value} + end + + {fields["id"], JSON.decode!(fields["data"])} + end + + {conn, events} + end + + defp send_message(path, text, extra \\ %{}) do + message = + Map.merge( + %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]}, + extra + ) + + {_conn, body} = rpc(path, "SendMessage", %{"message" => message}) + body + end + + describe "agent card" do + test "serves each agent's card under the mount" do + assert {200, card} = get_json("/a2a/research/agent-card.json") + + assert %{ + "name" => "Research", + "description" => "Designs and reviews research studies", + "version" => "2.0", + "defaultInputModes" => ["text/plain", "application/json"], + "defaultOutputModes" => ["text/markdown"], + "provider" => %{"organization" => "Phantom"}, + "securitySchemes" => %{"bearer" => _}, + "securityRequirements" => [_], + "capabilities" => + %{ + "streaming" => false, + "pushNotifications" => false, + "extendedAgentCard" => false + } = capabilities, + "supportedInterfaces" => [ + %{ + "url" => "http://www.example.com/a2a/research", + "protocolBinding" => "JSONRPC", + "protocolVersion" => "1.0" + }, + %{ + "url" => "http://www.example.com/a2a/research", + "protocolBinding" => "HTTP+JSON", + "protocolVersion" => "1.0" + } + ], + "skills" => [ + %{ + "id" => "study_review", + "name" => "Study review", + "description" => "Review a study and suggest missing questions", + "tags" => ["research"], + "examples" => ["Review study 42"] + }, + %{"id" => "quick_answer", "name" => "Quick answer", "tags" => ["quick_answer"]} + ] + } = card + + refute Map.has_key?(capabilities, "extensions") + end + + test "serves nested agent paths" do + assert {200, + %{ + "name" => "Minimal", + "skills" => [%{"id" => "default", "name" => "Minimal", "tags" => ["default"]}], + "supportedInterfaces" => [interface | _] + }} = + get_json("/a2a/teams/minimal/agent-card.json") + + assert interface["url"] == "http://www.example.com/a2a/teams/minimal" + end + + test "serves the well-known agent with the mount's URL" do + assert {200, %{"name" => "Research", "supportedInterfaces" => [%{"url" => url} | _]}} = + get_json("/.well-known/agent-card.json") + + assert url == "http://www.example.com/a2a/research" + end + + test "advertises streaming only with a pubsub" do + assert {200, %{"capabilities" => %{"streaming" => true}}} = + get_json("/pubsub/a2a/research/agent-card.json") + + assert {200, %{"capabilities" => %{"streaming" => false}}} = + get_json("/a2a/research/agent-card.json") + end + + test "takes options from the router's config" do + Application.put_env( + Phantom.A2A.PlugTest.ConfiguredRouter, + :pubsub, + Phantom.A2A.PlugTest.PubSub + ) + + on_exit(fn -> Application.delete_env(Phantom.A2A.PlugTest.ConfiguredRouter, :pubsub) end) + + assert {200, %{"capabilities" => %{"streaming" => true}}} = + get_json("/configured/a2a/research/agent-card.json") + end + + test "can be cached" do + conn = :get |> conn("/a2a/research/agent-card.json") |> HTTP.call(HTTP.init([])) + assert ["public, max-age=" <> _] = get_resp_header(conn, "cache-control") + assert [etag] = get_resp_header(conn, "etag") + + conn = + :get + |> conn("/a2a/research/agent-card.json") + |> put_req_header("if-none-match", etag) + |> HTTP.call(HTTP.init([])) + + assert conn.status == 304 + assert conn.resp_body == "" + end + + test "unknown agents are not found" do + assert {404, _} = get_json("/a2a/nope/agent-card.json") + end + end + + describe "JSON-RPC" do + test "requires A2A-Version 1.0" do + {conn, body} = + rpc("/a2a/research", "GetTask", %{"id" => "t1"}, + headers: [{"authorization", "Bearer token"}] + ) + + assert conn.status == 200 + + assert %{ + "id" => 1, + "error" => %{ + "code" => -32009, + "data" => [%{"@type" => "type.googleapis.com/google.rpc.ErrorInfo"} = info] + } + } = body + + assert %{"reason" => "VERSION_NOT_SUPPORTED", "domain" => "a2a-protocol.org"} = info + + {_conn, body} = + rpc("/a2a/research", "GetTask", %{"id" => "t1"}, + headers: [{"authorization", "Bearer token"}, {"a2a-version", "0.3"}] + ) + + assert %{"error" => %{"code" => -32009}} = body + + {_conn, body} = + rpc("/a2a/research", "GetTask", %{"id" => "t1"}, + headers: [{"authorization", "Bearer token"}, {"a2a-version", "1.2"}] + ) + + assert %{"error" => %{"code" => -32009}} = body + end + + test "unknown methods and agents" do + assert {_, %{"error" => %{"code" => -32601}}} = rpc("/a2a/research", "message/send", %{}) + assert {conn, _} = rpc("/a2a/nope", "GetTask", %{"id" => "t1"}) + assert conn.status == 404 + end + + test "rejects a JSON-RPC envelope that isn't a request" do + conn = + :post + |> conn("/a2a/research", JSON.encode!([%{"jsonrpc" => "2.0"}])) + |> put_req_header("content-type", "application/json") + |> put_req_header("a2a-version", "1.0") + |> HTTP.call(HTTP.init([])) + + assert %{"error" => %{"code" => -32600}} = JSON.decode!(conn.resp_body) + end + + test "connect/2 can reject the request" do + {conn, body} = + rpc("/a2a/research", "GetTask", %{"id" => "t1"}, headers: [{"a2a-version", "1.0"}]) + + assert conn.status == 401 + assert [www_authenticate] = get_resp_header(conn, "www-authenticate") + assert www_authenticate =~ "Bearer" + assert %{"error" => _} = body + end + + test "allow_agents/2 hides other agents" do + headers = [{"a2a-version", "1.0"}, {"authorization", "Bearer limited"}] + + assert {%{status: 200}, _} = + rpc("/a2a/research", "GetTask", %{"id" => "t1"}, headers: headers) + + assert {%{status: 404}, _} = + rpc("/a2a/teams/minimal", "GetTask", %{"id" => "t1"}, headers: headers) + end + end + + describe "SendMessage" do + test "connect/2 sees the tenant of a parsed body" do + assert %{"result" => %{"message" => %{"parts" => [%{"text" => "acme/acme"}]}}} = + rpc("/a2a/research", "SendMessage", %{ + "tenant" => "acme", + "message" => %{ + "messageId" => "m1", + "role" => "ROLE_USER", + "parts" => [%{"text" => "tenant"}] + } + }) + |> elem(1) + + assert {%{status: 403}, _} = + rpc("/a2a/research", "GetTask", %{"id" => "t1", "tenant" => "other-org"}) + end + + test "answers with a message in a new context" do + assert %{ + "result" => %{ + "message" => %{"role" => "ROLE_AGENT", "parts" => [%{"text" => "pong"}]} = msg + } + } = send_message("/a2a/research", "ping") + + assert is_binary(msg["contextId"]) + refute Map.has_key?(msg, "taskId") + end + + test "keeps the client's context and passes the route's options" do + assert %{ + "result" => %{ + "message" => %{"contextId" => "ctx-9", "parts" => [%{"text" => text}]} + } + } = + send_message("/a2a/research-es", "agent", %{"contextId" => "ctx-9"}) + + assert text == ~s(research-es [locale: "es"]) + end + + test "answers with a finished task" do + assert %{ + "result" => %{ + "task" => %{ + "id" => id, + "status" => %{"state" => "TASK_STATE_COMPLETED"}, + "history" => [%{"messageId" => "m1"}] + } + } + } = send_message("/a2a/research", "done") + + assert is_binary(id) + end + + test "without a pubsub, polls the agent's store until the task needs input" do + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_INPUT_REQUIRED"}}}} = + send_message("/a2a/research", "work") + end + + test "with a pubsub, waits until the task needs input" do + assert %{ + "result" => %{ + "task" => %{ + "status" => %{ + "state" => "TASK_STATE_INPUT_REQUIRED", + "message" => %{"parts" => [%{"text" => "Include demographics?"}]} + }, + "artifacts" => [ + %{ + "artifactId" => "review", + "parts" => [%{"text" => "## Gaps"}, %{"text" => "- Ages"}] + } + ] + } + } + } = send_message("/pubsub/a2a/research", "work") + end + + test "follows a task whose ID the agent chose" do + assert %{ + "result" => %{ + "task" => %{ + "id" => "external-" <> _, + "status" => %{"state" => "TASK_STATE_COMPLETED"} + } + } + } = send_message("/pubsub/a2a/research", "external") + end + + test "returnImmediately answers with the submitted task" do + message = %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => "work"}]} + + {_conn, body} = + rpc("/pubsub/a2a/research", "SendMessage", %{ + "message" => message, + "configuration" => %{"returnImmediately" => true} + }) + + assert %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_SUBMITTED"}}}} = + body + end + + test "continues an existing task" do + assert %{ + "result" => %{ + "task" => %{"id" => "t-7", "status" => %{"state" => "TASK_STATE_COMPLETED"}} + } + } = + send_message("/a2a/research", "yes", %{"taskId" => "t-7"}) + + assert_received {:continued, "t-7", "ctx-1"} + end + + test "rejects continuing a task that is missing, finished, or in another context" do + assert %{"error" => %{"code" => -32001}} = + send_message("/a2a/research", "yes", %{"taskId" => "missing"}) + + assert %{"error" => %{"code" => -32004}} = + send_message("/a2a/research", "yes", %{"taskId" => "finished"}) + + assert %{"error" => %{"code" => -32602}} = + send_message("/a2a/research", "yes", %{"taskId" => "t-7", "contextId" => "other"}) + + assert %{"error" => %{"code" => -32001}} = + send_message("/a2a/teams/minimal", "yes", %{"taskId" => "t-7"}) + end + + test "rejects invalid messages and unsupported media types" do + {_conn, body} = + rpc("/a2a/research", "SendMessage", %{"message" => %{"role" => "ROLE_USER"}}) + + assert %{"error" => %{"code" => -32602}} = body + + assert %{"error" => %{"code" => -32005}} = + send_message("/a2a/research", "ping", %{ + "parts" => [%{"raw" => "AA==", "mediaType" => "image/png"}] + }) + end + + test "handler errors" do + assert %{"error" => %{"code" => -32004, "message" => "Not today"}} = + send_message("/a2a/research", "refuse") + + assert %{"error" => %{"code" => -32006}} = send_message("/a2a/research", "invalid") + end + + test "a raise answers with an internal error, then reraises" do + assert_raise Plug.Conn.WrapperError, ~r/boom/, fn -> + send_message("/a2a/research", "explode") + end + + assert_received {_ref, {200, _headers, body}} + assert %{"error" => %{"code" => -32603, "message" => "Internal error"}} = JSON.decode!(body) + end + + test "streaming needs a pubsub" do + message = %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => "ping"}]} + + assert {_, %{"error" => %{"code" => -32004}}} = + rpc("/a2a/research", "SendStreamingMessage", %{"message" => message}) + end + end + + describe "unparsed bodies" do + defp raw(body) do + :post + |> conn("/research", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("a2a-version", "1.0") + |> put_req_header("authorization", "Bearer token") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Phantom.A2A.PlugTest.Router)) + end + + test "are read and decoded" do + body = + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "GetTask", + "params" => %{"id" => "t1"} + }) + + assert %{"result" => %{"id" => "t1"}} = JSON.decode!(raw(body).resp_body) + end + + test "connect/2 sees the body, such as the tenant" do + message = %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => "tenant"}]} + + body = fn tenant -> + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "SendMessage", + "params" => %{"tenant" => tenant, "message" => message} + }) + end + + assert %{"result" => %{"message" => %{"parts" => [%{"text" => "acme/acme"}]}}} = + JSON.decode!(raw(body.("acme")).resp_body) + + conn = raw(body.("other-org")) + assert conn.status == 403 + + assert %{"error" => %{"message" => "Not a member of other-org"}} = + JSON.decode!(conn.resp_body) + end + + test "invalid JSON is a JSON-RPC parse error" do + conn = raw("{this is not valid json") + assert conn.status == 200 + assert %{"id" => nil, "error" => %{"code" => -32700}} = JSON.decode!(conn.resp_body) + end + end + + describe "GetExtendedAgentCard" do + test "is unsupported when the card doesn't advertise it" do + assert {_, %{"error" => %{"code" => -32004}}} = + rpc("/a2a/research", "GetExtendedAgentCard", %{}) + end + end + + describe "GetTask" do + test "returns the task with trimmed history" do + {_conn, body} = rpc("/a2a/research", "GetTask", %{"id" => "t-7", "historyLength" => 1}) + + assert %{"result" => %{"id" => "t-7", "history" => [%{"messageId" => "m3"}]}} = body + end + + test "missing tasks, and agents without get_task/2" do + assert {_, %{"error" => %{"code" => -32001}}} = + rpc("/a2a/research", "GetTask", %{"id" => "missing"}) + + assert {_, %{"error" => %{"code" => -32001}}} = + rpc("/a2a/teams/minimal", "GetTask", %{"id" => "t-7"}) + + assert {_, %{"error" => %{"code" => -32602}}} = rpc("/a2a/research", "GetTask", %{}) + end + end + + describe "CancelTask" do + test "cancels through the agent" do + assert {_, %{"result" => %{"id" => "t-7", "status" => %{"state" => "TASK_STATE_CANCELED"}}}} = + rpc("/a2a/research", "CancelTask", %{"id" => "t-7"}) + + assert {_, %{"error" => %{"code" => -32002}}} = + rpc("/a2a/research", "CancelTask", %{"id" => "stuck"}) + + assert {_, %{"error" => %{"code" => -32001}}} = + rpc("/a2a/research", "CancelTask", %{"id" => "missing"}) + + assert {_, %{"error" => %{"code" => -32001}}} = + rpc("/a2a/teams/minimal", "CancelTask", %{"id" => "t-7"}) + end + + test "a finished task can't be canceled, and a canceled one is returned as is" do + assert {_, %{"error" => %{"code" => -32002}}} = + rpc("/a2a/research", "CancelTask", %{"id" => "finished"}) + + assert {_, + %{ + "result" => %{ + "id" => "was-canceled", + "status" => %{"state" => "TASK_STATE_CANCELED"} + } + }} = + rpc("/a2a/research", "CancelTask", %{"id" => "was-canceled"}) + end + end + + describe "SendStreamingMessage" do + defp user_message(text, extra \\ %{}), + do: + Map.merge( + %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]}, + extra + ) + + test "streams the task, then each event, until the task needs input" do + {conn, events} = + stream("/pubsub/a2a/research", "SendStreamingMessage", %{ + "message" => user_message("work") + }) + + assert ["text/event-stream" <> _] = get_resp_header(conn, "content-type") + assert Enum.map(events, &elem(&1, 0)) == ["1", "2", "3", "4", "5"] + assert Enum.all?(events, &match?({_, %{"jsonrpc" => "2.0", "id" => 7}}, &1)) + + assert [ + %{ + "task" => %{ + "id" => task_id, + "contextId" => context_id, + "status" => %{"state" => "TASK_STATE_SUBMITTED"} + } + }, + %{ + "statusUpdate" => %{ + "taskId" => task_id, + "contextId" => context_id, + "status" => %{"state" => "TASK_STATE_WORKING"} + } + }, + %{ + "artifactUpdate" => %{ + "taskId" => task_id, + "contextId" => context_id, + "append" => false + } + }, + %{ + "artifactUpdate" => %{ + "append" => true, + "artifact" => %{"parts" => [%{"text" => "- Ages"}]} + } + }, + %{"statusUpdate" => %{"status" => %{"state" => "TASK_STATE_INPUT_REQUIRED"}}} + ] = Enum.map(events, fn {_id, %{"result" => result}} -> result end) + end + + test "a message is the only event" do + assert {_conn, [{"1", %{"result" => %{"message" => %{"parts" => [%{"text" => "pong"}]}}}}]} = + stream("/pubsub/a2a/research", "SendStreamingMessage", %{ + "message" => user_message("ping") + }) + end + + test "a finished task is the only event" do + assert {_conn, + [ + {"1", + %{"result" => %{"task" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}}}} + ]} = + stream("/pubsub/a2a/research", "SendStreamingMessage", %{ + "message" => user_message("done") + }) + end + + test "errors before the stream starts are plain JSON-RPC errors" do + {conn, body} = + rpc("/pubsub/a2a/research", "SendStreamingMessage", %{ + "message" => %{"role" => "ROLE_USER"} + }) + + assert ["application/json" <> _] = get_resp_header(conn, "content-type") + assert %{"error" => %{"code" => -32602}} = body + + assert {_, %{"error" => %{"code" => -32004, "message" => "Not today"}}} = + rpc("/pubsub/a2a/research", "SendStreamingMessage", %{ + "message" => user_message("refuse") + }) + end + end + + describe "SubscribeToTask" do + test "streams the current task, then its events until it finishes" do + {_conn, events} = stream("/pubsub/a2a/research", "SubscribeToTask", %{"id" => "live"}) + + assert [ + %{"task" => %{"id" => "live", "status" => %{"state" => "TASK_STATE_WORKING"}}}, + %{ + "artifactUpdate" => %{ + "taskId" => "live", + "contextId" => "ctx-1", + "artifact" => %{"artifactId" => "out"} + } + }, + %{ + "statusUpdate" => %{ + "taskId" => "live", + "status" => %{"state" => "TASK_STATE_COMPLETED"} + } + } + ] = Enum.map(events, fn {_id, %{"result" => result}} -> result end) + end + + test "an idle stream sends keepalive comments" do + server = + start_supervised!( + {Bandit, plug: HTTP, ip: :loopback, port: 0, startup_log: false}, + id: :keepalive_server + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) + + body = + JSON.encode!(%{ + "jsonrpc" => "2.0", + "id" => 7, + "method" => "SubscribeToTask", + "params" => %{"id" => "idle"} + }) + + :ok = + :gen_tcp.send(socket, [ + "POST /keepalive/a2a/research HTTP/1.1\r\nhost: localhost\r\nconnection: close\r\n", + "a2a-version: 1.0\r\ncontent-type: application/json\r\naccept: text/event-stream\r\n", + "authorization: Bearer token\r\n", + "x-test-pid: #{self() |> :erlang.term_to_binary() |> Base.encode64()}\r\n", + "content-length: #{byte_size(body)}\r\n\r\n", + body + ]) + + assert_receive {:fetched, ref}, 2_000 + assert read_until(socket, ": keepalive\n\n", "") =~ ~s("task":) + + :ok = Phantom.A2A.publish(ref, Phantom.A2A.Task.status(:completed)) + assert read_all(socket, "") =~ "TASK_STATE_COMPLETED" + end + + defp read_until(socket, marker, acc) do + {:ok, data} = :gen_tcp.recv(socket, 0, 2_000) + acc = acc <> data + if acc =~ marker, do: acc, else: read_until(socket, marker, acc) + end + + defp read_all(socket, acc) do + case :gen_tcp.recv(socket, 0, 2_000) do + {:ok, data} -> read_all(socket, acc <> data) + {:error, :closed} -> acc + end + end + + test "events already in the current task aren't sent again" do + {_conn, events} = stream("/pubsub/a2a/research", "SubscribeToTask", %{"id" => "raced"}) + + assert [ + %{"task" => %{"artifacts" => [%{"parts" => [%{"text" => "a"}]}]}}, + %{"statusUpdate" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}} + ] = Enum.map(events, fn {_id, %{"result" => result}} -> result end) + end + + test "a task waiting on the client is sent, then the stream closes" do + assert {_conn, + [ + {"1", + %{ + "result" => %{ + "task" => %{"status" => %{"state" => "TASK_STATE_INPUT_REQUIRED"}} + } + }} + ]} = + stream("/pubsub/a2a/research", "SubscribeToTask", %{"id" => "t-7"}) + end + + test "finished, missing, and unsupported" do + assert {_, %{"error" => %{"code" => -32004}}} = + rpc("/pubsub/a2a/research", "SubscribeToTask", %{"id" => "finished"}) + + assert {_, %{"error" => %{"code" => -32001}}} = + rpc("/pubsub/a2a/research", "SubscribeToTask", %{"id" => "missing"}) + + assert {_, %{"error" => %{"code" => -32004}}} = + rpc("/a2a/research", "SubscribeToTask", %{"id" => "live"}) + + assert {_, %{"error" => %{"code" => -32602}}} = + rpc("/pubsub/a2a/research", "SubscribeToTask", %{}) + end + end + + describe "ListTasks" do + test "passes the filter to the agent and pages its answer" do + {_conn, body} = + rpc("/a2a/research", "ListTasks", %{ + "contextId" => "ctx-1", + "status" => "TASK_STATE_COMPLETED", + "pageSize" => 2, + "statusTimestampAfter" => "2026-10-27T12:00:00Z", + "historyLength" => 1 + }) + + assert_received {:list_filter, + %{ + context_id: "ctx-1", + status: :completed, + page_size: 2, + page_token: nil, + status_timestamp_after: ~U[2026-10-27 12:00:00Z] + }} + + assert %{ + "result" => %{ + "tasks" => [%{"id" => "a", "history" => [%{}]} = a, %{"id" => "b"}], + "nextPageToken" => "page-2", + "pageSize" => 2, + "totalSize" => 3 + } + } = body + + refute Map.has_key?(a, "artifacts") + end + + test "defaults, the last page, and includeArtifacts" do + {_conn, body} = + rpc("/a2a/research", "ListTasks", %{"pageToken" => "page-2", "includeArtifacts" => true}) + + assert_received {:list_filter, + %{context_id: nil, status: nil, page_size: 50, page_token: "page-2"}} + + assert %{ + "result" => %{ + "tasks" => [%{"artifacts" => [%{"artifactId" => "out"}]}, _], + "nextPageToken" => "", + "pageSize" => 50 + } + } = body + end + + test "rejects invalid parameters, and agents without list_tasks/2" do + for params <- [ + %{"pageSize" => 0}, + %{"pageSize" => 101}, + %{"status" => "TASK_STATE_BORED"}, + %{"statusTimestampAfter" => "yesterday"} + ] do + assert {_, %{"error" => %{"code" => -32602}}} = rpc("/a2a/research", "ListTasks", params) + end + + assert {_, %{"error" => %{"code" => -32004}}} = rpc("/a2a/teams/minimal", "ListTasks", %{}) + end + end +end diff --git a/test/phantom/a2a/push_test.exs b/test/phantom/a2a/push_test.exs new file mode 100644 index 0000000..bdbf5f2 --- /dev/null +++ b/test/phantom/a2a/push_test.exs @@ -0,0 +1,658 @@ +defmodule Phantom.A2A.PushTest.Agent do + use Phantom.A2A.Agent, + name: "Push", + description: "Pushes task updates", + push_notifications: true + + alias Phantom.A2A.{Message, Part, Task, TaskStatus} + + @table Phantom.A2A.PushTest + + def handle_message(%Message{parts: [%Part{text: "publish"}]}, ctx) do + Phantom.A2A.publish(ctx.task_ref, Task.status(:completed, "Done")) + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(_message, ctx), do: {:reply, Task.submitted(ctx), ctx} + + def get_task("missing", _ctx), do: {:error, :not_found} + + def get_task(id, _ctx), + do: {:ok, %Task{id: id, context_id: "ctx-1", status: %TaskStatus{state: :working}}} + + def create_push_config(config, _ctx) do + :ets.insert(@table, {config.task_id, config}) + {:ok, config} + end + + def get_push_config(task_id, id, _ctx) do + case Enum.find(push_configs(task_id), &(&1.id == id)) do + nil -> {:error, :not_found} + config -> {:ok, config} + end + end + + def list_push_configs(task_id, _ctx), do: {:ok, push_configs(task_id)} + + def delete_push_config(task_id, id, _ctx) do + :ets.match_delete(@table, {task_id, %{id: id}}) + :ok + end + + def push_configs(%Phantom.A2A.TaskRef{task_id: task_id}), do: push_configs(task_id) + + def push_configs(task_id) when is_binary(task_id), + do: for({_task_id, config} <- :ets.lookup(@table, task_id), do: config) +end + +defmodule Phantom.A2A.PushTest.CustomDelivery do + use Phantom.A2A.Agent, + name: "Custom delivery", + description: "Delivers pushes itself", + push_notifications: true + + alias Phantom.A2A.{PushConfig, Task} + + def handle_message(_message, ctx) do + Phantom.A2A.publish(ctx.task_ref, Task.status(:completed)) + {:reply, Task.submitted(ctx), ctx} + end + + def push_configs(ref), + do: [%PushConfig{id: "c1", task_id: ref.task_id, url: "https://example.com/hook"}] + + def deliver_push(config, event, ref) do + send(Phantom.A2A.PushTest, {:delivered, config, event, ref}) + :ok + end +end + +defmodule Phantom.A2A.PushTest.Plain do + use Phantom.A2A.Agent, name: "Plain", description: "No push notifications" + + def handle_message(_message, ctx), do: {:reply, Phantom.A2A.Task.submitted(ctx), ctx} + + def get_task(id, _ctx), + do: + {:ok, + %Phantom.A2A.Task{ + id: id, + context_id: "c", + status: %Phantom.A2A.TaskStatus{state: :working} + }} +end + +defmodule Phantom.A2A.PushTest.Router do + use Phantom.Router, name: "Push", vsn: "1.0", a2a: [allow_private_push_urls: true] + + agent "push", Phantom.A2A.PushTest.Agent + agent "custom", Phantom.A2A.PushTest.CustomDelivery + agent "plain", Phantom.A2A.PushTest.Plain +end + +defmodule Phantom.A2A.PushTest.Receiver do + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, test) do + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test, {:webhook, conn.request_path, conn.req_headers, JSON.decode!(body)}) + Plug.Conn.send_resp(conn, 200, "") + end +end + +defmodule Phantom.A2A.PushTest do + use ExUnit.Case, async: false + + import Phantom.A2A.Test + + alias Phantom.A2A.{Push, PushConfig} + alias Phantom.A2A.PushTest.Router + + setup_all do + start_supervised!({Agent, fn -> :ets.new(__MODULE__, [:named_table, :public, :bag]) end}) + :ok + end + + setup do + :ets.delete_all_objects(__MODULE__) + Process.register(self(), __MODULE__) + :ok + end + + defp eventually(fun, tries \\ 50) do + cond do + fun.() -> + true + + tries == 0 -> + false + + true -> + Process.sleep(20) + eventually(fun, tries - 1) + end + end + + defp deliveries do + for {_id, pid, _type, [Phantom.A2A.Push.Delivery]} <- + DynamicSupervisor.which_children(Phantom.A2A.PushSupervisor), + do: pid + end + + defp rpc(agent, method, params) do + body = %{"jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params} + + :post + |> Plug.Test.conn("/" <> agent, JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router)) + |> Map.fetch!(:resp_body) + |> JSON.decode!() + end + + describe "validate_url/2 with IPv6 literals" do + for host <- ~w[::7f00:1 ::1 ::ffff:127.0.0.1 2002:7f00:1:: 2002:a00:1:: 64:ff9b::7f00:1 + fec0::1 fc00::1 fe80::1 ff02::1 2001:db8::1 2001:0:1::1 + 64:ff9b:1::808:808 ::ffff:0:7f00:1 100::1] do + test "refuses #{host}" do + assert {:error, "resolves to a private address"} = + Push.validate_url("https://[#{unquote(host)}]/hook") + end + end + + test "allows a public address" do + assert :ok = Push.validate_url("https://[2606:4700:4700::1111]/hook") + assert :ok = Push.validate_url("https://[2002:808:808::1]/hook") + end + end + + describe "PushConfig" do + test "round-trips through ProtoJSON" do + json = %{ + "id" => "c1", + "taskId" => "t1", + "url" => "https://example.com/hook", + "token" => "tok", + "authentication" => %{"scheme" => "Bearer", "credentials" => "secret"} + } + + assert {:ok, + %PushConfig{ + id: "c1", + task_id: "t1", + url: "https://example.com/hook", + token: "tok", + authentication: %{scheme: "Bearer", credentials: "secret"} + } = config} = PushConfig.from_json(json) + + assert PushConfig.to_json(config) == json + assert {:error, _} = PushConfig.from_json(%{"taskId" => "t1"}) + end + + test "refuses header values with line breaks" do + url = "https://example.com/hook" + + for json <- [ + %{"url" => url, "token" => "tok\r\nx-injected: 1"}, + %{"url" => url, "authentication" => %{"scheme" => "Bearer\n"}}, + %{"url" => url, "authentication" => %{"scheme" => "Bearer", "credentials" => "a\rb"}} + ] do + assert {:error, _} = PushConfig.from_json(json), inspect(json) + end + end + end + + describe "validate_url/2" do + test "allows public https URLs" do + assert :ok = Push.validate_url("https://93.184.216.34/hook") + assert :ok = Push.validate_url("https://[2606:2800:220:1::1]/hook") + end + + test "rejects other schemes, plain http, and private addresses" do + for url <- [ + "ftp://93.184.216.34/hook", + "http://93.184.216.34/hook", + "https://127.0.0.1/hook", + "https://10.1.2.3/hook", + "https://172.16.0.1/hook", + "https://192.168.1.1/hook", + "https://169.254.169.254/latest/meta-data", + "https://100.64.0.1/hook", + "https://0.0.0.0/hook", + "https://[::1]/hook", + "https://[fd00::1]/hook", + "https://[fe80::1]/hook", + "https://[::ffff:127.0.0.1]/hook", + "https://192.88.99.1/hook", + "https://198.51.100.1/hook", + "https://203.0.113.1/hook", + "https://localhost/hook", + "not a url" + ] do + assert {:error, _reason} = Push.validate_url(url), url + end + end + + test "allow_private_networks allows private addresses and plain http" do + assert :ok = Push.validate_url("http://127.0.0.1:4000/hook", allow_private_networks: true) + end + end + + describe "config methods" do + test "need an agent that supports push notifications" do + assert %{"capabilities" => %{"pushNotifications" => true}} = get_card(Router, "push") + assert %{"capabilities" => %{"pushNotifications" => false}} = get_card(Router, "plain") + + assert %{"error" => %{"code" => -32003}} = + rpc("plain", "CreateTaskPushNotificationConfig", %{ + "taskId" => "t1", + "url" => "https://example.com/hook" + }) + end + + test "create, get, list, and delete" do + assert %{"result" => %{"id" => id, "taskId" => "t1", "url" => "https://example.com/hook"}} = + rpc("push", "CreateTaskPushNotificationConfig", %{ + "taskId" => "t1", + "url" => "https://example.com/hook" + }) + + assert is_binary(id) + + assert %{"result" => %{"id" => ^id}} = + rpc("push", "GetTaskPushNotificationConfig", %{"taskId" => "t1", "id" => id}) + + assert %{"result" => %{"configs" => [%{"id" => ^id}], "nextPageToken" => ""}} = + rpc("push", "ListTaskPushNotificationConfigs", %{"taskId" => "t1"}) + + for _ <- 1..2 do + assert %{"result" => nil} = + rpc("push", "DeleteTaskPushNotificationConfig", %{"taskId" => "t1", "id" => id}) + end + + assert %{"error" => %{"code" => -32001}} = + rpc("push", "GetTaskPushNotificationConfig", %{"taskId" => "t1", "id" => id}) + end + + test "check the task and the URL" do + assert %{"error" => %{"code" => -32001}} = + rpc("push", "CreateTaskPushNotificationConfig", %{ + "taskId" => "missing", + "url" => "https://example.com/hook" + }) + + assert %{"error" => %{"code" => -32602}} = + rpc("push", "CreateTaskPushNotificationConfig", %{ + "taskId" => "t1", + "url" => "ftp://example.com/hook" + }) + + assert %{"error" => %{"code" => -32602}} = + rpc("push", "CreateTaskPushNotificationConfig", %{"taskId" => "t1"}) + + assert %{"error" => %{"code" => -32001}} = + rpc("push", "ListTaskPushNotificationConfigs", %{"taskId" => "missing"}) + end + + test "SendMessage creates a config given in its configuration" do + assert {:task, %{"id" => task_id}} = + send_message(Router, "push", "hi", + configuration: %{ + "taskPushNotificationConfig" => %{"url" => "https://example.com/hook"} + } + ) + + assert [%PushConfig{task_id: ^task_id, url: "https://example.com/hook"}] = + Phantom.A2A.PushTest.Agent.push_configs(task_id) + end + end + + describe "delivery" do + setup do + {:ok, pid} = + Bandit.start_link( + plug: {Phantom.A2A.PushTest.Receiver, self()}, + ip: :loopback, + port: 0, + startup_log: false + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + {:ok, url: "http://127.0.0.1:#{port}/hook"} + end + + test "publish/2 posts each event to the task's webhooks", %{url: url} do + {:task, %{"id" => task_id, "contextId" => context_id}} = + send_message(Router, "push", "submit") + + :ets.insert( + __MODULE__, + {task_id, + %PushConfig{ + id: "c1", + task_id: task_id, + url: url, + token: "tok", + authentication: %{scheme: "Bearer", credentials: "secret"} + }} + ) + + ref = %Phantom.A2A.TaskRef{ + router: Router, + agent_id: "push", + task_id: task_id, + context_id: context_id + } + + :ok = Phantom.A2A.publish(ref, Phantom.A2A.Task.status(:completed, "Done")) + + assert_receive {:webhook, "/hook", headers, body}, 5_000 + assert {"authorization", "Bearer secret"} in headers + assert {"x-a2a-notification-token", "tok"} in headers + assert {"content-type", "application/a2a+json"} in headers + + assert %{ + "statusUpdate" => %{ + "taskId" => ^task_id, + "contextId" => ^context_id, + "status" => %{"state" => "TASK_STATE_COMPLETED"} + } + } = body + end + + test "is checked again, so a URL that became private isn't called", %{url: url} do + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.StrictRouter, + agent_id: "push", + task_id: "t9" + } + + :ets.insert(__MODULE__, {"t9", %PushConfig{id: "c1", task_id: "t9", url: url}}) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = Phantom.A2A.publish(ref, Phantom.A2A.Task.status(:completed)) + refute_receive {:webhook, _, _, _}, 300 + end) + end + + test "retries a failed delivery with backoff" do + test = self() + {:ok, attempts} = Agent.start_link(fn -> 0 end) + + plug = fn conn, _opts -> + attempt = Agent.get_and_update(attempts, &{&1 + 1, &1 + 1}) + send(test, {:attempt, attempt}) + Plug.Conn.send_resp(conn, if(attempt < 3, do: 503, else: 200), "") + end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :retry_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "t1", url: "http://127.0.0.1:#{port}/hook"} + + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.RetryRouter, + agent_id: "push", + task_id: "t1" + } + + assert :ok = Push.deliver_async(config, Phantom.A2A.Task.status(:working), ref) + + assert_receive {:attempt, 1}, 2_000 + assert_receive {:attempt, 2}, 2_000 + assert_receive {:attempt, 3}, 2_000 + refute_receive {:attempt, 4}, 50 + end + + test "waits for a retry without blocking, and can be stopped meanwhile" do + test = self() + + plug = fn conn, _opts -> + send(test, :attempt) + Plug.Conn.send_resp(conn, 503, "") + end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :waiting_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "t1", url: "http://127.0.0.1:#{port}/hook"} + + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.SlowRetryRouter, + agent_id: "push", + task_id: "t1" + } + + assert :ok = Push.deliver_async(config, Phantom.A2A.Task.status(:working), ref) + assert_receive :attempt, 2_000 + + assert [delivery] = deliveries() + + assert eventually(fn -> + match?(%{attempt: :waiting, backoff: []}, :sys.get_state(delivery)) + end) + + monitor = Process.monitor(delivery) + :ok = DynamicSupervisor.terminate_child(Phantom.A2A.PushSupervisor, delivery) + assert_receive {:DOWN, ^monitor, :process, ^delivery, _reason} + end + + test "doesn't retry a request the webhook refuses" do + test = self() + + plug = fn conn, _opts -> + send(test, :attempt) + Plug.Conn.send_resp(conn, 400, "") + end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :refusing_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "t1", url: "http://127.0.0.1:#{port}/hook"} + + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.RetryRouter, + agent_id: "push", + task_id: "t1" + } + + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = Push.deliver_async(config, Phantom.A2A.Task.status(:working), ref) + assert_receive :attempt, 2_000 + refute_receive :attempt, 100 + end) + end + + test "reaches a webhook that only has an IPv6 address" do + {:ok, pid} = + start_supervised( + {Bandit, + plug: {Phantom.A2A.PushTest.Receiver, self()}, + ip: {0, 0, 0, 0, 0, 0, 0, 1}, + port: 0, + startup_log: false}, + id: :ipv6_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "t1", url: "http://[::1]:#{port}/hook"} + + assert :ok = + Push.deliver(config, Phantom.A2A.Task.status(:working), + allow_private_networks: true + ) + + assert_receive {:webhook, "/hook", _headers, _body} + end + + test "telemetry gets the config's ID, not its secrets", %{url: url} do + test = self() + handler = "push-telemetry-#{inspect(test)}" + + :telemetry.attach( + handler, + [:phantom, :a2a, :push, :stop], + fn _event, _measurements, meta, _config -> send(test, {:telemetry, meta}) end, + nil + ) + + config = %PushConfig{ + id: "c1", + task_id: "t1", + url: url, + token: "tok-secret", + authentication: %{scheme: "Bearer", credentials: "cred-secret"} + } + + assert :ok = + Push.deliver(config, Phantom.A2A.Task.status(:working), + allow_private_networks: true + ) + + assert_receive {:telemetry, %{config_id: "c1", task_id: "t1"} = meta} + refute inspect(meta) =~ "secret" + after + :telemetry.detach("push-telemetry-#{inspect(self())}") + end + + test "doesn't wait for the webhook's answer body" do + plug = fn conn, _opts -> + conn = Plug.Conn.send_chunked(conn, 200) + {:ok, conn} = Plug.Conn.chunk(conn, String.duplicate("x", 1_000)) + Process.sleep(5_000) + conn + end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :streaming_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "t1", url: "http://127.0.0.1:#{port}/hook"} + + {time, result} = + :timer.tc(fn -> + Push.deliver(config, Phantom.A2A.Task.status(:working), allow_private_networks: true) + end) + + assert result == :ok + assert time < 2_000_000 + end + + test "delivers a webhook's events in order, one at a time" do + test = self() + {:ok, calls} = Agent.start_link(fn -> 0 end) + + plug = fn conn, _opts -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + if Agent.get_and_update(calls, &{&1, &1 + 1}) == 0, do: Process.sleep(200) + %{"statusUpdate" => %{"status" => %{"state" => state}}} = JSON.decode!(body) + send(test, {:state, state}) + Plug.Conn.send_resp(conn, 200, "") + end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :ordered_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "ordered", url: "http://127.0.0.1:#{port}/hook"} + + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.RetryRouter, + agent_id: "push", + task_id: "ordered" + } + + for state <- [:submitted, :working, :completed], + do: assert(:ok = Push.deliver_async(config, Phantom.A2A.Task.status(state), ref)) + + assert [_one] = deliveries() + + assert_receive {:state, "TASK_STATE_SUBMITTED"}, 2_000 + assert_receive {:state, "TASK_STATE_WORKING"}, 2_000 + assert_receive {:state, "TASK_STATE_COMPLETED"}, 2_000 + end + + test "keeps a limited number of events for a webhook" do + plug = fn conn, _opts -> Plug.Conn.send_resp(conn, 503, "") end + + {:ok, pid} = + start_supervised( + {Bandit, plug: plug, ip: :loopback, port: 0, startup_log: false}, + id: :full_webhook + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + config = %PushConfig{id: "c1", task_id: "full", url: "http://127.0.0.1:#{port}/hook"} + + ref = %Phantom.A2A.TaskRef{ + router: Phantom.A2A.PushTest.SlowRetryRouter, + agent_id: "push", + task_id: "full" + } + + ExUnit.CaptureLog.capture_log(fn -> + for _ <- 1..150, + do: assert(:ok = Push.deliver_async(config, Phantom.A2A.Task.status(:working), ref)) + end) + + assert [delivery] = deliveries() + assert :queue.len(:sys.get_state(delivery).queue) == 100 + :ok = DynamicSupervisor.terminate_child(Phantom.A2A.PushSupervisor, delivery) + end + + test "an agent can deliver pushes itself" do + {:task, %{"id" => task_id}} = send_message(Router, "custom", "go") + + assert_receive {:delivered, %PushConfig{id: "c1"}, + %Phantom.A2A.StatusUpdate{task_id: ^task_id, context_id: context_id}, + %Phantom.A2A.TaskRef{task_id: ^task_id}} + + assert is_binary(context_id) + end + end +end + +defmodule Phantom.A2A.PushTest.RetryRouter do + use Phantom.Router, + name: "Retry", + a2a: [allow_private_push_urls: true, push_retry_backoff: [1, 1, 1]] + + agent "push", Phantom.A2A.PushTest.Agent +end + +defmodule Phantom.A2A.PushTest.SlowRetryRouter do + use Phantom.Router, + name: "Slow retry", + a2a: [allow_private_push_urls: true, push_retry_backoff: [:timer.minutes(10)]] + + agent "push", Phantom.A2A.PushTest.Agent +end + +defmodule Phantom.A2A.PushTest.StrictRouter do + use Phantom.Router, name: "Strict" + + agent "push", Phantom.A2A.PushTest.Agent +end diff --git a/test/phantom/a2a/rest_test.exs b/test/phantom/a2a/rest_test.exs new file mode 100644 index 0000000..33646c1 --- /dev/null +++ b/test/phantom/a2a/rest_test.exs @@ -0,0 +1,327 @@ +defmodule Phantom.A2A.RESTTest.Agent do + use Phantom.A2A.Agent, + name: "REST", + description: "Served over HTTP+JSON", + push_notifications: true, + extended_card: true + + alias Phantom.A2A.{Artifact, Message, Part, Task, TaskStatus} + + @table Phantom.A2A.RESTTest + + def handle_message(%Message{parts: [%Part{text: "tenant"}]}, ctx), + do: {:reply, Message.agent("tenant=#{ctx.tenant}"), ctx} + + def handle_message(%Message{parts: [%Part{text: "work"}]}, ctx) do + ref = ctx.task_ref + + spawn(fn -> + Phantom.A2A.publish(ref, Task.status(:working, "Working")) + Phantom.A2A.publish(ref, Artifact.update("out", [Part.text("done")])) + Phantom.A2A.publish(ref, Task.status(:completed)) + end) + + {:reply, Task.submitted(ctx), ctx} + end + + def handle_message(_message, ctx), do: {:reply, Message.agent("pong"), ctx} + + def resolve(_params, _session), do: {:error, :not_found} + + def get_task("missing", _ctx), do: {:error, :not_found} + + def get_task("live", %{task_ref: ref}) do + spawn(fn -> Phantom.A2A.publish(ref, Task.status(:completed)) end) + {:ok, task("live", :working)} + end + + def get_task(id, _ctx), do: {:ok, task(id, :working)} + + def cancel_task(task, _ctx), do: {:ok, %{task | status: %TaskStatus{state: :canceled}}} + + def list_tasks(filter, _ctx) do + tasks = for id <- ["a", "b"], do: task(id, :completed) + send(Phantom.A2A.RESTTest, {:filter, filter}) + {:ok, %{tasks: Enum.take(tasks, filter.page_size), next_page_token: nil}} + end + + def create_push_config(config, _ctx) do + :ets.insert(@table, {config.task_id, config}) + {:ok, config} + end + + def get_push_config(task_id, id, _ctx) do + case for({^task_id, %{id: ^id} = c} <- :ets.lookup(@table, task_id), do: c) do + [config] -> {:ok, config} + [] -> {:error, :not_found} + end + end + + def list_push_configs(task_id, _ctx), + do: {:ok, for({_, config} <- :ets.lookup(@table, task_id), do: config)} + + def delete_push_config(task_id, id, _ctx) do + :ets.match_delete(@table, {task_id, %{id: id}}) + :ok + end + + defp task(id, state) do + %Task{ + id: id, + context_id: "ctx-1", + status: %TaskStatus{state: state}, + artifacts: [Artifact.new("out", [Part.text(id)])], + history: [Message.user("one"), Message.user("two")] + } + end +end + +defmodule Phantom.A2A.RESTTest.Router do + use Phantom.Router, name: "REST", vsn: "1.0" + + agent "rest", Phantom.A2A.RESTTest.Agent + agent ":org/:agent_key", Phantom.A2A.RESTTest.Agent, :resolve + + def connect(session, conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer nope"] -> {:unauthorized, %{method: "Bearer"}} + ["Bearer bare"] -> {:unauthorized, nil} + ["Bearer broken"] -> {:error, {:db, "postgres://admin:secret@db"}} + _ -> {:ok, session} + end + end +end + +defmodule Phantom.A2A.RESTTest do + use ExUnit.Case, async: false + + alias Phantom.A2A.RESTTest.Router + + setup_all do + start_supervised!({Agent, fn -> :ets.new(__MODULE__, [:named_table, :public, :bag]) end}) + start_supervised!({Phoenix.PubSub, name: Phantom.A2A.RESTTest.PubSub}) + :ok + end + + setup do + :ets.delete_all_objects(__MODULE__) + Process.register(self(), __MODULE__) + :ok + end + + defp request(method, path, body \\ nil, opts \\ []) do + headers = Keyword.get(opts, :headers, [{"a2a-version", "1.0"}]) + + plug_opts = + Phantom.A2A.Plug.init( + [router: Router, pubsub: Phantom.A2A.RESTTest.PubSub] ++ Keyword.get(opts, :plug, []) + ) + + method + |> Plug.Test.conn(path, body && JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> then( + &Enum.reduce(headers, &1, fn {k, v}, conn -> Plug.Conn.put_req_header(conn, k, v) end) + ) + |> Phantom.A2A.Plug.call(plug_opts) + end + + defp json(conn), do: {conn.status, JSON.decode!(conn.resp_body)} + + defp events(conn) do + for event <- String.split(conn.resp_body, "\n\n", trim: true), + not String.starts_with?(event, ":") do + ["id: " <> _, "data: " <> data] = String.split(event, "\n") + JSON.decode!(data) + end + end + + defp message(text), + do: %{ + "message" => %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]} + } + + test "the card lists both bindings at the agent's URL" do + {200, card} = json(request(:get, "/rest/agent-card.json")) + + assert [ + %{"protocolBinding" => "JSONRPC", "url" => url}, + %{"protocolBinding" => "HTTP+JSON", "url" => url} + ] = card["supportedInterfaces"] + + assert url == "http://www.example.com/rest" + end + + test "message:send answers with the bare result" do + assert {200, %{"message" => %{"parts" => [%{"text" => "pong"}]}}} = + json(request(:post, "/rest/message:send", message("ping"))) + end + + test "a segment before the route is the tenant" do + assert {200, %{"message" => %{"parts" => [%{"text" => "tenant=acme"}]}}} = + json(request(:post, "/rest/acme/message:send", message("tenant"))) + end + + test "a static agent and a tenant match before a dynamic agent" do + assert {200, %{"message" => %{"parts" => [%{"text" => "tenant=acme"}]}}} = + json(request(:post, "/rest/acme/message:send", message("tenant"))) + + assert {404, _} = json(request(:post, "/other/acme/message:send", message("tenant"))) + end + + test "connect/2 can refuse without www-authenticate info" do + headers = [{"a2a-version", "1.0"}, {"authorization", "Bearer bare"}] + conn = request(:post, "/rest/message:send", message("ping"), headers: headers) + + assert conn.status == 401 + assert Plug.Conn.get_resp_header(conn, "www-authenticate") == [] + end + + test "connect/2 errors don't reach the client" do + headers = [{"a2a-version", "1.0"}, {"authorization", "Bearer broken"}] + + assert {400, body} = + json(request(:post, "/rest/message:send", message("ping"), headers: headers)) + + refute JSON.encode!(body) =~ "secret" + end + + test "message:stream streams bare events" do + conn = request(:post, "/rest/message:stream", message("work")) + assert ["text/event-stream" <> _] = Plug.Conn.get_resp_header(conn, "content-type") + + assert [ + %{"task" => %{"status" => %{"state" => "TASK_STATE_SUBMITTED"}}}, + %{"statusUpdate" => %{"status" => %{"state" => "TASK_STATE_WORKING"}}}, + %{"artifactUpdate" => %{"artifact" => %{"artifactId" => "out"}}}, + %{"statusUpdate" => %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}} + ] = events(conn) + end + + test "tasks: get, list, cancel, and subscribe" do + assert {200, %{"id" => "t1", "history" => [%{}]}} = + json(request(:get, "/rest/tasks/t1?historyLength=1")) + + assert {200, + %{ + "tasks" => [%{"id" => "a", "artifacts" => [_]}], + "nextPageToken" => "", + "pageSize" => 1 + }} = + json( + request( + :get, + "/rest/tasks?contextId=ctx-1&pageSize=1&includeArtifacts=true&status=TASK_STATE_COMPLETED" + ) + ) + + assert_received {:filter, %{context_id: "ctx-1", page_size: 1, status: :completed}} + + assert {200, %{"id" => "t1", "status" => %{"state" => "TASK_STATE_CANCELED"}}} = + json(request(:post, "/rest/tasks/t1:cancel", %{})) + + assert [%{"task" => %{"id" => "live"}}, %{"statusUpdate" => %{"taskId" => "live"}}] = + events(request(:get, "/rest/tasks/live:subscribe")) + end + + test "responses are application/a2a+json" do + conn = request(:post, "/rest/message:send", message("ping")) + assert ["application/a2a+json" <> _] = Plug.Conn.get_resp_header(conn, "content-type") + + conn = request(:get, "/rest/tasks/missing") + assert ["application/a2a+json" <> _] = Plug.Conn.get_resp_header(conn, "content-type") + end + + test "cancel takes no body, and subscribe takes POST too" do + conn = + :post + |> Plug.Test.conn("/rest/tasks/t1:cancel", "") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router)) + + assert {200, %{"status" => %{"state" => "TASK_STATE_CANCELED"}}} = json(conn) + + assert [%{"task" => %{"id" => "live"}}, %{"statusUpdate" => _}] = + events(request(:post, "/rest/tasks/live:subscribe", %{})) + end + + test "push notification configs" do + assert {200, %{"id" => id, "taskId" => "t1"}} = + json( + request(:post, "/rest/tasks/t1/pushNotificationConfigs", %{ + "url" => "https://example.com/hook" + }) + ) + + assert {200, %{"id" => ^id}} = + json(request(:get, "/rest/tasks/t1/pushNotificationConfigs/#{id}")) + + assert {200, %{"configs" => [%{"id" => ^id}]}} = + json(request(:get, "/rest/tasks/t1/pushNotificationConfigs")) + + assert %{status: 200} = request(:delete, "/rest/tasks/t1/pushNotificationConfigs/#{id}") + assert {404, _} = json(request(:get, "/rest/tasks/t1/pushNotificationConfigs/#{id}")) + end + + test "extendedAgentCard" do + assert {200, %{"name" => "REST", "capabilities" => %{"extendedAgentCard" => true}}} = + json(request(:get, "/rest/extendedAgentCard")) + end + + test "errors are google.rpc.Status with HTTP status codes" do + assert {404, + %{ + "error" => %{ + "code" => 404, + "status" => "NOT_FOUND", + "message" => "Task not found: missing", + "details" => [%{"reason" => "TASK_NOT_FOUND", "domain" => "a2a-protocol.org"}] + } + }} = json(request(:get, "/rest/tasks/missing")) + + assert {400, %{"error" => %{"status" => "INVALID_ARGUMENT"}}} = + json(request(:post, "/rest/message:send", %{"message" => %{}})) + + assert {400, + %{ + "error" => %{ + "status" => "FAILED_PRECONDITION", + "details" => [%{"reason" => "VERSION_NOT_SUPPORTED"}] + } + }} = + json(request(:get, "/rest/tasks/t1", nil, headers: [])) + + conn = + request(:get, "/rest/tasks/t1", nil, + headers: [{"a2a-version", "1.0"}, {"authorization", "Bearer nope"}] + ) + + assert {401, %{"error" => %{"status" => "UNAUTHENTICATED"}}} = json(conn) + assert [_] = Plug.Conn.get_resp_header(conn, "www-authenticate") + end + + test "invalid JSON is INVALID_ARGUMENT" do + conn = + :post + |> Plug.Test.conn("/rest/message:send", "{nope") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router)) + + assert {400, %{"error" => %{"status" => "INVALID_ARGUMENT"}}} = json(conn) + end + + test "bindings: [:jsonrpc] turns HTTP+JSON off" do + {200, card} = json(request(:get, "/rest/agent-card.json", nil, plug: [bindings: [:jsonrpc]])) + assert [%{"protocolBinding" => "JSONRPC"}] = card["supportedInterfaces"] + + assert %{status: 404} = request(:get, "/rest/tasks/t1", nil, plug: [bindings: [:jsonrpc]]) + end + + test "agent paths can't use the binding's segments" do + for path <- ["tasks", "a/extendedAgentCard", "pushNotificationConfigs/b"] do + assert_raise ArgumentError, ~r/reserved/, fn -> + Phantom.Router.agent_route(path, __MODULE__, []) + end + end + end +end diff --git a/test/phantom/a2a/router_test.exs b/test/phantom/a2a/router_test.exs new file mode 100644 index 0000000..c25ea86 --- /dev/null +++ b/test/phantom/a2a/router_test.exs @@ -0,0 +1,38 @@ +defmodule Phantom.A2A.RouterTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.TaskRef + + test "agent paths are plain segments or path params" do + for path <- ["", "a//b", ":Org/agent", "a b", "research/agent-card.json"] do + assert_raise ArgumentError, ~r/invalid agent path/, fn -> + Phantom.Router.agent_route(path, __MODULE__, []) + end + end + + assert %{id: "teams/a.b-c_d~e", segments: ["teams", "a.b-c_d~e"], opts: [x: 1]} = + Phantom.Router.agent_route("teams/a.b-c_d~e", __MODULE__, x: 1) + end + + test "skills need a description" do + assert_raise ArgumentError, ~r/needs a description/, fn -> + Phantom.A2A.AgentSkill.build(:review, tags: []) + end + end + + test "task refs round-trip through a string" do + ref = %TaskRef{ + pubsub: Phantom.A2A.RouterTest.PubSub, + router: __MODULE__, + agent_id: "a/b", + task_id: "t1" + } + + assert {:ok, ^ref} = ref |> TaskRef.encode() |> TaskRef.decode() + + assert {:error, :invalid} = + TaskRef.decode(~s(["Elixir.NoSuchAtomAnywhere123", "x", "a", "t"])) + + assert {:error, :invalid} = TaskRef.decode("nope") + end +end diff --git a/test/phantom/a2a/telemetry_test.exs b/test/phantom/a2a/telemetry_test.exs new file mode 100644 index 0000000..9980b32 --- /dev/null +++ b/test/phantom/a2a/telemetry_test.exs @@ -0,0 +1,105 @@ +defmodule Phantom.A2A.TelemetryTest.Agent do + use Phantom.A2A.Agent, name: "Telemetry", description: "Emits events" + + alias Phantom.A2A.{Message, Part} + + def handle_message(%Message{parts: [%Part{text: "explode"}]}, _ctx), do: raise("boom") + def handle_message(_message, ctx), do: {:reply, Message.agent("ok"), ctx} +end + +defmodule Phantom.A2A.TelemetryTest.Router do + use Phantom.Router, name: "Telemetry", vsn: "1.0" + + agent "telemetry", Phantom.A2A.TelemetryTest.Agent +end + +defmodule Phantom.A2A.TelemetryTest do + use ExUnit.Case, async: true + + alias Phantom.A2A.TelemetryTest.Router + + setup do + test = self() + id = {__MODULE__, make_ref()} + + :telemetry.attach_many( + id, + [ + [:phantom, :a2a, :dispatch, :start], + [:phantom, :a2a, :dispatch, :stop], + [:phantom, :a2a, :dispatch, :exception], + [:phantom, :a2a, :push, :start], + [:phantom, :a2a, :push, :stop] + ], + fn event, measurements, meta, _config -> + if meta[:router] == Router or meta[:url] == "http://127.0.0.1:1/hook", + do: send(test, {:telemetry, event, measurements, meta}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(id) end) + end + + defp send_message(text, headers \\ []) do + body = %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "SendMessage", + "params" => %{ + "message" => %{"messageId" => "m1", "role" => "ROLE_USER", "parts" => [%{"text" => text}]} + } + } + + :post + |> Plug.Test.conn("/telemetry", JSON.encode!(body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> then( + &Enum.reduce(headers, &1, fn {k, v}, conn -> Plug.Conn.put_req_header(conn, k, v) end) + ) + |> Phantom.A2A.Plug.call(Phantom.A2A.Plug.init(router: Router)) + end + + test "each request is a dispatch span, with the client's trace context" do + send_message("hi", [{"traceparent", "00-abc-def-01"}, {"tracestate", "vendor=1"}]) + + assert_received {:telemetry, [:phantom, :a2a, :dispatch, :start], %{system_time: _}, meta} + + assert %{ + router: Router, + agent_id: "telemetry", + method: "SendMessage", + params: %{"message" => _}, + session: %Phantom.Session{}, + trace_context: %{traceparent: "00-abc-def-01", tracestate: "vendor=1"} + } = meta + + assert_received {:telemetry, [:phantom, :a2a, :dispatch, :stop], %{duration: _}, + %{method: "SendMessage", result: {:ok, %{"message" => _}}}} + end + + test "a raise is an exception event" do + assert_raise RuntimeError, "boom", fn -> send_message("explode") end + + assert_received {:telemetry, [:phantom, :a2a, :dispatch, :exception], %{duration: _}, + %{ + kind: :error, + reason: %RuntimeError{message: "boom"}, + agent_id: "telemetry" + }} + end + + test "each push delivery is a span" do + config = %Phantom.A2A.PushConfig{id: "c1", task_id: "t1", url: "http://127.0.0.1:1/hook"} + event = %{Phantom.A2A.Task.status(:completed) | task_id: "t1", context_id: "c"} + + assert {:error, _reason} = + Phantom.A2A.Push.deliver(config, event, allow_private_networks: true) + + assert_received {:telemetry, [:phantom, :a2a, :push, :start], _, + %{task_id: "t1", config_id: "c1"}} + + assert_received {:telemetry, [:phantom, :a2a, :push, :stop], _, %{result: {:error, _}}} + end +end diff --git a/test/phantom/a2a/test_helpers_test.exs b/test/phantom/a2a/test_helpers_test.exs new file mode 100644 index 0000000..0bc9c4d --- /dev/null +++ b/test/phantom/a2a/test_helpers_test.exs @@ -0,0 +1,76 @@ +defmodule Phantom.A2A.TestHelpersTest.Echo do + use Phantom.A2A.Agent, name: "Echo", description: "Echoes" + + alias Phantom.A2A.{Message, Part, Task, TaskStatus} + + def handle_message(%Message{parts: [%Part{text: "task"}]}, ctx), + do: {:reply, Task.submitted(ctx, status: %TaskStatus{state: :completed}), ctx} + + def handle_message(%Message{parts: [%Part{text: text}]}, ctx), + do: {:reply, Message.agent("#{ctx.assigns.user}: #{text}"), ctx} + + def get_task(id, _ctx), + do: {:ok, %Task{id: id, context_id: "c", status: %TaskStatus{state: :working}}} + + def cancel_task(task, _ctx), do: {:ok, %{task | status: %TaskStatus{state: :canceled}}} +end + +defmodule Phantom.A2A.TestHelpersTest.Router do + use Phantom.Router, name: "Helpers", vsn: "1.0" + + agent "echo", Phantom.A2A.TestHelpersTest.Echo + + def connect(session, conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer " <> user] -> {:ok, Phantom.Session.assign(session, user: user)} + [] -> {:unauthorized, %{method: "Bearer"}} + end + end +end + +defmodule Phantom.A2A.TestHelpersTest do + use ExUnit.Case, async: true + + import Phantom.A2A.Test + + alias Phantom.A2A.TestHelpersTest.Router + + @auth [headers: [{"authorization", "Bearer alice"}]] + + test "get_card/3" do + assert %{ + "name" => "Echo", + "supportedInterfaces" => [%{"url" => "http://www.example.com/echo"} | _] + } = + get_card(Router, "echo") + end + + test "send_message/4 with text or a message" do + assert {:message, %{"parts" => [%{"text" => "alice: hi"}]}} = + send_message(Router, "echo", "hi", @auth) + + message = Phantom.A2A.Message.user("yo", context_id: "ctx") + + assert {:message, %{"contextId" => "ctx", "parts" => [%{"text" => "alice: yo"}]}} = + send_message(Router, "echo", message, @auth) + + assert {:task, %{"status" => %{"state" => "TASK_STATE_COMPLETED"}}} = + send_message(Router, "echo", "task", @auth) + end + + test "errors" do + assert {:error, %{"code" => -32600, "message" => "Unauthorized"}} = + send_message(Router, "echo", "hi") + + assert {:error, %{"code" => -32600, "message" => "Not found"}} = + send_message(Router, "nope", "hi", @auth) + end + + test "get_task/4 and cancel_task/4" do + assert {:ok, %{"id" => "t1", "status" => %{"state" => "TASK_STATE_WORKING"}}} = + get_task(Router, "echo", "t1", @auth) + + assert {:ok, %{"status" => %{"state" => "TASK_STATE_CANCELED"}}} = + cancel_task(Router, "echo", "t1", @auth) + end +end diff --git a/test/phantom/router_extensions_test.exs b/test/phantom/router_extensions_test.exs new file mode 100644 index 0000000..27e2a70 --- /dev/null +++ b/test/phantom/router_extensions_test.exs @@ -0,0 +1,52 @@ +defmodule Phantom.RouterExtensionsTest do + use ExUnit.Case, async: true + + @client_credentials "io.modelcontextprotocol/oauth-client-credentials" + + defmodule ExtensionsRouter do + use Phantom.Router, + name: "Test", + vsn: "1.0", + extensions: %{"io.modelcontextprotocol/oauth-client-credentials" => %{}} + + def get_task(_task_id, _session), do: nil + end + + defmodule PlainRouter do + use Phantom.Router, name: "Test", vsn: "1.0" + end + + setup_all do + Phantom.Cache.register(ExtensionsRouter) + Phantom.Cache.register(PlainRouter) + :ok + end + + defp dispatch(router, method, params) do + session = Phantom.Test.build_session(router) + request = Phantom.Test.build_request(method, params: params) + router.dispatch_method(method, params, request, %{session | request: request}) + end + + test "server/discover advertises the router's extensions beside Phantom's own" do + assert {:reply, %{capabilities: %{extensions: extensions}}, _} = + dispatch(ExtensionsRouter, "server/discover", %{}) + + assert extensions[@client_credentials] == %{} + assert Map.has_key?(extensions, Phantom.Tasks.extension()) + end + + test "initialize advertises the router's extensions" do + params = %{"protocolVersion" => "2025-06-18", "capabilities" => %{}} + + assert {:reply, %{capabilities: %{extensions: %{@client_credentials => %{}}}}, _} = + dispatch(ExtensionsRouter, "initialize", params) + end + + test "a router without extensions advertises none" do + assert {:reply, %{capabilities: capabilities}, _} = + dispatch(PlainRouter, "server/discover", %{}) + + refute Map.has_key?(capabilities, :extensions) + end +end diff --git a/test/support/acts.ex b/test/support/acts.ex new file mode 100644 index 0000000..18f29ba --- /dev/null +++ b/test/support/acts.ex @@ -0,0 +1,122 @@ +defmodule Phantom.Test.ACTS do + @moduledoc """ + Runs the A2A conformance suite (ACTS) from `a2aproject/a2a-itk` against + `Phantom.ACTS.Agent`, served three ways: the default agent, one that + doesn't stream (`--reduced-sut-url`), and one behind bearer authentication + (`--auth-sut-url`). See test/acts/README.md. + """ + + @itk_repository "https://github.com/a2aproject/a2a-itk.git" + @itk_ref "d53108aaf378a38eaff64ea39029a3d795591827" + @ports [default: 4130, reduced: 4131, auth: 4132] + @dir Path.expand("../acts", __DIR__) + + @doc "Children that serve the three agents" + def children do + [ + {Phoenix.PubSub, name: Phantom.ACTS.PubSub}, + Phantom.ACTS.Store, + bandit(:default, pubsub: Phantom.ACTS.PubSub), + bandit(:reduced, push_notifications: false, extended_card: false), + bandit(:auth, pubsub: Phantom.ACTS.PubSub, router: Phantom.ACTS.AuthRouter) + ] + end + + defp bandit(name, opts) do + Supervisor.child_spec( + {Bandit, + plug: {Phantom.ACTS.HTTP, opts}, + ip: {127, 0, 0, 1}, + port: Keyword.fetch!(@ports, name), + startup_log: false}, + id: {__MODULE__, name} + ) + end + + @doc "The URL of each agent" + def url(name), do: "http://127.0.0.1:#{Keyword.fetch!(@ports, name)}" + + @doc """ + Run the suite over JSON-RPC and HTTP+JSON. Returns + `{exit_status, output, reports}`, with the decoded ACTS report of each + binding by its name, `"jsonrpc"` and `"rest"`. + """ + def run(args \\ []) do + itk = itk!() + out = Path.join(System.tmp_dir!(), "phantom-acts-#{System.unique_integer([:positive])}") + + {output, status} = + System.cmd( + "uv", + [ + "run", + "run_acts.py", + "--sut-url", + url(:default), + "--reduced-sut-url", + url(:reduced), + "--auth-sut-url", + url(:auth), + "--sdk", + "phantom_mcp", + "--language", + "elixir", + "--behaviors", + Path.join(@dir, "sut-behaviors.yaml"), + "--transport", + "jsonrpc", + "--transport", + "rest", + "--out", + out | args + ], + cd: itk, + stderr_to_stdout: true, + env: [{"NO_COLOR", "1"}] + ) + + reports = + for path <- Path.wildcard(Path.join(out, "*.json")), into: %{} do + report = path |> File.read!() |> JSON.decode!() + {report["transport"], report} + end + + File.rm_rf(out) + {status, output, reports} + end + + @doc "The test ids in a report, by result" + def results(report) do + for suite <- report["suites"], test <- suite["tests"], reduce: %{} do + acc -> Map.update(acc, test["result"], [test["id"]], &[test["id"] | &1]) + end + end + + @doc "The ids in expected-failures.yml" + def expected_failures do + for line <- File.stream!(Path.join(@dir, "expected-failures.yml")), + [_, id] <- [Regex.run(~r/^- ([A-Z0-9-]+)/, line)], + do: id + end + + # A checkout given in A2A_ITK_PATH, or the pinned ref cloned into _build. + defp itk! do + path = System.get_env("A2A_ITK_PATH") || Path.join(Mix.Project.build_path(), "../a2a-itk") + + if not File.exists?(Path.join(path, "run_acts.py")) do + File.rm_rf!(path) + cmd!("git", ["clone", "--quiet", @itk_repository, path]) + cmd!("git", ["checkout", "--quiet", @itk_ref], cd: path) + end + + cmd!("uv", ["sync", "--quiet"], cd: path) + path + end + + defp cmd!(command, args, opts \\ []) do + case System.cmd(command, args, [stderr_to_stdout: true] ++ opts) do + {_output, 0} -> :ok + {output, status} -> raise "#{command} #{Enum.join(args, " ")} exited #{status}:\n#{output}" + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index ac6375a..b93253e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -12,4 +12,4 @@ unless :clustered in exclude do ]) end -ExUnit.start(exclude: [:conformance | exclude]) +ExUnit.start(exclude: [:conformance, :acts | exclude])