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.

Functions

build_agent_card(config, opts)

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 [].

handle_message(config, message)

@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 whose text is neither a string nor nil (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.

handle_message(config, message, carrier)

@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.

handle_message(config, message, carrier, opts)

@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. nil sets no limit. The default value is 100000.

  • :tool_context (map/0) - Replaces config.tool_context for 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.