keycardai-a2a 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 85d721a792f798ee77ee42405e0c4222f3bceeca3c0174dfe6301e75ebbef9ee
4
+ data.tar.gz: fae24b8dae14b2babe8a59c77633b43752d0b43e6312340bae8fcc135fdca822
5
+ SHA512:
6
+ metadata.gz: eb034049a79763b365a4939bfa02035ba2917fd0314777c543c7a0b7cdeeb3c7496747ad80131c8b56cdb5733beaec09cb07ee0aa3f0cb7b5e3aa8e8fed4ed70
7
+ data.tar.gz: 8cae6a783cba822fd0cdeb62ec3ab848469dbdcaf856f300a687021cce488356690c1c3c15929fabc83204bd91edd2f8f7a20e9de658d70d6a96e84f186381ad
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ Entries from 0.2.0 onward are generated by commitizen on each bump. This first
4
+ one is written by hand because 0.1.0 is published from a baseline tag rather
5
+ than from a bump.
6
+
7
+ ## 0.1.0
8
+
9
+ Initial release.
10
+
11
+ Agent-to-agent delegation: agent card discovery with caching, per-hop RFC 8693
12
+ token exchange that keeps the user as the subject, and JSON-RPC `message/send`
13
+ invocation. Wraps no A2A SDK; hosting an agent inside a framework is out of
14
+ scope.
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT LICENSE
2
+
3
+ Copyright © 2026 Keycard Labs, inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # keycardai-a2a
2
+
3
+ Agent-to-agent delegation for Ruby agents on the Keycard platform.
4
+
5
+ > **Preview.** APIs may change between minor versions while the surface settles.
6
+ > Conformance against the cross-SDK contract is tracked in the
7
+ > [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
8
+
9
+ ```sh
10
+ bundle add keycardai-a2a
11
+ ```
12
+
13
+ Implements the delegation contract from
14
+ [keycard-sdk-spec](https://github.com/keycardai/keycard-sdk-spec)
15
+ (`specs/a2a/a2a-delegation.md`):
16
+
17
+ 1. **Discover**: fetch and cache the target agent's card from
18
+ `/.well-known/agent-card.json`
19
+ 2. **Exchange**: RFC 8693 token exchange, subject = the inbound user token,
20
+ authenticated by the calling agent's credential; the user stays the subject
21
+ 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
24
+
25
+ Hosting an agent inside a specific agent framework is out of scope, matching
26
+ the Go SDK's boundary. This gem wraps no A2A SDK.
27
+
28
+ ## Quickstart
29
+
30
+ ### Call another agent on the user's behalf
31
+
32
+ ```ruby
33
+ require "keycardai/a2a"
34
+
35
+ delegation = Keycardai::A2A::DelegationClient.new(
36
+ issuer: ENV.fetch("KEYCARD_URL"),
37
+ client_id: ENV.fetch("KEYCARD_CLIENT_ID"),
38
+ client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"),
39
+ )
40
+
41
+ result = delegation.invoke(
42
+ target: "https://agent-b.example.com",
43
+ subject_token: inbound_user_token,
44
+ message: Keycardai::A2A.text_message("summarize today's incidents"),
45
+ )
46
+
47
+ result.message # the JSON-RPC result from agent B
48
+ result.agent_card # the card that was discovered on the way
49
+ ```
50
+
51
+ 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
53
+ exchanged token as the bearer credential. The user stays the subject across the
54
+ hop; agent B verifies a token whose `sub` is the original user, not this agent.
55
+
56
+ Failures are typed by stage, so you can tell "B is unreachable" from "the zone
57
+ refused the exchange":
58
+
59
+ ```ruby
60
+ begin
61
+ delegation.invoke(...)
62
+ rescue Keycardai::A2A::DiscoveryError => e # no resolvable agent card
63
+ rescue Keycardai::OAuth::OAuthError => e # the zone rejected the exchange
64
+ rescue Keycardai::A2A::InvocationError => e # B was reached and failed
65
+ end
66
+ ```
67
+
68
+ ### Discover a card without invoking
69
+
70
+ ```ruby
71
+ discovery = Keycardai::A2A::ServiceDiscovery.new
72
+ card = discovery.get_card("https://agent-b.example.com")
73
+ card["url"]
74
+ ```
75
+
76
+ Cards are cached for 15 minutes by default (`cache_ttl:`). `refresh` forces a
77
+ fetch, `clear_cache` drops everything. Passing the same `ServiceDiscovery` into
78
+ `DelegationClient.new(discovery:)` shares one cache across both paths.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Keycardai
7
+ # The A2A delegation client and its result type.
8
+ module A2A
9
+ # The result of a delegated invocation: the target's JSON-RPC result plus
10
+ # the agent card it was discovered through.
11
+ Result = Data.define(:message, :agent_card)
12
+
13
+ # Calls another agent on the user's behalf, carrying the user's identity
14
+ # through the hop: discover the target's agent card, exchange the inbound
15
+ # user token for one scoped to the target (RFC 8693; the user stays the
16
+ # subject and the authorization server records this agent in the act
17
+ # chain), and invoke the target's JSON-RPC endpoint with the exchanged
18
+ # token as the bearer credential.
19
+ class DelegationClient
20
+ # @param issuer [String] the zone where exchanges are performed
21
+ # @param credential [Object, nil] this agent's application credential
22
+ # @param client_id [String, nil] shared-secret pair alternative
23
+ # @param client_secret [String, nil]
24
+ # @param http_client [#get, #post_form, #post_json] pluggable transport
25
+ # @param discovery [ServiceDiscovery, nil] card resolution override
26
+ # @param invoke_timeout [Numeric, nil] JSON-RPC call timeout
27
+ # @param protocol_version [String] sent as X-A2A-Protocol-Version
28
+ def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
29
+ http_client: OAuth::HTTP::NetHTTPClient.new, discovery: nil,
30
+ invoke_timeout: nil, protocol_version: PROTOCOL_VERSION)
31
+ @exchange = OAuth::TokenExchangeClient.new(issuer: issuer, credential: credential,
32
+ client_id: client_id, client_secret: client_secret,
33
+ http_client: http_client)
34
+ @discovery = discovery || ServiceDiscovery.new(http_client: http_client)
35
+ @http_client = http_client
36
+ @invoke_timeout = invoke_timeout
37
+ @protocol_version = protocol_version
38
+ end
39
+
40
+ # Delegate a call to another agent: discover, exchange, invoke.
41
+ #
42
+ # @param target [String] the downstream agent's base URL
43
+ # @param subject_token [String] the inbound user's verified access token
44
+ # @param message [Hash] the A2A message/send params
45
+ # @return [Result]
46
+ # @raise [DiscoveryError] the agent card cannot be resolved
47
+ # @raise [Keycardai::OAuth::OAuthError] the exchange was rejected
48
+ # @raise [InvocationError] the JSON-RPC call failed
49
+ def invoke(target:, subject_token:, message:)
50
+ card = @discovery.get_card(target)
51
+ token = @exchange.exchange_token(subject_token: subject_token, resource: target.chomp("/"))
52
+ result = post_jsonrpc(jsonrpc_url(target, card), token.access_token, message)
53
+ Result.new(message: result, agent_card: card)
54
+ end
55
+
56
+ private
57
+
58
+ # The invocation endpoint: read from the card when it names one,
59
+ # otherwise derived by convention from the target base URL.
60
+ def jsonrpc_url(target, card)
61
+ card["url"].is_a?(String) && !card["url"].empty? ? card["url"] : "#{target.chomp("/")}#{JSONRPC_PATH}"
62
+ end
63
+
64
+ def post_jsonrpc(url, access_token, message)
65
+ payload = { "jsonrpc" => "2.0", "id" => SecureRandom.uuid, "method" => MESSAGE_SEND_METHOD,
66
+ "params" => message }
67
+ headers = { "Accept" => "application/json", "Authorization" => "Bearer #{access_token}",
68
+ "X-A2A-Protocol-Version" => @protocol_version }
69
+ response = begin
70
+ @http_client.post_json(url, payload, headers: headers, timeout: @invoke_timeout)
71
+ rescue OAuth::NetworkError => e
72
+ raise InvocationError, "invocation of #{url} failed: #{e.message}"
73
+ end
74
+ raise InvocationError, "invocation of #{url} returned HTTP #{response.status}" unless response.success?
75
+
76
+ parse_jsonrpc(url, response.body)
77
+ end
78
+
79
+ def parse_jsonrpc(url, body)
80
+ document = begin
81
+ JSON.parse(body)
82
+ rescue JSON::ParserError
83
+ raise InvocationError, "invocation of #{url} returned invalid JSON"
84
+ end
85
+ if document["error"]
86
+ raise InvocationError.new("invocation of #{url} returned a JSON-RPC error: #{document["error"]["message"]}",
87
+ rpc_error: document["error"])
88
+ end
89
+
90
+ document["result"]
91
+ end
92
+ end
93
+
94
+ # Build A2A message/send params carrying one text part, the common case
95
+ # for driving a downstream agent.
96
+ #
97
+ # @param text [String]
98
+ # @return [Hash]
99
+ def self.text_message(text)
100
+ {
101
+ "message" => {
102
+ "role" => "user",
103
+ "parts" => [{ "kind" => "text", "text" => text }],
104
+ "messageId" => SecureRandom.uuid
105
+ }
106
+ }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module A2A
5
+ # The target agent's card cannot be fetched, is not valid JSON, or is
6
+ # missing the required name.
7
+ class DiscoveryError < Keycardai::Error; end
8
+
9
+ # The JSON-RPC invocation returned an HTTP error or a JSON-RPC error
10
+ # response. Carries the JSON-RPC error payload when one was returned.
11
+ class InvocationError < Keycardai::Error
12
+ # @return [Hash, nil] the JSON-RPC error object, when present
13
+ attr_reader :rpc_error
14
+
15
+ def initialize(message, rpc_error: nil)
16
+ super(message)
17
+ @rpc_error = rpc_error
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Keycardai
6
+ module A2A
7
+ # Resolves an agent's base URL to its agent card, fetched from
8
+ # /.well-known/agent-card.json and cached (default 15 minutes,
9
+ # refreshable). A card must carry a name to be valid.
10
+ class ServiceDiscovery
11
+ DEFAULT_CACHE_TTL = 900
12
+
13
+ # @param http_client [#get] pluggable transport
14
+ # @param cache_ttl [Numeric] card cache lifetime in seconds
15
+ # @param timeout [Numeric, nil] fetch timeout
16
+ # @param clock [#call] returns the current Time; override in tests
17
+ def initialize(http_client: OAuth::HTTP::NetHTTPClient.new, cache_ttl: DEFAULT_CACHE_TTL,
18
+ timeout: nil, clock: -> { Time.now })
19
+ @http_client = http_client
20
+ @cache_ttl = cache_ttl
21
+ @timeout = timeout
22
+ @clock = clock
23
+ @cards = {}
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # The agent card for a base URL, served from cache while fresh.
28
+ #
29
+ # @param base_url [String] the agent's base URL
30
+ # @return [Hash] the agent card
31
+ # @raise [DiscoveryError]
32
+ def get_card(base_url)
33
+ key = base_url.chomp("/")
34
+ cached = @mutex.synchronize do
35
+ entry = @cards[key]
36
+ entry[:card] if entry && @clock.call - entry[:fetched_at] <= @cache_ttl
37
+ end
38
+ return cached if cached
39
+
40
+ refresh(key)
41
+ end
42
+
43
+ # Fetch a fresh card, replacing any cached one.
44
+ #
45
+ # @param base_url [String]
46
+ # @return [Hash]
47
+ # @raise [DiscoveryError]
48
+ def refresh(base_url)
49
+ key = base_url.chomp("/")
50
+ card = fetch_card(key)
51
+ @mutex.synchronize { @cards[key] = { card: card, fetched_at: @clock.call } }
52
+ card
53
+ end
54
+
55
+ # Drop all cached cards.
56
+ #
57
+ # @return [void]
58
+ def clear_cache
59
+ @mutex.synchronize { @cards.clear }
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_card(base_url)
65
+ url = "#{base_url}#{AGENT_CARD_PATH}"
66
+ response = begin
67
+ @http_client.get(url, headers: { "Accept" => "application/json" }, timeout: @timeout)
68
+ rescue OAuth::NetworkError => e
69
+ raise DiscoveryError, "agent card fetch from #{url} failed: #{e.message}"
70
+ end
71
+ raise DiscoveryError, "agent card fetch from #{url} returned HTTP #{response.status}" unless response.success?
72
+
73
+ parse_card(url, response.body)
74
+ end
75
+
76
+ def parse_card(url, body)
77
+ card = begin
78
+ JSON.parse(body)
79
+ rescue JSON::ParserError
80
+ raise DiscoveryError, "agent card from #{url} is not valid JSON"
81
+ end
82
+ unless card.is_a?(Hash) && card["name"].is_a?(String) && !card["name"].empty?
83
+ raise DiscoveryError, "agent card from #{url} is missing the required name"
84
+ end
85
+
86
+ card
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module A2A
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keycardai/oauth"
4
+ require_relative "a2a/version"
5
+ require_relative "a2a/errors"
6
+ require_relative "a2a/service_discovery"
7
+ require_relative "a2a/delegation_client"
8
+
9
+ module Keycardai
10
+ # Agent-to-agent delegation: one agent calls another on the user's behalf.
11
+ # Discover the target's agent card, exchange the user's token for one scoped
12
+ # to the target (RFC 8693; the user stays the subject, the authorization
13
+ # server records the caller in the token's act chain), and invoke the target
14
+ # over JSON-RPC.
15
+ #
16
+ # Contract: https://github.com/keycardai/keycard-sdk-spec
17
+ module A2A
18
+ # Well-known path of an agent's card, relative to its base URL.
19
+ AGENT_CARD_PATH = "/.well-known/agent-card.json"
20
+
21
+ # JSON-RPC invocation path, relative to an agent's base URL.
22
+ JSONRPC_PATH = "/a2a/jsonrpc"
23
+
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"
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keycardai-a2a
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Keycard
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: keycardai-oauth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ description: 'The A2A delegation contract: agent card discovery, per-hop RFC 8693
27
+ token exchange carrying the user''s identity, and JSON-RPC invocation. Framework
28
+ glue for hosting agents is out of scope.'
29
+ email:
30
+ - support@keycard.ai
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - lib/keycardai/a2a.rb
39
+ - lib/keycardai/a2a/delegation_client.rb
40
+ - lib/keycardai/a2a/errors.rb
41
+ - lib/keycardai/a2a/service_discovery.rb
42
+ - lib/keycardai/a2a/version.rb
43
+ homepage: https://github.com/keycardai/ruby-sdk
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ rubygems_mfa_required: 'true'
48
+ source_code_uri: https://github.com/keycardai/ruby-sdk/tree/main/a2a
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.2'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Agent-to-agent delegation with Keycard
66
+ test_files: []