A2A.Agent behaviour (A2A v0.2.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 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
  • {: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

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 artifactsstatus.message is left empty.

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()
}

reply()

@type reply() ::
  {:reply, [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.