Skip to content

Consolidate on %A2A.AgentCard{} as the single canonical card shape (deprecate opts grab-bag) #53

@zeroasterisk

Description

@zeroasterisk

Summary

There are two redundant shapes for the same "agent card" concept, and the agent-author-facing one hides most A2A fields inside an untyped opts grab-bag. Proposing we consolidate on %A2A.AgentCard{} as the single canonical shape, transforming only at the JSON boundary — done in a backward-compatible way via pattern matching so existing loose-map agents keep working.

The two shapes today

1. A2A.Agent.card() — what the agent_card/0 callback returns (lib/a2a/agent.ex:138, :170):

@type card :: %{
        name: String.t(),
        description: String.t(),
        version: String.t(),
        skills: [skill()],
        opts: keyword()   # <- capabilities, provider, security_schemes, ... all hidden here, untyped
      }

2. %A2A.AgentCard{} — the decode/remote shape (lib/a2a/agent_card.ex), a fully-typed struct with all 16 A2A spec fields as first-class keys (capabilities, provider, security_schemes, supported_interfaces, signatures, etc.).

The opts keyword in shape 1 is a weaker, untyped second encoding of fields that shape 2 already models properly. An author defining an agent has no typed surface for capabilities/provider/security — they go into an opaque keyword list and are only interpreted later at encode time.

Proposal

Make %A2A.AgentCard{} the single canonical card shape:

  • agent_card/0 returns a %A2A.AgentCard{}.
  • It flows unchanged through :get_agent_cardA2A.RegistryA2A.JSON.encode_agent_card/2 → JSON, and the same struct type comes back from decode_agent_card/1 / over the wire.
  • Transformation happens only at the JSON edge (where encode_agent_card/decode_agent_card already live).

Backward compatibility via pattern matching

We do not need to break existing agents that return the loose map. Support both with multiple function heads, e.g.:

def encode_agent_card(%A2A.AgentCard{} = card, opts), do: ...        # new canonical shape
def encode_agent_card(%{name: _, description: _} = card, opts), do: ...  # legacy loose map

Same approach for :get_agent_card consumers / registry. New agents get the typed struct; old loose-map agents keep working untouched. The loose-map path can be documented as deprecated and removed in a later major.

Open design question

%A2A.AgentCard{} has @enforce_keys [:name, :description, :url, :version, :skills] (lib/a2a/agent_card.ex:61) — but an agent author doesn't know their own public URL at definition time; it's the deployment base_url, injected later at encode (A2A.get_agent_card/2 passes it as :url). This is very likely why the loose author-facing map exists.

Consolidating means picking one of:

  • relax @enforce_keys to drop :url (author cards may omit it; encode supplies it), or
  • make url injectable / overridable at encode time (encode's :url opt already wins), or
  • keep :url enforced and require authors to pass a placeholder.

Want maintainer direction on this before implementing.

Scope (if approved)

  • agent_card/0 @callback type → A2A.AgentCard.t() (lib/a2a/agent.ex:170)
  • __using__ default build_card_ast → emit %A2A.AgentCard{} (lib/a2a/agent.ex:398)
  • A2A.Registry specs ×3 (lib/a2a/registry.ex:50,68,95)
  • legacy loose-map heads + deprecation note
  • test agents

Context

Came out of #52, which fixes the immediate bug (a populated %A2A.AgentCard{} had its rich fields silently dropped by encode_agent_card/2). #52 is intentionally small and backward-compatible; this issue is the broader consistency follow-up.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions