keycardai-a2a 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85d721a792f798ee77ee42405e0c4222f3bceeca3c0174dfe6301e75ebbef9ee
4
- data.tar.gz: fae24b8dae14b2babe8a59c77633b43752d0b43e6312340bae8fcc135fdca822
3
+ metadata.gz: b1573597b5a3ee265b3a30b56b8e0681b70b5993aefd619da4f761efcd46eac1
4
+ data.tar.gz: a42a9591d4fdeaf4a409aad1b7b9a2f83ccf064f17ae8e739105d1ed70e318ea
5
5
  SHA512:
6
- metadata.gz: eb034049a79763b365a4939bfa02035ba2917fd0314777c543c7a0b7cdeeb3c7496747ad80131c8b56cdb5733beaec09cb07ee0aa3f0cb7b5e3aa8e8fed4ed70
7
- data.tar.gz: 8cae6a783cba822fd0cdeb62ec3ab848469dbdcaf856f300a687021cce488356690c1c3c15929fabc83204bd91edd2f8f7a20e9de658d70d6a96e84f186381ad
6
+ metadata.gz: fc0107c8c5b975c24518c9589e64d0d9afe0db248c1dc49a121fcd6deb3ad6dfd87150376eaab736a3bcbe091fbefad6e149fae8b3813e452af7ab44358eac64
7
+ data.tar.gz: 8281680b4456cc7066e404875ea766282b4d28adb2e1c2221933997748e73ffa950f2ed73ef4b7267ea75d09811577ef0d1ee1842b21f1694b668d851247c08e
data/CHANGELOG.md CHANGED
@@ -12,3 +12,31 @@ Agent-to-agent delegation: agent card discovery with caching, per-hop RFC 8693
12
12
  token exchange that keeps the user as the subject, and JSON-RPC `message/send`
13
13
  invocation. Wraps no A2A SDK; hosting an agent inside a framework is out of
14
14
  scope.
15
+
16
+ ## 0.2.0-keycardai-a2a (2026-09-07)
17
+
18
+
19
+ - feat(keycardai-a2a): speak A2A protocol 1.0 by default
20
+ - ECO-161 Ruby leg, after typescript-sdk #173 and go-sdk #47. DelegationClient sends 1.0-generation requests by default (SendMessage, A2A-Version: 1.0, ROLE_USER roles, untagged text parts, endpoint read from the card's JSONRPC supportedInterfaces entry) instead of the 0.3 envelope, so invoking a keycardai-a2a (Python) or @keycardai/a2a agent no longer returns MethodNotFound. protocol_version: now selects the whole wire generation through Keycardai::A2A::Wire: LEGACY_PROTOCOL_VERSION sends a real 0.3 envelope (role and part shapes translated on the way out), and any other value raises ArgumentError instead of being sent verbatim. A2A.text_message builds the 1.0 shape either way. The JSON-RPC result is returned as the agent sent it, the gem's existing contract.
21
+ - BREAKING CHANGE(keycardai-a2a): PROTOCOL_VERSION and MESSAGE_SEND_METHOD constant values moved to the 1.0 generation, and 0.3 interop requires protocol_version: LEGACY_PROTOCOL_VERSION.
22
+
23
+ ## 0.1.0-keycardai-a2a (2026-08-19)
24
+
25
+
26
+ - feat(keycardai-a2a): delegation client and agent card discovery (#10)
27
+ - Phase 3: the A2A delegation contract per specs/a2a/a2a-delegation.md,
28
+ matching the Go boundary (delegation client only; agent-framework
29
+ hosting glue is out of scope, and no community A2A gem dependency):
30
+ - - ServiceDiscovery: agent card fetch from /.well-known/agent-card.json
31
+ with a 15-minute refreshable cache; a card must carry a name
32
+ - DelegationClient: discover, RFC 8693 exchange (subject = the user
33
+ token, resource = the target agent, this agent authenticates with its
34
+ credential, no actor_token), then JSON-RPC message/send with the
35
+ exchanged bearer token and X-A2A-Protocol-Version 0.3; the invocation
36
+ endpoint is read from the card or derived by convention
37
+ - Typed DiscoveryError / InvocationError (with the JSON-RPC error
38
+ payload); exchange failures surface as the oauth OAuthError per hop
39
+ - A2A.text_message convenience for single-text-part params
40
+ - Conformance suite maps to all five spec unit rows, plus card-declared
41
+ endpoint and cache-TTL coverage; the multi-hop act-chain rows are the
42
+ integration table for the E2E phase.
data/README.md CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  Agent-to-agent delegation for Ruby agents on the Keycard platform.
4
4
 
5
- > **Preview.** APIs may change between minor versions while the surface settles.
6
5
  > Conformance against the cross-SDK contract is tracked in the
7
6
  > [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
8
7
 
@@ -19,8 +18,8 @@ Implements the delegation contract from
19
18
  2. **Exchange**: RFC 8693 token exchange, subject = the inbound user token,
20
19
  authenticated by the calling agent's credential; the user stays the subject
21
20
  and the authorization server appends the caller to the `act` chain
22
- 3. **Invoke**: JSON-RPC `message/send` against the target with the exchanged
23
- token as the bearer credential
21
+ 3. **Invoke**: JSON-RPC `SendMessage` (A2A protocol 1.0) against the target
22
+ with the exchanged token as the bearer credential
24
23
 
25
24
  Hosting an agent inside a specific agent framework is out of scope, matching
26
25
  the Go SDK's boundary. This gem wraps no A2A SDK.
@@ -49,10 +48,21 @@ result.agent_card # the card that was discovered on the way
49
48
  ```
50
49
 
51
50
  One call covers all three steps: fetch and cache agent B's card, exchange the
52
- inbound token for one scoped to B, then send JSON-RPC `message/send` with the
51
+ inbound token for one scoped to B, then send JSON-RPC `SendMessage` with the
53
52
  exchanged token as the bearer credential. The user stays the subject across the
54
53
  hop; agent B verifies a token whose `sub` is the original user, not this agent.
55
54
 
55
+ ### Protocol version
56
+
57
+ The client speaks A2A protocol 1.0 by default: `SendMessage`, an `A2A-Version:
58
+ 1.0` header, `ROLE_USER` roles, untagged text parts, and the invocation URL read
59
+ from the card's JSONRPC `supportedInterfaces` entry. Agents still on protocol
60
+ 0.3 do not interoperate with the default; to reach one, pass
61
+ `protocol_version: Keycardai::A2A::LEGACY_PROTOCOL_VERSION`. That switches the
62
+ whole envelope generation (`message/send`, `X-A2A-Protocol-Version: 0.3`,
63
+ `user` roles, `kind`-tagged parts), so `Keycardai::A2A.text_message` output is
64
+ still the input either way. Any other value raises `ArgumentError`.
65
+
56
66
  Failures are typed by stage, so you can tell "B is unreachable" from "the zone
57
67
  refused the exchange":
58
68
 
@@ -24,7 +24,12 @@ module Keycardai
24
24
  # @param http_client [#get, #post_form, #post_json] pluggable transport
25
25
  # @param discovery [ServiceDiscovery, nil] card resolution override
26
26
  # @param invoke_timeout [Numeric, nil] JSON-RPC call timeout
27
- # @param protocol_version [String] sent as X-A2A-Protocol-Version
27
+ # @param protocol_version [String] the A2A generation to speak: PROTOCOL_VERSION
28
+ # (1.0: SendMessage, A2A-Version header, ROLE_USER roles, untagged text parts)
29
+ # or LEGACY_PROTOCOL_VERSION (0.3: message/send, X-A2A-Protocol-Version header,
30
+ # "user" roles, kind-tagged parts). Build the message with A2A.text_message
31
+ # either way; the client translates it for 0.3.
32
+ # @raise [ArgumentError] protocol_version is neither supported generation
28
33
  def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
29
34
  http_client: OAuth::HTTP::NetHTTPClient.new, discovery: nil,
30
35
  invoke_timeout: nil, protocol_version: PROTOCOL_VERSION)
@@ -34,15 +39,17 @@ module Keycardai
34
39
  @discovery = discovery || ServiceDiscovery.new(http_client: http_client)
35
40
  @http_client = http_client
36
41
  @invoke_timeout = invoke_timeout
37
- @protocol_version = protocol_version
42
+ @wire = Wire.for(protocol_version)
38
43
  end
39
44
 
40
45
  # Delegate a call to another agent: discover, exchange, invoke.
41
46
  #
42
47
  # @param target [String] the downstream agent's base URL
43
48
  # @param subject_token [String] the inbound user's verified access token
44
- # @param message [Hash] the A2A message/send params
45
- # @return [Result]
49
+ # @param message [Hash] the A2A SendMessage params (see A2A.text_message)
50
+ # @return [Result] message is the JSON-RPC result as the agent returned it:
51
+ # for a 1.0 agent an object with a +message+ or +task+ key
52
+ # @raise [ArgumentError] protocol_version is unsupported
46
53
  # @raise [DiscoveryError] the agent card cannot be resolved
47
54
  # @raise [Keycardai::OAuth::OAuthError] the exchange was rejected
48
55
  # @raise [InvocationError] the JSON-RPC call failed
@@ -55,17 +62,18 @@ module Keycardai
55
62
 
56
63
  private
57
64
 
58
- # The invocation endpoint: read from the card when it names one,
59
- # otherwise derived by convention from the target base URL.
65
+ # The invocation endpoint: read from the card when it names one (a 1.0
66
+ # card's JSONRPC interface or a 0.3 card's url), otherwise derived by
67
+ # convention from the target base URL.
60
68
  def jsonrpc_url(target, card)
61
- card["url"].is_a?(String) && !card["url"].empty? ? card["url"] : "#{target.chomp("/")}#{JSONRPC_PATH}"
69
+ @wire.endpoint_from(card) || "#{target.chomp("/")}#{JSONRPC_PATH}"
62
70
  end
63
71
 
64
72
  def post_jsonrpc(url, access_token, message)
65
- payload = { "jsonrpc" => "2.0", "id" => SecureRandom.uuid, "method" => MESSAGE_SEND_METHOD,
66
- "params" => message }
73
+ payload = { "jsonrpc" => "2.0", "id" => SecureRandom.uuid, "method" => @wire.method_name,
74
+ "params" => @wire.encode_params(message) }
67
75
  headers = { "Accept" => "application/json", "Authorization" => "Bearer #{access_token}",
68
- "X-A2A-Protocol-Version" => @protocol_version }
76
+ @wire.header => @wire.version }
69
77
  response = begin
70
78
  @http_client.post_json(url, payload, headers: headers, timeout: @invoke_timeout)
71
79
  rescue OAuth::NetworkError => e
@@ -91,16 +99,17 @@ module Keycardai
91
99
  end
92
100
  end
93
101
 
94
- # Build A2A message/send params carrying one text part, the common case
95
- # for driving a downstream agent.
102
+ # Build A2A 1.0 SendMessage params carrying one text part, the common case
103
+ # for driving a downstream agent. A client configured for 0.3 translates
104
+ # the role and part shape on the way out.
96
105
  #
97
106
  # @param text [String]
98
107
  # @return [Hash]
99
108
  def self.text_message(text)
100
109
  {
101
110
  "message" => {
102
- "role" => "user",
103
- "parts" => [{ "kind" => "text", "text" => text }],
111
+ "role" => ROLE_USER,
112
+ "parts" => [{ "text" => text }],
104
113
  "messageId" => SecureRandom.uuid
105
114
  }
106
115
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module A2A
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module A2A
5
+ # A2A protocol version spoken by default: the 1.0 generation, which is
6
+ # what keycardai-a2a (Python) and @keycardai/a2a (TypeScript) serve.
7
+ PROTOCOL_VERSION = "1.0"
8
+
9
+ # The 0.3 generation, for agents not yet on 1.0. Pass it as
10
+ # DelegationClient's protocol_version: to send 0.3-shaped requests.
11
+ LEGACY_PROTOCOL_VERSION = "0.3"
12
+
13
+ # JSON-RPC method that delivers a message to a 1.0 agent.
14
+ MESSAGE_SEND_METHOD = "SendMessage"
15
+
16
+ # JSON-RPC method that delivers a message to a 0.3 agent.
17
+ LEGACY_MESSAGE_SEND_METHOD = "message/send"
18
+
19
+ # Header carrying the protocol version on a 1.0 request.
20
+ PROTOCOL_VERSION_HEADER = "A2A-Version"
21
+
22
+ # Header carrying the protocol version on a 0.3 request.
23
+ LEGACY_PROTOCOL_VERSION_HEADER = "X-A2A-Protocol-Version"
24
+
25
+ # Message roles as the 1.0 protocol names them (0.3 used "user"/"agent").
26
+ ROLE_USER = "ROLE_USER"
27
+ ROLE_AGENT = "ROLE_AGENT"
28
+
29
+ # One protocol generation's wire conventions: the JSON-RPC method, the
30
+ # version header, and how message roles and text parts are spelled. The
31
+ # version selects the whole envelope, never only the header.
32
+ class Wire
33
+ LEGACY_ROLES = { ROLE_USER => "user", ROLE_AGENT => "agent" }.freeze
34
+
35
+ # @param version [String] PROTOCOL_VERSION or LEGACY_PROTOCOL_VERSION
36
+ # @raise [ArgumentError] any other value
37
+ def self.for(version)
38
+ case version
39
+ when PROTOCOL_VERSION
40
+ new(version: version, header: PROTOCOL_VERSION_HEADER, method_name: MESSAGE_SEND_METHOD)
41
+ when LEGACY_PROTOCOL_VERSION
42
+ new(version: version, header: LEGACY_PROTOCOL_VERSION_HEADER, method_name: LEGACY_MESSAGE_SEND_METHOD)
43
+ else
44
+ raise ArgumentError, "unsupported A2A protocol version #{version.inspect} " \
45
+ "(supported: #{PROTOCOL_VERSION}, #{LEGACY_PROTOCOL_VERSION})"
46
+ end
47
+ end
48
+
49
+ attr_reader :version, :header, :method_name
50
+
51
+ def initialize(version:, header:, method_name:)
52
+ @version = version
53
+ @header = header
54
+ @method_name = method_name
55
+ end
56
+
57
+ def legacy?
58
+ version == LEGACY_PROTOCOL_VERSION
59
+ end
60
+
61
+ # Encode 1.0-shaped params (as built by A2A.text_message) for the wire.
62
+ # On 1.0 they pass through; on 0.3 the message role takes its 0.3 name
63
+ # and text parts gain the kind tag 0.3 requires.
64
+ def encode_params(params)
65
+ message = params.is_a?(Hash) ? params["message"] : nil
66
+ return params unless legacy? && message.is_a?(Hash)
67
+
68
+ params.merge("message" => message.merge(
69
+ "role" => LEGACY_ROLES.fetch(message["role"], message["role"]),
70
+ "parts" => Array(message["parts"]).map { |part| legacy_part(part) }
71
+ ))
72
+ end
73
+
74
+ # The invocation endpoint from an agent card: a 1.0 card's JSONRPC
75
+ # interface (preferring the one matching this version), else a 0.3
76
+ # card's url, else nil.
77
+ def endpoint_from(card)
78
+ interfaces = Array(card["supportedInterfaces"]).select { |iface| jsonrpc_interface?(iface) }
79
+ matching = interfaces.find { |iface| iface["protocolVersion"] == version } || interfaces.first
80
+ return matching["url"] if matching
81
+
82
+ card["url"] if card["url"].is_a?(String) && !card["url"].empty?
83
+ end
84
+
85
+ private
86
+
87
+ def legacy_part(part)
88
+ return part unless part.is_a?(Hash) && part.key?("text") && !part.key?("kind")
89
+
90
+ { "kind" => "text" }.merge(part)
91
+ end
92
+
93
+ def jsonrpc_interface?(iface)
94
+ iface.is_a?(Hash) && iface["protocolBinding"].to_s.casecmp?("JSONRPC") &&
95
+ iface["url"].is_a?(String) && !iface["url"].empty?
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/keycardai/a2a.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "keycardai/oauth"
4
4
  require_relative "a2a/version"
5
5
  require_relative "a2a/errors"
6
+ require_relative "a2a/wire"
6
7
  require_relative "a2a/service_discovery"
7
8
  require_relative "a2a/delegation_client"
8
9
 
@@ -21,10 +22,7 @@ module Keycardai
21
22
  # JSON-RPC invocation path, relative to an agent's base URL.
22
23
  JSONRPC_PATH = "/a2a/jsonrpc"
23
24
 
24
- # JSON-RPC method used to deliver a message to an agent.
25
- MESSAGE_SEND_METHOD = "message/send"
26
-
27
- # A2A protocol version sent with each invocation.
28
- PROTOCOL_VERSION = "0.3"
25
+ # The protocol-generation constants (PROTOCOL_VERSION, MESSAGE_SEND_METHOD,
26
+ # ROLE_USER, and their LEGACY_ 0.3 counterparts) live in a2a/wire.rb.
29
27
  end
30
28
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycardai-a2a
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard
@@ -40,6 +40,7 @@ files:
40
40
  - lib/keycardai/a2a/errors.rb
41
41
  - lib/keycardai/a2a/service_discovery.rb
42
42
  - lib/keycardai/a2a/version.rb
43
+ - lib/keycardai/a2a/wire.rb
43
44
  homepage: https://github.com/keycardai/ruby-sdk
44
45
  licenses:
45
46
  - MIT