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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions lib/a2a/plug/multi_tenant.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
if Code.ensure_loaded?(Plug) do
defmodule A2A.Plug.MultiTenant do
@moduledoc """
Plug for multi-tenant A2A deployments with path-based routing.

Routes requests matching `/:tenant/:agent/*path` to the correct agent
process. This is an optional module — existing single-tenant usage
via `A2A.Plug` is unaffected.

## Usage

# In a Phoenix router:
forward "/", A2A.Plug.MultiTenant,
agents: %{
"greeter" => GreeterAgent,
"helper" => HelperAgent
},
base_url: "http://localhost:4000"

# With a registry:
forward "/", A2A.Plug.MultiTenant,
registry: MyApp.AgentRegistry,
base_url: "http://localhost:4000"

This serves:
- `GET /:tenant/:agent/.well-known/agent-card.json` — per-tenant agent card
- `POST /:tenant/:agent/` — JSON-RPC dispatch with tenant context

## Options

- `:agents` — static map of agent name to GenServer name/pid
- `:registry` — `A2A.Registry` name for dynamic agent lookup
- `:base_url` — public base URL (required)
- `:plug_opts` — extra options forwarded to `A2A.Plug.init/1`

## Tenant Context

Injects into `conn.assigns`:
- `:a2a_tenant` — the tenant ID from the URL path
- `:a2a_agent_name` — the agent name from the URL path

Sets `"tenant_id"` in task metadata via `A2A.Plug.put_metadata/2`.
"""

@behaviour Plug

import Plug.Conn

@impl Plug
@spec init(keyword()) :: map()
def init(opts) do
agents = Keyword.get(opts, :agents)
registry = Keyword.get(opts, :registry)

unless agents || registry do
raise ArgumentError,
"A2A.Plug.MultiTenant requires either :agents map or :registry option"
end

%{
agents: agents,
registry: registry,
base_url: Keyword.fetch!(opts, :base_url),
plug_opts: Keyword.get(opts, :plug_opts, [])
}
end

@impl Plug
@spec call(Plug.Conn.t(), map()) :: Plug.Conn.t()
def call(%{path_info: [tenant, agent_name | rest]} = conn, opts) do
case resolve_agent(agent_name, opts) do
{:ok, agent_ref} ->
conn =
conn
|> assign(:a2a_tenant, tenant)
|> assign(:a2a_agent_name, agent_name)

tenant_base_url = "#{opts.base_url}/#{tenant}/#{agent_name}"

conn =
conn
|> A2A.Plug.put_base_url(tenant_base_url)
|> A2A.Plug.put_metadata(%{"tenant_id" => tenant})

plug_init_opts =
[
agent: agent_ref,
base_url: opts.base_url
] ++ opts.plug_opts

conn = %{conn | path_info: rest, script_name: conn.script_name ++ [tenant, agent_name]}

a2a_opts = A2A.Plug.init(plug_init_opts)
A2A.Plug.call(conn, a2a_opts)

{:error, :not_found} ->
conn
|> send_resp(404, "Agent not found: #{agent_name}")
end
end

def call(conn, _opts) do
send_resp(conn, 404, "Not Found")
end

defp resolve_agent(name, %{agents: agents}) when is_map(agents) do
case Map.fetch(agents, name) do
{:ok, agent} -> {:ok, agent}
:error -> {:error, :not_found}
end
end

defp resolve_agent(name, %{registry: registry}) when not is_nil(registry) do
entries = A2A.Registry.all(registry)

case Enum.find(entries, fn {_mod, card} -> card.name == name end) do
{mod, _card} -> {:ok, mod}
nil -> {:error, :not_found}
end
end

defp resolve_agent(_name, _opts), do: {:error, :not_found}
end
end
103 changes: 96 additions & 7 deletions lib/a2a/task_store/ets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ defmodule A2A.TaskStore.ETS do
## With an Agent

MyAgent.start_link(task_store: {A2A.TaskStore.ETS, :my_tasks})

## Multi-Tenant Usage

For tenant-isolated storage, use `tenant_ref/2` to create a namespaced
reference. Tasks stored under one tenant are invisible to other tenants.

ref = A2A.TaskStore.ETS.tenant_ref(:my_tasks, "acme")
:ok = A2A.TaskStore.ETS.put(ref, task)

# Only returns tasks for "acme" tenant
{:ok, tasks} = A2A.TaskStore.ETS.list_all(ref)

# Plain ref still works (backward compatible)
{:ok, all} = A2A.TaskStore.ETS.list_all(:my_tasks)
"""

use GenServer
Expand All @@ -33,6 +47,32 @@ defmodule A2A.TaskStore.ETS do
GenServer.start_link(__MODULE__, name, name: name)
end

@doc """
Creates a tenant-namespaced store reference.

All operations using this ref will scope keys by the given tenant,
providing task isolation between tenants sharing the same ETS table.

ref = A2A.TaskStore.ETS.tenant_ref(:my_tasks, "acme")
:ok = A2A.TaskStore.ETS.put(ref, task)
"""
@spec tenant_ref(atom(), String.t()) :: {atom(), String.t()}
def tenant_ref(table, tenant) when is_atom(table) and is_binary(tenant) do
{table, tenant}
end

# -- get ---------------------------------------------------------------------

@doc false
def get({table, tenant}, task_id) do
key = {tenant, task_id}

case :ets.lookup(table, key) do
[{^key, task}] -> {:ok, task}
[] -> {:error, :not_found}
end
end

@impl A2A.TaskStore
def get(table, task_id) do
case :ets.lookup(table, task_id) do
Expand All @@ -41,32 +81,81 @@ defmodule A2A.TaskStore.ETS do
end
end

# -- put ---------------------------------------------------------------------

@doc false
def put({table, tenant}, %A2A.Task{} = task) do
:ets.insert(table, {{tenant, task.id}, task})
:ok
end

@impl A2A.TaskStore
def put(table, %A2A.Task{} = task) do
:ets.insert(table, {task.id, task})
:ok
end

# -- delete ------------------------------------------------------------------

@doc false
def delete({table, tenant}, task_id) do
:ets.delete(table, {tenant, task_id})
:ok
end

@impl A2A.TaskStore
def delete(table, task_id) do
:ets.delete(table, task_id)
:ok
end

# -- list --------------------------------------------------------------------

@doc false
def list({table, tenant}, context_id) do
:ets.tab2list(table)
|> Enum.filter(fn
{{^tenant, _id}, task} -> task.context_id == context_id
_ -> false
end)
|> Enum.map(fn {_key, task} -> task end)
|> then(&{:ok, &1})
end

@impl A2A.TaskStore
def list(table, context_id) do
tasks =
:ets.tab2list(table)
|> Enum.filter(fn {_id, task} -> task.context_id == context_id end)
|> Enum.map(fn {_id, task} -> task end)
:ets.tab2list(table)
|> Enum.filter(fn
{{_tenant, _id}, _task} -> false
{_id, task} -> task.context_id == context_id
end)
|> Enum.map(fn {_key, task} -> task end)
|> then(&{:ok, &1})
end

{:ok, tasks}
# -- list_all ----------------------------------------------------------------

@doc false
def list_all(ref, opts \\ [])

def list_all({table, tenant}, opts) do
:ets.tab2list(table)
|> Enum.filter(fn
{{^tenant, _id}, _task} -> true
_ -> false
end)
|> Enum.map(fn {_key, task} -> task end)
|> A2A.Task.Filter.apply(opts)
end

@impl A2A.TaskStore
def list_all(table, opts \\ []) do
def list_all(table, opts) do
:ets.tab2list(table)
|> Enum.map(fn {_id, task} -> task end)
|> Enum.filter(fn
{{_tenant, _id}, _task} -> false
_ -> true
end)
|> Enum.map(fn {_key, task} -> task end)
|> A2A.Task.Filter.apply(opts)
end

Expand Down
Loading