Skip to content

fix(json): honor rich %A2A.AgentCard{} fields in encode_agent_card/2#52

Open
zeroasterisk wants to merge 1 commit into
actioncard:mainfrom
zeroasterisk:fix/encode-agent-card-spec
Open

fix(json): honor rich %A2A.AgentCard{} fields in encode_agent_card/2#52
zeroasterisk wants to merge 1 commit into
actioncard:mainfrom
zeroasterisk:fix/encode-agent-card-spec

Conversation

@zeroasterisk

Copy link
Copy Markdown
Contributor

Split out from #41 per review — single-scope, independently mergeable.

What

encode_agent_card/2 is typed to accept an A2A.AgentCard.t(), but it only reads name, description, version and skills from the card. Every other field — capabilities, provider, security_schemes, supported_interfaces, signatures, documentation_url, icon_url — was sourced exclusively from opts. Passing a fully-populated %A2A.AgentCard{} silently dropped those values.

card = %A2A.AgentCard{name: "x", description: "d", url: u, version: "1.0.0",
                      skills: [...], capabilities: %{streaming: true},
                      provider: %{organization: "Acme", url: "..."}}

A2A.JSON.encode_agent_card(card, url: u)
# before: capabilities => %{}, provider absent  (struct values dropped)
# after:  capabilities => %{"streaming" => true}, provider => %{...}

Fix

Each rich field is now resolved opts-first (explicit override), then from the card struct/map, then the default. So:

  • A fully-populated %A2A.AgentCard{} round-trips correctly.
  • Every existing opts-based caller (A2A.Plug, A2A.get_agent_card/2) is unchanged — opts still win.

Backward compatible: all 119 existing json_test cases pass untouched; 2 new tests cover struct-sourced fields and opts-override precedence.

Verification

  • mix test — 496 tests + 2 doctests, 0 failures
  • mix format --check-formatted — clean
  • mix credo — no issues
  • mix dialyzer — 0 errors

Note on the one-line version

The minimal change discussed in #41 was just the spec line:

@spec encode_agent_card(A2A.Agent.card(), keyword()) :: map()
# -> @spec encode_agent_card(A2A.AgentCard.t(), keyword()) :: map()

That alone wouldn't actually be correct: it advertises that the function consumes an A2A.AgentCard.t(), but the body ignores all of the struct's rich fields (they come from opts), so a caller passing a populated struct gets a card with those fields silently dropped — and dialyzer stays green either way, so it wouldn't catch the gap. This PR makes the code honor the type instead of just relabeling it. Happy to trim to the spec-only change if you'd prefer that scope — let me know which you'd rather merge.

encode_agent_card/2 was typed to accept A2A.AgentCard.t() but only read
name/description/version/skills from the card — capabilities, provider,
security_schemes, supported_interfaces, signatures, documentation_url and
icon_url were sourced exclusively from opts. Passing a fully-populated
%A2A.AgentCard{} silently dropped those fields.

Resolve each rich field from opts first (explicit override, backward
compatible), then fall back to the card struct/map. The A2A.AgentCard.t()
spec is now accurate: a populated struct round-trips correctly while every
existing opts-based caller (A2A.Plug, A2A.get_agent_card) is unchanged.

Adds tests for struct-sourced fields and opts-override precedence.
@github-actions

Copy link
Copy Markdown

TCK 1.0-dev Compatibility Results (experimental)

This run is informational — failures do not block CI.

             A2A TCK Compatibility Report              
═══════════════════════════════════════════════════════
SUT: http://localhost:9999
Timestamp: 2026-06-11T13:03:51.943300+00:00

OVERALL COMPATIBILITY: 71.6%

┌─────────────┬────────┬────────┬─────────┬───────┐
│ Level       │ Passed │ Failed │ Skipped │ Total │
├─────────────┼────────┼────────┼─────────┼───────┤
│ MUST        │     44 │     35 │      35 │   114 │
│ SHOULD      │      2 │      9 │       0 │    11 │
│ MAY         │      2 │      2 │       0 │     4 │
└─────────────┴────────┴────────┴─────────┴───────┘

BY TRANSPORT:
  agent_card:    8/10 ⚠
  grpc:          0/72 (72 skipped) ✓
  jsonrpc:       50/100 (30 skipped) ⚠
  http_json:     3/83 (80 skipped) ✓

FAILED REQUIREMENTS:
  ✗ CARD-CACHE-002 (agent_card): Agent Card response should include an ETag header
  ✗ CARD-CACHE-003 (agent_card): Agent Card response may include a Last-Modified header
  ✗ DM-ART-001 (): Response contains no artifacts
  ✗ DM-MSG-001 (): Expected a Message response, but got a Task or no payload
  ✗ JSONRPC-SSE-002 (): Error code mismatch: expected ContentTypeNotSupportedError (-32005), got ParseError (-32700)
  ✗ JSONRPC-ERR-003 (): error.data is absent — A2A errors MUST include ErrorInfo in data array
  ✗ CORE-MULTI-004 (jsonrpc): Expected error code -32001 (TaskNotFoundError), got -32603
  ✗ CORE-HIST-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-003 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-004 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-005 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-006 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-GET-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-CANCEL-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-CANCEL-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-SEND-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-MULTI-005 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")

@zeroasterisk

Copy link
Copy Markdown
Contributor Author

Filed #53 for the broader follow-up: consolidating on %A2A.AgentCard{} as the single canonical card shape and deprecating the untyped opts grab-bag in the author-facing card. It can be done backward-compatibly with pattern-matched function heads (struct vs legacy loose map), so existing agents keep working. There's one open design question there (the struct enforces :url but authors don't know their public URL at definition time) worth your input before implementing. This PR stays scoped to the immediate fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant