A2A.Agent behaviour (A2A v0.3.0)

Copy Markdown View Source

Behaviour for defining A2A agents.

Agents are the primary abstraction in the A2A library. Each agent declares its identity and capabilities via agent_card/0 and implements message handling via handle_message/2.

Usage

defmodule MyApp.GreeterAgent do
  use A2A.Agent,
    name: "greeter",
    description: "Greets users",
    skills: [
      %{id: "greet", name: "Greet", description: "Says hello", tags: []}
    ]

  @impl A2A.Agent
  def handle_message(message, _context) do
    text = A2A.Message.text(message)
    {:reply, [A2A.Part.Text.new("Hello, #{text}!")]}
  end
end

The use macro accepts shorthand options for agent_card/0:

  • :name — agent name (required unless agent_card/0 is defined manually)
  • :description — agent description (default: "")
  • :version — agent version (default: "0.1.0")
  • :skills — list of skill maps (default: [])
  • :opts — additional keyword options (default: [])

Architecture

use A2A.Agent generates a full GenServer. The agent author only implements the behaviour callbacks — the runtime manages task lifecycle, state transitions, history accumulation, and persistence.

Internally, three modules collaborate:

  • A2A.Agent (this module) — the behaviour definition and the use macro that generates GenServer client API and callbacks.
  • Agent.Runtime (internal) — pure functions for message processing. Creates tasks, calls handle_message/2, maps reply tuples to state transitions. Also handles task continuation (multi-turn) and stream wrapping.
  • Agent.State (internal) — the internal GenServer state struct. Holds the task map, context index, and optional task store reference. Provides helpers for task storage, retrieval, and state transitions.

Task Lifecycle

The runtime manages a task state machine so agent implementations don't have to. Each message creates (or continues) a task — except {:message, parts}, which answers without one — that progresses through:

:submitted → :working → :completed
                      → :failed
                      → :input_required → (new message) → :working → ...
                      → :canceled

The reply from handle_message/2 determines the transition:

  • {:reply, parts} — creates an artifact, transitions to :completed
  • {:message, parts} — answers with a bare A2A.Message and no task (see below)
  • {:input_required, parts} — transitions to :input_required, caller can continue the same task by passing task_id: to the next call
  • {:stream, enumerable} — stays :working, transitions to :completed when the caller fully consumes the stream
  • {:error, reason} — transitions to :failed

Bare Message Replies

{:message, parts} is the other half of the A2A spec's SendMessageResponse oneof: the agent answers out-of-band and no task is created. The task the runtime built for the turn is discarded — nothing is persisted, tasks/get will not find it, and A2A.call/3 returns {:ok, %A2A.Message{}} instead of a task. On the wire the JSON-RPC result is {"message": …} rather than {"task": …}.

The message inherits the request's context_id and carries no task_id, since there is no task for it to reference.

Prefer it over {:reply, parts} for answers with nothing to track — a lookup, an acknowledgement, a rejected request. Anything a caller might later poll, cancel, or continue needs a task.

Returning it while continuing an existing task is rejected with {:error, :message_on_task} (-32006 on the wire): the caller is holding a task id, so a bare Message would strand that task and drop the turn from its history.

Reply Parts and Status

The parts returned from handle_message/2 serve double duty: they are appended to the task's history (as an agent message) and, for {:input_required, parts}, set as the task's status.message.

These two fields have different audiences:

  • history — the full conversation transcript, used by agent logic for context on subsequent turns.
  • status.message — a short prompt surfaced to the client UI, explaining why the task is paused and what input is needed.

Because both are populated from the same parts, keep {:input_required, parts} concise and actionable:

# Good — short prompt that works as both history entry and status
{:input_required, [Part.Text.new("What size pizza?")]}

# Avoid — long explanation duplicated into status.message
{:input_required, [Part.Text.new("Here's our full menu... What size?")]}

For {:reply, parts} and {:stream, enumerable}, the parts only go into history and artifacts — status.message is left empty. {:message, parts} populates neither, since it produces no task.

Multi-Turn Conversations

When an agent returns {:input_required, parts}, the task pauses. The caller continues it by passing task_id: with the next message:

{:ok, task} = A2A.call(agent, "order pizza")
# task.status.state == :input_required
{:ok, task} = A2A.call(agent, "large", task_id: task.id)
# task.status.state may be :completed or :input_required again

The runtime appends each message to the task's history, so the agent receives the full conversation in context.history.

Streaming

When an agent returns {:stream, enumerable}, the runtime wraps the stream so that consuming it automatically finalizes the task:

{:ok, task, stream} = A2A.stream(agent, "count to 5")
Enum.each(stream, &IO.inspect/1)
# task is now :completed with an artifact containing all streamed parts

Persistence

By default, tasks live in the GenServer's process state (in-memory map). For external persistence, pass a task store at startup:

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

The runtime writes every task update to both the internal map and the external store. See A2A.TaskStore for the behaviour interface.

Starting an Agent

{:ok, pid} = MyAgent.start_link()
{:ok, task} = A2A.call(MyAgent, "hello")

Or with options:

MyAgent.start_link(name: :my_agent, task_store: {A2A.TaskStore.ETS, :tasks})

Summary

Callbacks

Returns the agent's identity and capabilities.

Called when a task is canceled by the caller. Optional.

Handles an incoming message. This is the core agent logic.

Types

card()

@type card() :: %{
  name: String.t(),
  description: String.t(),
  version: String.t(),
  skills: [skill()],
  opts: keyword()
}

context()

@type context() :: %{
  task_id: String.t(),
  context_id: String.t() | nil,
  history: [A2A.Message.t()],
  metadata: map(),
  extensions: %{optional(String.t()) => A2A.Extension.activation()}
}

reply()

@type reply() ::
  {:reply, [A2A.Part.t()]}
  | {:message, [A2A.Part.t()]}
  | {:stream, Enumerable.t()}
  | {:input_required, [A2A.Part.t()]}
  | {:error, term()}

skill()

@type skill() :: %{
  id: String.t(),
  name: String.t(),
  description: String.t(),
  tags: [String.t()]
}

Callbacks

agent_card()

@callback agent_card() :: card()

Returns the agent's identity and capabilities.

handle_cancel(context)

@callback handle_cancel(context()) :: :ok | {:error, String.t()}

Called when a task is canceled by the caller. Optional.

handle_message(t, context)

@callback handle_message(A2A.Message.t(), context()) :: reply()

Handles an incoming message. This is the core agent logic.