a2a-ruby 1.0.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +137 -0
- data/.simplecov +46 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +33 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/CONTRIBUTING.md +165 -0
- data/Gemfile +43 -0
- data/Guardfile +34 -0
- data/LICENSE.txt +21 -0
- data/PUBLISHING_CHECKLIST.md +214 -0
- data/README.md +171 -0
- data/Rakefile +165 -0
- data/docs/agent_execution.md +309 -0
- data/docs/api_reference.md +792 -0
- data/docs/configuration.md +780 -0
- data/docs/events.md +475 -0
- data/docs/getting_started.md +668 -0
- data/docs/integration.md +262 -0
- data/docs/server_apps.md +621 -0
- data/docs/troubleshooting.md +765 -0
- data/lib/a2a/client/api_methods.rb +263 -0
- data/lib/a2a/client/auth/api_key.rb +161 -0
- data/lib/a2a/client/auth/interceptor.rb +288 -0
- data/lib/a2a/client/auth/jwt.rb +189 -0
- data/lib/a2a/client/auth/oauth2.rb +146 -0
- data/lib/a2a/client/auth.rb +137 -0
- data/lib/a2a/client/base.rb +316 -0
- data/lib/a2a/client/config.rb +210 -0
- data/lib/a2a/client/connection_pool.rb +233 -0
- data/lib/a2a/client/http_client.rb +524 -0
- data/lib/a2a/client/json_rpc_handler.rb +136 -0
- data/lib/a2a/client/middleware/circuit_breaker_interceptor.rb +245 -0
- data/lib/a2a/client/middleware/logging_interceptor.rb +371 -0
- data/lib/a2a/client/middleware/rate_limit_interceptor.rb +142 -0
- data/lib/a2a/client/middleware/retry_interceptor.rb +161 -0
- data/lib/a2a/client/middleware.rb +116 -0
- data/lib/a2a/client/performance_tracker.rb +60 -0
- data/lib/a2a/configuration/defaults.rb +34 -0
- data/lib/a2a/configuration/environment_loader.rb +76 -0
- data/lib/a2a/configuration/file_loader.rb +115 -0
- data/lib/a2a/configuration/inheritance.rb +101 -0
- data/lib/a2a/configuration/validator.rb +180 -0
- data/lib/a2a/configuration.rb +201 -0
- data/lib/a2a/errors.rb +291 -0
- data/lib/a2a/modules.rb +50 -0
- data/lib/a2a/monitoring/alerting.rb +490 -0
- data/lib/a2a/monitoring/distributed_tracing.rb +398 -0
- data/lib/a2a/monitoring/health_endpoints.rb +204 -0
- data/lib/a2a/monitoring/metrics_collector.rb +438 -0
- data/lib/a2a/monitoring.rb +463 -0
- data/lib/a2a/plugin.rb +358 -0
- data/lib/a2a/plugin_manager.rb +159 -0
- data/lib/a2a/plugins/example_auth.rb +81 -0
- data/lib/a2a/plugins/example_middleware.rb +118 -0
- data/lib/a2a/plugins/example_transport.rb +76 -0
- data/lib/a2a/protocol/agent_card.rb +8 -0
- data/lib/a2a/protocol/agent_card_server.rb +584 -0
- data/lib/a2a/protocol/capability.rb +496 -0
- data/lib/a2a/protocol/json_rpc.rb +254 -0
- data/lib/a2a/protocol/message.rb +8 -0
- data/lib/a2a/protocol/task.rb +8 -0
- data/lib/a2a/rails/a2a_controller.rb +258 -0
- data/lib/a2a/rails/controller_helpers.rb +499 -0
- data/lib/a2a/rails/engine.rb +167 -0
- data/lib/a2a/rails/generators/agent_generator.rb +311 -0
- data/lib/a2a/rails/generators/install_generator.rb +209 -0
- data/lib/a2a/rails/generators/migration_generator.rb +232 -0
- data/lib/a2a/rails/generators/templates/add_a2a_indexes.rb +57 -0
- data/lib/a2a/rails/generators/templates/agent_controller.rb +122 -0
- data/lib/a2a/rails/generators/templates/agent_controller_spec.rb +160 -0
- data/lib/a2a/rails/generators/templates/agent_readme.md +200 -0
- data/lib/a2a/rails/generators/templates/create_a2a_push_notification_configs.rb +68 -0
- data/lib/a2a/rails/generators/templates/create_a2a_tasks.rb +83 -0
- data/lib/a2a/rails/generators/templates/example_agent_controller.rb +228 -0
- data/lib/a2a/rails/generators/templates/initializer.rb +108 -0
- data/lib/a2a/rails/generators/templates/push_notification_config_model.rb +228 -0
- data/lib/a2a/rails/generators/templates/task_model.rb +200 -0
- data/lib/a2a/rails/tasks/a2a.rake +228 -0
- data/lib/a2a/server/a2a_methods.rb +520 -0
- data/lib/a2a/server/agent.rb +537 -0
- data/lib/a2a/server/agent_execution/agent_executor.rb +279 -0
- data/lib/a2a/server/agent_execution/request_context.rb +219 -0
- data/lib/a2a/server/apps/rack_app.rb +311 -0
- data/lib/a2a/server/apps/sinatra_app.rb +261 -0
- data/lib/a2a/server/default_request_handler.rb +350 -0
- data/lib/a2a/server/events/event_consumer.rb +116 -0
- data/lib/a2a/server/events/event_queue.rb +226 -0
- data/lib/a2a/server/example_agent.rb +248 -0
- data/lib/a2a/server/handler.rb +281 -0
- data/lib/a2a/server/middleware/authentication_middleware.rb +212 -0
- data/lib/a2a/server/middleware/cors_middleware.rb +171 -0
- data/lib/a2a/server/middleware/logging_middleware.rb +362 -0
- data/lib/a2a/server/middleware/rate_limit_middleware.rb +382 -0
- data/lib/a2a/server/middleware.rb +213 -0
- data/lib/a2a/server/push_notification_manager.rb +327 -0
- data/lib/a2a/server/request_handler.rb +136 -0
- data/lib/a2a/server/storage/base.rb +141 -0
- data/lib/a2a/server/storage/database.rb +266 -0
- data/lib/a2a/server/storage/memory.rb +274 -0
- data/lib/a2a/server/storage/redis.rb +320 -0
- data/lib/a2a/server/storage.rb +38 -0
- data/lib/a2a/server/task_manager.rb +534 -0
- data/lib/a2a/transport/grpc.rb +481 -0
- data/lib/a2a/transport/http.rb +415 -0
- data/lib/a2a/transport/sse.rb +499 -0
- data/lib/a2a/types/agent_card.rb +540 -0
- data/lib/a2a/types/artifact.rb +99 -0
- data/lib/a2a/types/base_model.rb +223 -0
- data/lib/a2a/types/events.rb +117 -0
- data/lib/a2a/types/message.rb +106 -0
- data/lib/a2a/types/part.rb +288 -0
- data/lib/a2a/types/push_notification.rb +139 -0
- data/lib/a2a/types/security.rb +167 -0
- data/lib/a2a/types/task.rb +154 -0
- data/lib/a2a/types.rb +88 -0
- data/lib/a2a/utils/helpers.rb +245 -0
- data/lib/a2a/utils/message_buffer.rb +278 -0
- data/lib/a2a/utils/performance.rb +247 -0
- data/lib/a2a/utils/rails_detection.rb +97 -0
- data/lib/a2a/utils/structured_logger.rb +306 -0
- data/lib/a2a/utils/time_helpers.rb +167 -0
- data/lib/a2a/utils/validation.rb +8 -0
- data/lib/a2a/version.rb +6 -0
- data/lib/a2a-rails.rb +58 -0
- data/lib/a2a.rb +198 -0
- metadata +437 -0
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Example custom transport plugin
|
5
|
+
#
|
6
|
+
# Demonstrates how to create a custom transport plugin
|
7
|
+
# for the A2A plugin architecture.
|
8
|
+
#
|
9
|
+
module A2A
|
10
|
+
module Plugins
|
11
|
+
class ExampleTransport < A2A::Plugin::TransportPlugin
|
12
|
+
# Transport name for identification
|
13
|
+
def transport_name
|
14
|
+
"EXAMPLE"
|
15
|
+
end
|
16
|
+
|
17
|
+
# Send request implementation
|
18
|
+
# @param request [Hash] Request data
|
19
|
+
# @param **options [Hash] Transport options
|
20
|
+
# @return [Hash] Response
|
21
|
+
def send_request(request, **_options)
|
22
|
+
logger&.info("Sending request via Example Transport: #{request[:method]}")
|
23
|
+
|
24
|
+
# Simulate request processing
|
25
|
+
{
|
26
|
+
jsonrpc: "2.0",
|
27
|
+
result: { message: "Response from Example Transport" },
|
28
|
+
id: request[:id]
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
# This transport supports streaming
|
33
|
+
def supports_streaming?
|
34
|
+
true
|
35
|
+
end
|
36
|
+
|
37
|
+
# Create streaming connection
|
38
|
+
# @param **options [Hash] Connection options
|
39
|
+
# @return [Enumerator] Stream enumerator
|
40
|
+
def create_stream(**_options)
|
41
|
+
Enumerator.new do |yielder|
|
42
|
+
5.times do |i|
|
43
|
+
yielder << {
|
44
|
+
event: "data",
|
45
|
+
data: { message: "Stream message #{i + 1}" }
|
46
|
+
}
|
47
|
+
sleep(0.1) # Simulate streaming delay
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Register hooks for this plugin
|
53
|
+
def register_hooks(plugin_manager)
|
54
|
+
plugin_manager.add_hook(A2A::Plugin::Events::BEFORE_REQUEST) do |request|
|
55
|
+
logger&.debug("Example Transport: Processing request #{request[:id]}")
|
56
|
+
request[:transport_metadata] = { plugin: "example_transport" }
|
57
|
+
end
|
58
|
+
|
59
|
+
plugin_manager.add_hook(A2A::Plugin::Events::AFTER_RESPONSE) do |response, request|
|
60
|
+
logger&.debug("Example Transport: Processed response for #{request[:id]}")
|
61
|
+
response
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def setup
|
68
|
+
logger&.info("Example Transport plugin initialized")
|
69
|
+
end
|
70
|
+
|
71
|
+
def cleanup
|
72
|
+
logger&.info("Example Transport plugin cleaned up")
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,584 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module A2A
|
7
|
+
module Protocol
|
8
|
+
##
|
9
|
+
# Serves agent cards with caching and automatic generation
|
10
|
+
#
|
11
|
+
# The agent card server provides HTTP endpoints for agent card discovery,
|
12
|
+
# supports multiple output formats, and includes configurable caching.
|
13
|
+
#
|
14
|
+
# @example Basic usage
|
15
|
+
# server = A2A::Protocol::AgentCardServer.new
|
16
|
+
# server.configure do |config|
|
17
|
+
# config.cache_ttl = 300 # 5 minutes
|
18
|
+
# config.enable_signatures = true
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# # Register capabilities
|
22
|
+
# server.capability_registry.register(capability)
|
23
|
+
#
|
24
|
+
# # Generate agent card
|
25
|
+
# card = server.generate_card(
|
26
|
+
# name: "My Agent",
|
27
|
+
# description: "A helpful agent"
|
28
|
+
# )
|
29
|
+
#
|
30
|
+
class AgentCardServer
|
31
|
+
attr_reader :capability_registry, :config
|
32
|
+
|
33
|
+
##
|
34
|
+
# Configuration for the agent card server
|
35
|
+
#
|
36
|
+
class Config
|
37
|
+
attr_accessor :cache_ttl, :enable_signatures, :signing_key, :signing_algorithm,
|
38
|
+
:default_protocol_version, :enable_authenticated_extended_cards,
|
39
|
+
:card_modification_callback
|
40
|
+
|
41
|
+
def initialize
|
42
|
+
@cache_ttl = 300 # 5 minutes
|
43
|
+
@enable_signatures = false
|
44
|
+
@signing_key = nil
|
45
|
+
@signing_algorithm = "RS256"
|
46
|
+
@default_protocol_version = "1.0"
|
47
|
+
@enable_authenticated_extended_cards = false
|
48
|
+
@card_modification_callback = nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Initialize a new agent card server
|
54
|
+
#
|
55
|
+
# @param capability_registry [CapabilityRegistry, nil] Optional registry
|
56
|
+
def initialize(capability_registry: nil)
|
57
|
+
@capability_registry = capability_registry || CapabilityRegistry.new
|
58
|
+
@config = Config.new
|
59
|
+
@cache = {}
|
60
|
+
@cache_timestamps = {}
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Configure the server
|
65
|
+
#
|
66
|
+
# @yield [config] Configuration block
|
67
|
+
# @yieldparam config [Config] The configuration object
|
68
|
+
def configure
|
69
|
+
yield(@config) if block_given?
|
70
|
+
end
|
71
|
+
|
72
|
+
##
|
73
|
+
# Generate an agent card from registered capabilities
|
74
|
+
#
|
75
|
+
# @param name [String] Agent name
|
76
|
+
# @param description [String] Agent description
|
77
|
+
# @param version [String] Agent version
|
78
|
+
# @param url [String] Primary agent URL
|
79
|
+
# @param preferred_transport [String] Preferred transport
|
80
|
+
# @param additional_options [Hash] Additional card options
|
81
|
+
# @return [A2A::Types::AgentCard] The generated agent card
|
82
|
+
def generate_card(name:, description:, url:, version: "1.0.0",
|
83
|
+
preferred_transport: "JSONRPC", **additional_options)
|
84
|
+
# Convert capabilities to skills
|
85
|
+
skills = @capability_registry.all.map { |cap| capability_to_skill(cap) }
|
86
|
+
|
87
|
+
# Determine capabilities from registry
|
88
|
+
capabilities = determine_capabilities
|
89
|
+
|
90
|
+
# Build default input/output modes from skills
|
91
|
+
input_modes = determine_input_modes(skills)
|
92
|
+
output_modes = determine_output_modes(skills)
|
93
|
+
|
94
|
+
# Create the agent card
|
95
|
+
card_options = {
|
96
|
+
name: name,
|
97
|
+
description: description,
|
98
|
+
version: version,
|
99
|
+
url: url,
|
100
|
+
preferred_transport: preferred_transport,
|
101
|
+
skills: skills,
|
102
|
+
capabilities: capabilities,
|
103
|
+
default_input_modes: input_modes,
|
104
|
+
default_output_modes: output_modes,
|
105
|
+
protocol_version: @config.default_protocol_version,
|
106
|
+
supports_authenticated_extended_card: @config.enable_authenticated_extended_cards
|
107
|
+
}.merge(additional_options)
|
108
|
+
|
109
|
+
card = A2A::Types::AgentCard.new(**card_options)
|
110
|
+
|
111
|
+
# Add signatures if enabled
|
112
|
+
if @config.enable_signatures && @config.signing_key
|
113
|
+
signatures = [generate_signature(card)]
|
114
|
+
card = A2A::Types::AgentCard.new(**card_options, signatures: signatures)
|
115
|
+
end
|
116
|
+
|
117
|
+
card
|
118
|
+
end
|
119
|
+
|
120
|
+
##
|
121
|
+
# Get agent card with caching
|
122
|
+
#
|
123
|
+
# @param cache_key [String] Cache key for the card
|
124
|
+
# @param card_params [Hash] Parameters for card generation
|
125
|
+
# @return [A2A::Types::AgentCard] The agent card
|
126
|
+
def get_card(cache_key: "default", **card_params)
|
127
|
+
# Check cache
|
128
|
+
if (cached_card = get_from_cache(cache_key))
|
129
|
+
return cached_card
|
130
|
+
end
|
131
|
+
|
132
|
+
# Generate new card
|
133
|
+
card = generate_card(**card_params)
|
134
|
+
|
135
|
+
# Cache the card
|
136
|
+
store_in_cache(cache_key, card)
|
137
|
+
|
138
|
+
card
|
139
|
+
end
|
140
|
+
|
141
|
+
##
|
142
|
+
# Get authenticated extended agent card
|
143
|
+
#
|
144
|
+
# This method allows for dynamic modification of the agent card
|
145
|
+
# based on the authentication context.
|
146
|
+
#
|
147
|
+
# @param auth_context [Hash] Authentication context
|
148
|
+
# @param base_card_params [Hash] Base card parameters
|
149
|
+
# @return [A2A::Types::AgentCard] The extended agent card
|
150
|
+
def get_authenticated_extended_card(auth_context: {}, **base_card_params)
|
151
|
+
unless @config.enable_authenticated_extended_cards
|
152
|
+
raise A2A::Errors::A2AError.new(
|
153
|
+
"Authenticated extended cards are not enabled",
|
154
|
+
code: -32_001
|
155
|
+
)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Generate base card
|
159
|
+
card = generate_card(**base_card_params)
|
160
|
+
|
161
|
+
# Apply modifications based on auth context
|
162
|
+
card = @config.card_modification_callback.call(card, auth_context) if @config.card_modification_callback
|
163
|
+
|
164
|
+
card
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
# Serve agent card as HTTP response data
|
169
|
+
#
|
170
|
+
# @param format [String] Output format ('json' or 'jws')
|
171
|
+
# @param cache_key [String] Cache key
|
172
|
+
# @param card_params [Hash] Card generation parameters
|
173
|
+
# @return [Hash] HTTP response data with headers and body
|
174
|
+
def serve_card(format: "json", cache_key: "default", **card_params)
|
175
|
+
card = get_card(cache_key: cache_key, **card_params)
|
176
|
+
|
177
|
+
case format.downcase
|
178
|
+
when "json"
|
179
|
+
{
|
180
|
+
status: 200,
|
181
|
+
headers: {
|
182
|
+
"Content-Type" => "application/json",
|
183
|
+
"Cache-Control" => "max-age=#{@config.cache_ttl}"
|
184
|
+
},
|
185
|
+
body: card.to_json
|
186
|
+
}
|
187
|
+
when "jws"
|
188
|
+
if @config.enable_signatures && @config.signing_key
|
189
|
+
jws_token = generate_jws_token(card)
|
190
|
+
{
|
191
|
+
status: 200,
|
192
|
+
headers: {
|
193
|
+
"Content-Type" => "application/jose+json",
|
194
|
+
"Cache-Control" => "max-age=#{@config.cache_ttl}"
|
195
|
+
},
|
196
|
+
body: jws_token
|
197
|
+
}
|
198
|
+
else
|
199
|
+
{
|
200
|
+
status: 400,
|
201
|
+
headers: { "Content-Type" => "application/json" },
|
202
|
+
body: { error: "JWS signing not configured" }.to_json
|
203
|
+
}
|
204
|
+
end
|
205
|
+
else
|
206
|
+
{
|
207
|
+
status: 400,
|
208
|
+
headers: { "Content-Type" => "application/json" },
|
209
|
+
body: { error: "Unsupported format: #{format}" }.to_json
|
210
|
+
}
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
##
|
215
|
+
# Clear cache
|
216
|
+
#
|
217
|
+
# @param cache_key [String, nil] Specific key to clear, or nil for all
|
218
|
+
def clear_cache(cache_key = nil)
|
219
|
+
if cache_key
|
220
|
+
@cache.delete(cache_key)
|
221
|
+
@cache_timestamps.delete(cache_key)
|
222
|
+
else
|
223
|
+
@cache.clear
|
224
|
+
@cache_timestamps.clear
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
##
|
229
|
+
# Get cache statistics
|
230
|
+
#
|
231
|
+
# @return [Hash] Cache statistics
|
232
|
+
def cache_stats
|
233
|
+
{
|
234
|
+
entries: @cache.size,
|
235
|
+
keys: @cache.keys,
|
236
|
+
oldest_entry: @cache_timestamps.values.min,
|
237
|
+
newest_entry: @cache_timestamps.values.max
|
238
|
+
}
|
239
|
+
end
|
240
|
+
|
241
|
+
private
|
242
|
+
|
243
|
+
##
|
244
|
+
# Convert a capability to an agent skill
|
245
|
+
#
|
246
|
+
# @param capability [Capability] The capability to convert
|
247
|
+
# @return [A2A::Types::AgentSkill] The converted skill
|
248
|
+
def capability_to_skill(capability)
|
249
|
+
A2A::Types::AgentSkill.new(
|
250
|
+
id: capability.name,
|
251
|
+
name: capability.name.split("_").map(&:capitalize).join(" "),
|
252
|
+
description: capability.description,
|
253
|
+
tags: capability.tags,
|
254
|
+
examples: capability.examples,
|
255
|
+
input_modes: determine_capability_input_modes(capability),
|
256
|
+
output_modes: determine_capability_output_modes(capability),
|
257
|
+
security: capability.security_requirements
|
258
|
+
)
|
259
|
+
end
|
260
|
+
|
261
|
+
##
|
262
|
+
# Determine agent capabilities from registry
|
263
|
+
#
|
264
|
+
# @return [A2A::Types::AgentCapabilities] The agent capabilities
|
265
|
+
def determine_capabilities
|
266
|
+
streaming = @capability_registry.all.any?(&:streaming?)
|
267
|
+
push_notifications = false # TODO: Determine from server config
|
268
|
+
state_history = false # TODO: Determine from server config
|
269
|
+
extensions = [] # TODO: Collect from capabilities
|
270
|
+
|
271
|
+
A2A::Types::AgentCapabilities.new(
|
272
|
+
streaming: streaming,
|
273
|
+
push_notifications: push_notifications,
|
274
|
+
state_transition_history: state_history,
|
275
|
+
extensions: extensions
|
276
|
+
)
|
277
|
+
end
|
278
|
+
|
279
|
+
##
|
280
|
+
# Determine input modes from skills
|
281
|
+
#
|
282
|
+
# @param skills [Array<A2A::Types::AgentSkill>] The skills
|
283
|
+
# @return [Array<String>] Input modes
|
284
|
+
def determine_input_modes(skills)
|
285
|
+
modes = skills.flat_map { |skill| skill.input_modes || ["text"] }.uniq
|
286
|
+
modes.empty? ? ["text"] : modes
|
287
|
+
end
|
288
|
+
|
289
|
+
##
|
290
|
+
# Determine output modes from skills
|
291
|
+
#
|
292
|
+
# @param skills [Array<A2A::Types::AgentSkill>] The skills
|
293
|
+
# @return [Array<String>] Output modes
|
294
|
+
def determine_output_modes(skills)
|
295
|
+
modes = skills.flat_map { |skill| skill.output_modes || ["text"] }.uniq
|
296
|
+
modes.empty? ? ["text"] : modes
|
297
|
+
end
|
298
|
+
|
299
|
+
##
|
300
|
+
# Determine input modes for a capability
|
301
|
+
#
|
302
|
+
# @param capability [Capability] The capability
|
303
|
+
# @return [Array<String>] Input modes
|
304
|
+
def determine_capability_input_modes(capability)
|
305
|
+
# Analyze input schema to determine modes
|
306
|
+
return ["text"] unless capability.input_schema
|
307
|
+
|
308
|
+
modes = ["text"] # Default to text
|
309
|
+
|
310
|
+
# Check if schema accepts file inputs
|
311
|
+
modes << "file" if schema_accepts_files?(capability.input_schema)
|
312
|
+
|
313
|
+
# Check if schema accepts structured data
|
314
|
+
modes << "data" if schema_accepts_data?(capability.input_schema)
|
315
|
+
|
316
|
+
modes.uniq
|
317
|
+
end
|
318
|
+
|
319
|
+
##
|
320
|
+
# Determine output modes for a capability
|
321
|
+
#
|
322
|
+
# @param capability [Capability] The capability
|
323
|
+
# @return [Array<String>] Output modes
|
324
|
+
def determine_capability_output_modes(capability)
|
325
|
+
# Analyze output schema to determine modes
|
326
|
+
return ["text"] unless capability.output_schema
|
327
|
+
|
328
|
+
modes = ["text"] # Default to text
|
329
|
+
|
330
|
+
# Check if schema produces files
|
331
|
+
modes << "file" if schema_produces_files?(capability.output_schema)
|
332
|
+
|
333
|
+
# Check if schema produces structured data
|
334
|
+
modes << "data" if schema_produces_data?(capability.output_schema)
|
335
|
+
|
336
|
+
modes.uniq
|
337
|
+
end
|
338
|
+
|
339
|
+
##
|
340
|
+
# Check if schema accepts file inputs
|
341
|
+
#
|
342
|
+
# @param schema [Hash] The JSON schema
|
343
|
+
# @return [Boolean] True if files are accepted
|
344
|
+
def schema_accepts_files?(schema)
|
345
|
+
# Simple heuristic: look for file-related properties
|
346
|
+
return false unless schema.is_a?(Hash)
|
347
|
+
|
348
|
+
properties = schema[:properties] || schema["properties"] || {}
|
349
|
+
properties.any? do |name, prop_schema|
|
350
|
+
name.to_s.include?("file") ||
|
351
|
+
(prop_schema.is_a?(Hash) && prop_schema[:format] == "binary")
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
##
|
356
|
+
# Check if schema accepts structured data
|
357
|
+
#
|
358
|
+
# @param schema [Hash] The JSON schema
|
359
|
+
# @return [Boolean] True if structured data is accepted
|
360
|
+
def schema_accepts_data?(schema)
|
361
|
+
return false unless schema.is_a?(Hash)
|
362
|
+
|
363
|
+
type = schema[:type] || schema["type"]
|
364
|
+
%w[object array].include?(type)
|
365
|
+
end
|
366
|
+
|
367
|
+
##
|
368
|
+
# Check if schema produces files
|
369
|
+
#
|
370
|
+
# @param schema [Hash] The JSON schema
|
371
|
+
# @return [Boolean] True if files are produced
|
372
|
+
def schema_produces_files?(schema)
|
373
|
+
schema_accepts_files?(schema) # Same logic for now
|
374
|
+
end
|
375
|
+
|
376
|
+
##
|
377
|
+
# Check if schema produces structured data
|
378
|
+
#
|
379
|
+
# @param schema [Hash] The JSON schema
|
380
|
+
# @return [Boolean] True if structured data is produced
|
381
|
+
def schema_produces_data?(schema)
|
382
|
+
schema_accepts_data?(schema) # Same logic for now
|
383
|
+
end
|
384
|
+
|
385
|
+
##
|
386
|
+
# Get card from cache if valid
|
387
|
+
#
|
388
|
+
# @param cache_key [String] The cache key
|
389
|
+
# @return [A2A::Types::AgentCard, nil] Cached card or nil
|
390
|
+
def get_from_cache(cache_key)
|
391
|
+
return nil unless @cache.key?(cache_key)
|
392
|
+
|
393
|
+
timestamp = @cache_timestamps[cache_key]
|
394
|
+
return nil unless timestamp
|
395
|
+
|
396
|
+
# Check if cache entry is still valid
|
397
|
+
if Time.now - timestamp < @config.cache_ttl
|
398
|
+
@cache[cache_key]
|
399
|
+
else
|
400
|
+
# Remove expired entry
|
401
|
+
@cache.delete(cache_key)
|
402
|
+
@cache_timestamps.delete(cache_key)
|
403
|
+
nil
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
##
|
408
|
+
# Store card in cache
|
409
|
+
#
|
410
|
+
# @param cache_key [String] The cache key
|
411
|
+
# @param card [A2A::Types::AgentCard] The card to cache
|
412
|
+
def store_in_cache(cache_key, card)
|
413
|
+
@cache[cache_key] = card
|
414
|
+
@cache_timestamps[cache_key] = Time.now
|
415
|
+
end
|
416
|
+
|
417
|
+
##
|
418
|
+
# Generate a JWS signature for the agent card
|
419
|
+
#
|
420
|
+
# @param card [A2A::Types::AgentCard] The card to sign
|
421
|
+
# @return [A2A::Types::AgentCardSignature] The signature
|
422
|
+
def generate_signature(_card)
|
423
|
+
# This is a placeholder implementation
|
424
|
+
# In a real implementation, you would use a proper JWT library
|
425
|
+
|
426
|
+
header = {
|
427
|
+
alg: @config.signing_algorithm,
|
428
|
+
typ: "JWT"
|
429
|
+
}
|
430
|
+
|
431
|
+
require "base64"
|
432
|
+
header_b64 = Base64.urlsafe_encode64(header.to_json).delete("=")
|
433
|
+
|
434
|
+
# Placeholder signature (in real implementation, sign with private key)
|
435
|
+
signature_b64 = Base64.urlsafe_encode64("signature_#{Time.now.to_i}").delete("=")
|
436
|
+
|
437
|
+
A2A::Types::AgentCardSignature.new(
|
438
|
+
signature: signature_b64,
|
439
|
+
protected_header: header_b64
|
440
|
+
)
|
441
|
+
end
|
442
|
+
|
443
|
+
##
|
444
|
+
# Generate a complete JWS token for the agent card
|
445
|
+
#
|
446
|
+
# @param card [A2A::Types::AgentCard] The card to sign
|
447
|
+
# @return [String] The JWS token
|
448
|
+
def generate_jws_token(card)
|
449
|
+
# This is a placeholder implementation
|
450
|
+
# In a real implementation, you would use a proper JWT library
|
451
|
+
|
452
|
+
header = {
|
453
|
+
alg: @config.signing_algorithm,
|
454
|
+
typ: "JWT"
|
455
|
+
}
|
456
|
+
|
457
|
+
payload = card.to_h
|
458
|
+
|
459
|
+
require "base64"
|
460
|
+
header_b64 = Base64.urlsafe_encode64(header.to_json).delete("=")
|
461
|
+
payload_b64 = Base64.urlsafe_encode64(payload.to_json).delete("=")
|
462
|
+
|
463
|
+
# Placeholder signature (in real implementation, sign with private key)
|
464
|
+
signature_b64 = Base64.urlsafe_encode64("signature_#{Time.now.to_i}").delete("=")
|
465
|
+
|
466
|
+
"#{header_b64}.#{payload_b64}.#{signature_b64}"
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
##
|
471
|
+
# HTTP endpoint handlers for agent card serving
|
472
|
+
#
|
473
|
+
# Provides Rack-compatible handlers for serving agent cards
|
474
|
+
# over HTTP with proper content negotiation and caching.
|
475
|
+
#
|
476
|
+
class AgentCardEndpoints
|
477
|
+
def initialize(server)
|
478
|
+
@server = server
|
479
|
+
end
|
480
|
+
|
481
|
+
##
|
482
|
+
# Handle agent card requests
|
483
|
+
#
|
484
|
+
# @param env [Hash] Rack environment
|
485
|
+
# @return [Array] Rack response [status, headers, body]
|
486
|
+
def call(env)
|
487
|
+
Rack::Request.new(env) if defined?(Rack::Request)
|
488
|
+
|
489
|
+
# Simple request parsing for non-Rack environments
|
490
|
+
method = env["REQUEST_METHOD"] || "GET"
|
491
|
+
path = env["PATH_INFO"] || env["REQUEST_URI"] || "/"
|
492
|
+
query_params = parse_query_string(env["QUERY_STRING"] || "")
|
493
|
+
|
494
|
+
case path
|
495
|
+
when "/agent-card", "/agent-card.json"
|
496
|
+
handle_agent_card_request(method, query_params)
|
497
|
+
when "/agent-card.jws"
|
498
|
+
handle_agent_card_jws_request(method, query_params)
|
499
|
+
when "/capabilities"
|
500
|
+
handle_capabilities_request(method, query_params)
|
501
|
+
else
|
502
|
+
[404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']]
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
private
|
507
|
+
|
508
|
+
##
|
509
|
+
# Handle agent card JSON requests
|
510
|
+
def handle_agent_card_request(method, params)
|
511
|
+
return method_not_allowed unless method == "GET"
|
512
|
+
|
513
|
+
response = @server.serve_card(
|
514
|
+
format: "json",
|
515
|
+
cache_key: params["cache_key"] || "default",
|
516
|
+
name: params["name"] || "Agent",
|
517
|
+
description: params["description"] || "An A2A agent",
|
518
|
+
version: params["version"] || "1.0.0",
|
519
|
+
url: params["url"] || "https://example.com/agent"
|
520
|
+
)
|
521
|
+
|
522
|
+
[response[:status], response[:headers], [response[:body]]]
|
523
|
+
end
|
524
|
+
|
525
|
+
##
|
526
|
+
# Handle agent card JWS requests
|
527
|
+
def handle_agent_card_jws_request(method, params)
|
528
|
+
return method_not_allowed unless method == "GET"
|
529
|
+
|
530
|
+
response = @server.serve_card(
|
531
|
+
format: "jws",
|
532
|
+
cache_key: params["cache_key"] || "default",
|
533
|
+
name: params["name"] || "Agent",
|
534
|
+
description: params["description"] || "An A2A agent",
|
535
|
+
version: params["version"] || "1.0.0",
|
536
|
+
url: params["url"] || "https://example.com/agent"
|
537
|
+
)
|
538
|
+
|
539
|
+
[response[:status], response[:headers], [response[:body]]]
|
540
|
+
end
|
541
|
+
|
542
|
+
##
|
543
|
+
# Handle capabilities listing requests
|
544
|
+
def handle_capabilities_request(method, _params)
|
545
|
+
return method_not_allowed unless method == "GET"
|
546
|
+
|
547
|
+
capabilities = @server.capability_registry.to_h
|
548
|
+
|
549
|
+
[
|
550
|
+
200,
|
551
|
+
{ "Content-Type" => "application/json" },
|
552
|
+
[capabilities.to_json]
|
553
|
+
]
|
554
|
+
end
|
555
|
+
|
556
|
+
##
|
557
|
+
# Parse query string into hash
|
558
|
+
def parse_query_string(query_string)
|
559
|
+
return {} if query_string.empty?
|
560
|
+
|
561
|
+
params = {}
|
562
|
+
query_string.split("&").each do |pair|
|
563
|
+
key, value = pair.split("=", 2)
|
564
|
+
next unless key
|
565
|
+
|
566
|
+
key = URI.decode_www_form_component(key) if defined?(URI.decode_www_form_component)
|
567
|
+
value = URI.decode_www_form_component(value || "") if defined?(URI.decode_www_form_component)
|
568
|
+
params[key] = value
|
569
|
+
end
|
570
|
+
params
|
571
|
+
end
|
572
|
+
|
573
|
+
##
|
574
|
+
# Return method not allowed response
|
575
|
+
def method_not_allowed
|
576
|
+
[
|
577
|
+
405,
|
578
|
+
{ "Content-Type" => "application/json" },
|
579
|
+
['{"error":"Method not allowed"}']
|
580
|
+
]
|
581
|
+
end
|
582
|
+
end
|
583
|
+
end
|
584
|
+
end
|