Normandy.A2A.Server
(normandy v2.2.0)
View Source
Exposes Normandy agents as A2A-compatible endpoints.
Provides logic for building agent cards from Normandy agent configurations and handling incoming A2A messages by routing them through the agent.
This module provides the logic layer only - it does not start an HTTP server. Integrate it into your web framework (e.g., Phoenix controller or Plug).
Example
# Build agent card
card = Normandy.A2A.Server.build_agent_card(agent_config,
name: "My Agent",
description: "Helps with tasks",
url: "https://myapp.com/a2a",
version: "1.0.0"
)
# Handle incoming message (in your controller), scoped to the caller
{:ok, task} =
Normandy.A2A.Server.handle_message(agent_config, a2a_message, conn.req_headers,
tool_context: %{memory_namespace: "a2a/" <> caller_id}
)What the host owns
This module never sees the HTTP request. Authentication, authorization and the request
body limit (Plug.Parsers' :length) belong to your Plug or controller: every caller
that gets past them reaches the agent and every tool in config.toolset, whatever
build_agent_card/2's :expose_tools lists (the card only advertises). Expose an agent
whose tools are safe for every such caller.
Each call runs on the config you pass, so callers share its tool_context (and the
memory namespace in it) unless you pass handle_message/4's :tool_context per caller.
A shared memory namespace lets one caller plant text that every other caller's turns
read. :max_text_bytes (default 100_000) bounds the text a caller can send.
Summary
Functions
Builds a Claudio.A2A.AgentCard from a Normandy agent configuration.
Handles an incoming A2A message by routing it through the agent.
Like handle_message/2, joining the caller's trace from carrier.
Like handle_message/3, with options.
Functions
@spec build_agent_card( Normandy.Agents.BaseAgentConfig.t(), keyword() ) :: Claudio.A2A.AgentCard.t()
Builds a Claudio.A2A.AgentCard from a Normandy agent configuration.
Introspects registered tools to generate skill descriptions.
Options
:name(String.t/0) - Required. The agent's name.:description(String.t/0) - Required. What the agent does.:url(String.t/0) - The A2A endpoint URL.:version(String.t/0) - The agent's version.:provider_url(String.t/0) - The provider's URL.:provider_org(String.t/0) - The provider's organization (used with:provider_url).:expose_tools- Tool names to list as skills in the public card, or:all. By default none, so an internal or privileged tool is not advertised unless you choose to. The default value is[].
@spec handle_message(Normandy.Agents.BaseAgentConfig.t(), Claudio.A2A.Message.t()) :: {:ok, Claudio.A2A.Task.t()} | {:error, term()}
Handles an incoming A2A message by routing it through the agent.
Returns a Claudio.A2A.Task with the result as an artifact, or:
{:error, :no_text}- the message has no text part (file or data parts alone are not run as an empty prompt){:error, :invalid_message}- a text part whosetextis neither a string nornil(a decoded JSON list, say); the agent does not run{:error, :agent_failed}- the agent raised or exited. The remote caller gets only this; the detail is logged.
Each call runs on the given config: the A2A context_id and task_id do
not carry memory between calls.
Same as handle_message/3 with no carrier: the request span nests under
whatever OpenTelemetry context is current in the calling process.
@spec handle_message(Normandy.Agents.BaseAgentConfig.t(), Claudio.A2A.Message.t(), [ {String.t(), String.t()} ]) :: {:ok, Claudio.A2A.Task.t()} | {:error, term()}
Like handle_message/2, joining the caller's trace from carrier.
carrier is the incoming request's headers as [{name, value}] (a Plug
conn.req_headers works as is). When it has a valid W3C traceparent and
OpenTelemetry is loaded, the extracted context is attached for the duration
of the call (and the previous one put back after), so the agent's spans join
the remote caller's trace. Only traceparent and tracestate are read: an inbound
baggage header is dropped, so a remote caller cannot set baggage entries in this
process's context (they would reach every outbound call that propagates it). Without
a traceparent (absent or malformed) the current context is left alone. If your HTTP instrumentation already extracted the trace into a
server span, call handle_message/2 instead so the agent nests under that
span.
Emits a [:normandy, :a2a, :request] Normandy.Telemetry.Span.span/3. Start metadata:
:task_id (the id of the returned task) and :agent_name (config.name).
Stop metadata adds :status (:ok or :error) and, on error, :error_type
(:no_text, :too_large, :invalid_message or :agent_failed). No message content is included.
Same as handle_message/4 with no options.
@spec handle_message( Normandy.Agents.BaseAgentConfig.t(), Claudio.A2A.Message.t(), [{String.t(), String.t()}], keyword() ) :: {:ok, Claudio.A2A.Task.t()} | {:error, term()}
Like handle_message/3, with options.
Returns {:error, :too_large} when the message's text parts hold more than
:max_text_bytes bytes in total: the agent does not run, nothing is logged, and the
request span's stop metadata has error_type: :too_large.
Options
:max_text_bytes- The most bytes of text the message's parts may hold in total; more is{:error, :too_large}and the agent does not run.nilsets no limit. The default value is100000.:tool_context(map/0) - Replacesconfig.tool_contextfor this call, so each caller gets its own (its memory namespace, say). Not merged: a key you leave out is absent. Default: the config's own.