simple_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 +7 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +192 -0
- data/Rakefile +13 -0
- data/docs/api/client/index.md +124 -0
- data/docs/api/index.md +27 -0
- data/docs/api/models/index.md +233 -0
- data/docs/api/server/index.md +162 -0
- data/docs/api/storage/index.md +84 -0
- data/docs/architecture/index.md +63 -0
- data/docs/architecture/protocol.md +112 -0
- data/docs/assets/css/custom.css +6 -0
- data/docs/examples/basic-usage.md +77 -0
- data/docs/examples/index.md +92 -0
- data/docs/examples/llm-research.md +92 -0
- data/docs/examples/streaming.md +81 -0
- data/docs/getting-started/installation.md +48 -0
- data/docs/getting-started/quick-start.md +100 -0
- data/docs/guides/custom-storage.md +69 -0
- data/docs/guides/push-notifications.md +104 -0
- data/docs/guides/streaming.md +75 -0
- data/docs/index.md +98 -0
- data/examples/01_basic_usage/client.rb +75 -0
- data/examples/01_basic_usage/server.rb +57 -0
- data/examples/02_streaming/client.rb +70 -0
- data/examples/02_streaming/server.rb +177 -0
- data/examples/03_llm_research/client.rb +138 -0
- data/examples/03_llm_research/run +82 -0
- data/examples/03_llm_research/server.rb +203 -0
- data/examples/03_llm_research/web_client.rb +501 -0
- data/examples/common_config.rb +4 -0
- data/examples/run +108 -0
- data/lib/simple_a2a/client/base.rb +101 -0
- data/lib/simple_a2a/client/sse.rb +58 -0
- data/lib/simple_a2a/errors.rb +15 -0
- data/lib/simple_a2a/json_rpc.rb +89 -0
- data/lib/simple_a2a/models/agent_capabilities.rb +11 -0
- data/lib/simple_a2a/models/agent_card.rb +23 -0
- data/lib/simple_a2a/models/agent_interface.rb +11 -0
- data/lib/simple_a2a/models/agent_provider.rb +11 -0
- data/lib/simple_a2a/models/agent_skill.rb +12 -0
- data/lib/simple_a2a/models/artifact.rb +23 -0
- data/lib/simple_a2a/models/authentication_info.rb +11 -0
- data/lib/simple_a2a/models/base.rb +111 -0
- data/lib/simple_a2a/models/message.rb +45 -0
- data/lib/simple_a2a/models/part.rb +45 -0
- data/lib/simple_a2a/models/push_notification_config.rb +17 -0
- data/lib/simple_a2a/models/security_scheme.rb +16 -0
- data/lib/simple_a2a/models/send_message_configuration.rb +12 -0
- data/lib/simple_a2a/models/stream_response.rb +32 -0
- data/lib/simple_a2a/models/task.rb +57 -0
- data/lib/simple_a2a/models/task_artifact_update_event.rb +21 -0
- data/lib/simple_a2a/models/task_status.rb +20 -0
- data/lib/simple_a2a/models/task_status_update_event.rb +19 -0
- data/lib/simple_a2a/models/types.rb +39 -0
- data/lib/simple_a2a/server/agent_executor.rb +16 -0
- data/lib/simple_a2a/server/app.rb +227 -0
- data/lib/simple_a2a/server/base.rb +43 -0
- data/lib/simple_a2a/server/context.rb +44 -0
- data/lib/simple_a2a/server/event_router.rb +50 -0
- data/lib/simple_a2a/server/falcon_runner.rb +31 -0
- data/lib/simple_a2a/server/multi_agent.rb +50 -0
- data/lib/simple_a2a/server/push_sender.rb +80 -0
- data/lib/simple_a2a/server/resume_context.rb +14 -0
- data/lib/simple_a2a/storage/base.rb +12 -0
- data/lib/simple_a2a/storage/memory.rb +41 -0
- data/lib/simple_a2a/version.rb +5 -0
- data/lib/simple_a2a.rb +49 -0
- data/mkdocs.yml +143 -0
- data/sig/simple_a2a.rbs +4 -0
- metadata +353 -0
data/examples/run
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Run a demo app end-to-end: starts the server, waits until it accepts
|
|
5
|
+
# connections, runs the client, then shuts the server down.
|
|
6
|
+
#
|
|
7
|
+
# Usage (from the project root):
|
|
8
|
+
# ruby examples/run 01_basic_usage
|
|
9
|
+
#
|
|
10
|
+
# Usage (from inside examples/):
|
|
11
|
+
# ./run 01_basic_usage
|
|
12
|
+
|
|
13
|
+
require "socket"
|
|
14
|
+
|
|
15
|
+
EXAMPLES_DIR = File.expand_path("..", __FILE__)
|
|
16
|
+
RUBY = RbConfig.ruby
|
|
17
|
+
SERVER_PORT = 9292
|
|
18
|
+
STARTUP_TIMEOUT = 15 # seconds
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Argument handling
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
name = ARGV.first&.chomp("/")
|
|
24
|
+
|
|
25
|
+
unless name
|
|
26
|
+
puts "Usage: run <demo-name>"
|
|
27
|
+
puts
|
|
28
|
+
puts "Available demos:"
|
|
29
|
+
Dir["#{EXAMPLES_DIR}/*/server.rb"].sort.each do |f|
|
|
30
|
+
puts " #{File.basename(File.dirname(f))}"
|
|
31
|
+
end
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
demo_dir = File.join(EXAMPLES_DIR, name)
|
|
36
|
+
|
|
37
|
+
abort "Demo not found: #{demo_dir}" unless File.directory?(demo_dir)
|
|
38
|
+
|
|
39
|
+
# If the demo has its own run script, delegate to it.
|
|
40
|
+
custom_runner = File.join(demo_dir, "run")
|
|
41
|
+
if File.exist?(custom_runner)
|
|
42
|
+
exec(RUBY, custom_runner, *ARGV.drop(1))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
server_rb = File.join(demo_dir, "server.rb")
|
|
46
|
+
client_rb = File.join(demo_dir, "client.rb")
|
|
47
|
+
|
|
48
|
+
abort "Missing server.rb in #{demo_dir}" unless File.exist?(server_rb)
|
|
49
|
+
abort "Missing client.rb in #{demo_dir}" unless File.exist?(client_rb)
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Helpers
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
def banner(text)
|
|
55
|
+
bar = "─" * (text.length + 4)
|
|
56
|
+
puts "┌#{bar}┐"
|
|
57
|
+
puts "│ #{text} │"
|
|
58
|
+
puts "└#{bar}┘"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def server_ready?(port)
|
|
62
|
+
TCPSocket.new("localhost", port).close
|
|
63
|
+
true
|
|
64
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def wait_for_server(port, timeout)
|
|
69
|
+
deadline = Time.now + timeout
|
|
70
|
+
sleep 0.05 until server_ready?(port) || Time.now > deadline
|
|
71
|
+
server_ready?(port)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Start server
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
banner "Starting #{name} — server"
|
|
78
|
+
server_pid = spawn(RUBY, server_rb, out: $stdout, err: $stderr)
|
|
79
|
+
|
|
80
|
+
unless wait_for_server(SERVER_PORT, STARTUP_TIMEOUT)
|
|
81
|
+
Process.kill("TERM", server_pid) rescue nil
|
|
82
|
+
abort "\nServer did not become ready within #{STARTUP_TIMEOUT}s — aborting."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "\n(server ready on port #{SERVER_PORT})\n\n"
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Run client
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
banner "Running #{name} — client"
|
|
91
|
+
begin
|
|
92
|
+
client_ok = system(RUBY, client_rb)
|
|
93
|
+
rescue Interrupt
|
|
94
|
+
client_ok = false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Shut down server
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
puts "\n(stopping server…)"
|
|
101
|
+
begin
|
|
102
|
+
Process.kill("TERM", server_pid)
|
|
103
|
+
Process.wait(server_pid)
|
|
104
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
105
|
+
# already gone
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
exit(client_ok ? 0 : 1)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/internet"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Client
|
|
8
|
+
class Base
|
|
9
|
+
def initialize(url:, headers: {})
|
|
10
|
+
@url = url
|
|
11
|
+
@headers = headers
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def agent_card
|
|
15
|
+
body = http_get("agentCard")
|
|
16
|
+
Models::AgentCard.from_hash(JSON.parse(body))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def send_task(message:, **opts)
|
|
20
|
+
result = rpc_call("tasks/send", build_send_params(message, opts))
|
|
21
|
+
Models::Task.from_hash(result)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_task(task_id)
|
|
25
|
+
result = rpc_call("tasks/get", { "id" => task_id })
|
|
26
|
+
Models::Task.from_hash(result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def list_tasks
|
|
30
|
+
result = rpc_call("tasks/list", {})
|
|
31
|
+
result.map { |t| Models::Task.from_hash(t) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cancel_task(task_id)
|
|
35
|
+
result = rpc_call("tasks/cancel", { "id" => task_id })
|
|
36
|
+
Models::Task.from_hash(result)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def rpc_call(method, params)
|
|
42
|
+
body = JSON.generate({
|
|
43
|
+
"jsonrpc" => "2.0",
|
|
44
|
+
"id" => SecureRandom.uuid,
|
|
45
|
+
"method" => method,
|
|
46
|
+
"params" => params
|
|
47
|
+
})
|
|
48
|
+
resp_body = http_post(body)
|
|
49
|
+
parsed = JSON.parse(resp_body)
|
|
50
|
+
raise A2A::Error, parsed["error"]["message"] if parsed["error"]
|
|
51
|
+
|
|
52
|
+
parsed["result"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def http_post(body)
|
|
56
|
+
run_async do |internet|
|
|
57
|
+
internet.post(@url, headers: rpc_headers, body: body).read
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def http_get(path)
|
|
62
|
+
url = [@url.chomp("/"), path].join("/")
|
|
63
|
+
run_async do |internet|
|
|
64
|
+
internet.get(url, headers: extra_headers).read
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_async(&block)
|
|
69
|
+
if Async::Task.current?
|
|
70
|
+
with_internet(&block)
|
|
71
|
+
else
|
|
72
|
+
Async { with_internet(&block) }.wait
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_internet
|
|
77
|
+
internet = Async::HTTP::Internet.new
|
|
78
|
+
yield internet
|
|
79
|
+
ensure
|
|
80
|
+
internet&.close
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def rpc_headers
|
|
84
|
+
{ "content-type" => "application/json" }.merge(extra_headers)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extra_headers
|
|
88
|
+
@headers.transform_keys(&:to_s).transform_values(&:to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_send_params(message, opts)
|
|
92
|
+
msg_hash = message.is_a?(Models::Message) ? message.to_h : message
|
|
93
|
+
params = { "message" => msg_hash }
|
|
94
|
+
params["id"] = opts[:task_id] if opts[:task_id]
|
|
95
|
+
params["contextId"] = opts[:context_id] if opts[:context_id]
|
|
96
|
+
params["metadata"] = opts[:metadata] if opts[:metadata]
|
|
97
|
+
params
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Client
|
|
5
|
+
class SSE < Base
|
|
6
|
+
EVENT_CLASSES = {
|
|
7
|
+
"TaskStatusUpdateEvent" => Models::TaskStatusUpdateEvent,
|
|
8
|
+
"TaskArtifactUpdateEvent" => Models::TaskArtifactUpdateEvent
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def send_subscribe(message:, **opts, &block)
|
|
12
|
+
body = JSON.generate({
|
|
13
|
+
"jsonrpc" => "2.0",
|
|
14
|
+
"id" => SecureRandom.uuid,
|
|
15
|
+
"method" => "tasks/sendSubscribe",
|
|
16
|
+
"params" => build_send_params(message, opts)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
run_async do |internet|
|
|
20
|
+
headers = rpc_headers.merge("accept" => "text/event-stream")
|
|
21
|
+
response = internet.post(@url, headers: headers, body: body)
|
|
22
|
+
parse_sse_stream(response, &block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse_sse_stream(response, &block)
|
|
29
|
+
buffer = +""
|
|
30
|
+
response.body.each do |chunk|
|
|
31
|
+
buffer << chunk
|
|
32
|
+
while (idx = buffer.index("\n\n"))
|
|
33
|
+
event_str = buffer[0, idx]
|
|
34
|
+
buffer = buffer[(idx + 2)..]
|
|
35
|
+
event = parse_sse_event(event_str)
|
|
36
|
+
block.call(event) if event
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_sse_event(event_str)
|
|
42
|
+
data = nil
|
|
43
|
+
event_str.each_line do |line|
|
|
44
|
+
data = line.chomp[6..] if line.start_with?("data: ")
|
|
45
|
+
end
|
|
46
|
+
return nil unless data
|
|
47
|
+
|
|
48
|
+
parsed = JSON.parse(data)
|
|
49
|
+
result = parsed["result"] || parsed
|
|
50
|
+
type_name = result["type"] || result["kind"]
|
|
51
|
+
klass = EVENT_CLASSES[type_name]
|
|
52
|
+
klass ? klass.from_hash(result) : result
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class TaskNotFoundError < Error; end
|
|
7
|
+
class TaskNotCancelableError < Error; end
|
|
8
|
+
class PushNotificationNotSupportedError < Error; end
|
|
9
|
+
class UnsupportedOperationError < Error; end
|
|
10
|
+
class ContentTypeNotSupportedError < Error; end
|
|
11
|
+
class InvalidAgentResponseError < Error; end
|
|
12
|
+
class ExtensionSupportRequiredError < Error; end
|
|
13
|
+
class VersionNotSupportedError < Error; end
|
|
14
|
+
class ExtendedAgentCardNotConfiguredError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module JsonRpc
|
|
5
|
+
# A2A-defined JSON-RPC error codes
|
|
6
|
+
module ErrorCode
|
|
7
|
+
PARSE_ERROR = -32700
|
|
8
|
+
INVALID_REQUEST = -32600
|
|
9
|
+
METHOD_NOT_FOUND = -32601
|
|
10
|
+
INVALID_PARAMS = -32602
|
|
11
|
+
INTERNAL_ERROR = -32603
|
|
12
|
+
|
|
13
|
+
TASK_NOT_FOUND = -32001
|
|
14
|
+
TASK_NOT_CANCELABLE = -32002
|
|
15
|
+
PUSH_NOT_SUPPORTED = -32003
|
|
16
|
+
UNSUPPORTED_OPERATION = -32004
|
|
17
|
+
CONTENT_TYPE_NOT_SUPPORTED = -32005
|
|
18
|
+
INVALID_AGENT_RESPONSE = -32006
|
|
19
|
+
EXTENSION_REQUIRED = -32007
|
|
20
|
+
VERSION_NOT_SUPPORTED = -32008
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Request
|
|
24
|
+
attr_reader :id, :method, :params
|
|
25
|
+
|
|
26
|
+
def initialize(id:, method:, params: nil)
|
|
27
|
+
@id = id
|
|
28
|
+
@method = method
|
|
29
|
+
@params = params
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.parse(json_string)
|
|
33
|
+
data = JSON.parse(json_string)
|
|
34
|
+
raise ParseError, "not a Hash" unless data.is_a?(Hash)
|
|
35
|
+
raise InvalidRequestError, "missing jsonrpc field" unless data["jsonrpc"] == "2.0"
|
|
36
|
+
raise InvalidRequestError, "missing method" unless data.key?("method")
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
id: data["id"],
|
|
40
|
+
method: data["method"],
|
|
41
|
+
params: data["params"]
|
|
42
|
+
)
|
|
43
|
+
rescue JSON::ParserError => e
|
|
44
|
+
raise ParseError, e.message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def notification? = id.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Response
|
|
51
|
+
def self.success(id:, result:)
|
|
52
|
+
JSON.generate({ "jsonrpc" => "2.0", "id" => id, "result" => result })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.error(id:, code:, message:, data: nil)
|
|
56
|
+
err = { "code" => code, "message" => message }
|
|
57
|
+
err["data"] = data if data
|
|
58
|
+
JSON.generate({ "jsonrpc" => "2.0", "id" => id, "error" => err })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.from_error(id:, error:)
|
|
62
|
+
code, msg = classify(error)
|
|
63
|
+
error(id: id, code: code, message: msg)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.classify(error)
|
|
67
|
+
case error
|
|
68
|
+
when TaskNotFoundError then [ErrorCode::TASK_NOT_FOUND, error.message || "Task not found"]
|
|
69
|
+
when TaskNotCancelableError then [ErrorCode::TASK_NOT_CANCELABLE, error.message || "Task not cancelable"]
|
|
70
|
+
when PushNotificationNotSupportedError then [ErrorCode::PUSH_NOT_SUPPORTED, error.message || "Push notifications not supported"]
|
|
71
|
+
when UnsupportedOperationError then [ErrorCode::UNSUPPORTED_OPERATION, error.message || "Unsupported operation"]
|
|
72
|
+
when ContentTypeNotSupportedError then [ErrorCode::CONTENT_TYPE_NOT_SUPPORTED, error.message || "Content type not supported"]
|
|
73
|
+
when InvalidAgentResponseError then [ErrorCode::INVALID_AGENT_RESPONSE, error.message || "Invalid agent response"]
|
|
74
|
+
when ExtensionSupportRequiredError then [ErrorCode::EXTENSION_REQUIRED, error.message || "Extension required"]
|
|
75
|
+
when VersionNotSupportedError then [ErrorCode::VERSION_NOT_SUPPORTED, error.message || "Version not supported"]
|
|
76
|
+
when ParseError then [ErrorCode::PARSE_ERROR, error.message || "Parse error"]
|
|
77
|
+
when InvalidRequestError then [ErrorCode::INVALID_REQUEST, error.message || "Invalid request"]
|
|
78
|
+
when InvalidParamsError then [ErrorCode::INVALID_PARAMS, error.message || "Invalid params"]
|
|
79
|
+
else [ErrorCode::INTERNAL_ERROR, error.message || "Internal error"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# JSON-RPC specific errors (not A2A domain errors)
|
|
85
|
+
class ParseError < A2A::Error; end
|
|
86
|
+
class InvalidRequestError < A2A::Error; end
|
|
87
|
+
class InvalidParamsError < A2A::Error; end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class AgentCard < Base
|
|
6
|
+
attribute :name, required: true
|
|
7
|
+
attribute :description
|
|
8
|
+
attribute :version, required: true
|
|
9
|
+
attribute :provider, type: AgentProvider
|
|
10
|
+
attribute :capabilities, type: AgentCapabilities, required: true
|
|
11
|
+
attribute :skills, type: [AgentSkill], default: -> { [] }, required: true
|
|
12
|
+
attribute :interfaces, type: [AgentInterface], default: -> { [] }, required: true
|
|
13
|
+
attribute :security_schemes, default: -> { [] }
|
|
14
|
+
attribute :security, default: -> { [] }
|
|
15
|
+
attribute :extensions, default: -> { [] }
|
|
16
|
+
|
|
17
|
+
def valid?
|
|
18
|
+
!name.nil? && !version.nil? && !capabilities.nil? &&
|
|
19
|
+
!skills.nil? && !interfaces.nil?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class Artifact < Base
|
|
6
|
+
attribute :artifact_id
|
|
7
|
+
attribute :name
|
|
8
|
+
attribute :description
|
|
9
|
+
attribute :parts, type: [Part], default: -> { [] }
|
|
10
|
+
attribute :metadata
|
|
11
|
+
attribute :extensions, default: -> { [] }
|
|
12
|
+
|
|
13
|
+
def initialize(**kwargs)
|
|
14
|
+
kwargs[:artifact_id] ||= SecureRandom.uuid
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid?
|
|
19
|
+
!parts.nil? && !parts.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class Base
|
|
6
|
+
class << self
|
|
7
|
+
def attribute(name, type: nil, default: nil, required: false)
|
|
8
|
+
attributes[name] = { type: type, default: default, required: required }
|
|
9
|
+
attr_accessor name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def attributes
|
|
13
|
+
@attributes ||= {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inherited(subclass)
|
|
17
|
+
super
|
|
18
|
+
subclass.instance_variable_set(:@attributes, attributes.dup)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def from_hash(hash)
|
|
22
|
+
return nil if hash.nil?
|
|
23
|
+
kwargs = {}
|
|
24
|
+
attributes.each do |name, opts|
|
|
25
|
+
val = find_value(hash, name)
|
|
26
|
+
next if val.nil?
|
|
27
|
+
kwargs[name] = coerce(val, opts[:type])
|
|
28
|
+
end
|
|
29
|
+
new(**kwargs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def find_value(hash, name)
|
|
35
|
+
camel = camelize(name)
|
|
36
|
+
hash[camel] || hash[camel.to_sym] || hash[name.to_s] || hash[name]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def camelize(snake)
|
|
40
|
+
parts = snake.to_s.split("_")
|
|
41
|
+
(parts[0..0] + parts[1..].map(&:capitalize)).join
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def coerce(val, type)
|
|
45
|
+
return val if type.nil?
|
|
46
|
+
|
|
47
|
+
if type.is_a?(Array)
|
|
48
|
+
item_type = type[0]
|
|
49
|
+
return val unless val.is_a?(Array)
|
|
50
|
+
return val.map { |v| coerce(v, item_type) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return val if val.is_a?(type)
|
|
54
|
+
return type.from_hash(val) if val.is_a?(Hash) && type.respond_to?(:from_hash)
|
|
55
|
+
val
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(**kwargs)
|
|
60
|
+
self.class.attributes.each do |name, opts|
|
|
61
|
+
val = kwargs.key?(name) ? kwargs[name] : resolve_default(opts[:default])
|
|
62
|
+
send(:"#{name}=", val)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
self.class.attributes.each_with_object({}) do |(name, _), result|
|
|
68
|
+
val = send(name)
|
|
69
|
+
next if val.nil?
|
|
70
|
+
result[camelize(name)] = serialize(val)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_json(*)
|
|
75
|
+
JSON.generate(to_h)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def valid?
|
|
79
|
+
self.class.attributes.all? do |name, opts|
|
|
80
|
+
!opts[:required] || !send(name).nil?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ==(other)
|
|
85
|
+
return false unless other.is_a?(self.class)
|
|
86
|
+
self.class.attributes.keys.all? { |n| send(n) == other.send(n) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def camelize(snake)
|
|
92
|
+
parts = snake.to_s.split("_")
|
|
93
|
+
(parts[0..0] + parts[1..].map(&:capitalize)).join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_default(default)
|
|
97
|
+
default.respond_to?(:call) ? default.call : default
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def serialize(val)
|
|
101
|
+
case val
|
|
102
|
+
when Base then val.to_h
|
|
103
|
+
when Array then val.map { |v| serialize(v) }
|
|
104
|
+
when Hash then val.transform_values { |v| serialize(v) }
|
|
105
|
+
when Time then val.iso8601
|
|
106
|
+
else val
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class Message < Base
|
|
6
|
+
attribute :message_id
|
|
7
|
+
attribute :role, required: true
|
|
8
|
+
attribute :parts, type: [Part], default: -> { [] }, required: true
|
|
9
|
+
attribute :context_id
|
|
10
|
+
attribute :task_id
|
|
11
|
+
attribute :reference_task_ids, default: -> { [] }
|
|
12
|
+
attribute :metadata
|
|
13
|
+
attribute :extensions, default: -> { [] }
|
|
14
|
+
|
|
15
|
+
def self.user(*content)
|
|
16
|
+
new(message_id: SecureRandom.uuid, role: Types::Role::USER, parts: build_parts(content))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.agent(*content)
|
|
20
|
+
new(message_id: SecureRandom.uuid, role: Types::Role::AGENT, parts: build_parts(content))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.build_parts(content)
|
|
24
|
+
content.map { |c| c.is_a?(Part) ? c : Part.text(c.to_s) }
|
|
25
|
+
end
|
|
26
|
+
private_class_method :build_parts
|
|
27
|
+
|
|
28
|
+
def initialize(**kwargs)
|
|
29
|
+
kwargs[:message_id] ||= SecureRandom.uuid
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def user? = role == Types::Role::USER
|
|
34
|
+
def agent? = role == Types::Role::AGENT
|
|
35
|
+
|
|
36
|
+
def text_content
|
|
37
|
+
parts.select(&:text?).map(&:text).join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid?
|
|
41
|
+
!role.nil? && !parts.nil? && !parts.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class Part < Base
|
|
6
|
+
attribute :text
|
|
7
|
+
attribute :raw
|
|
8
|
+
attribute :url
|
|
9
|
+
attribute :data
|
|
10
|
+
attribute :media_type
|
|
11
|
+
attribute :filename
|
|
12
|
+
attribute :metadata
|
|
13
|
+
|
|
14
|
+
def self.text(content, media_type: "text/plain", filename: nil)
|
|
15
|
+
new(text: content, media_type: media_type, filename: filename)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.json(hash, filename: nil)
|
|
19
|
+
new(data: hash, media_type: "application/json", filename: filename)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_url(url, media_type:, filename: nil)
|
|
23
|
+
new(url: url, media_type: media_type, filename: filename)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.binary(bytes, media_type:, filename: nil)
|
|
27
|
+
new(raw: Base64.strict_encode64(bytes), media_type: media_type, filename: filename)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def text? = !text.nil?
|
|
31
|
+
def json? = !data.nil?
|
|
32
|
+
def url? = !url.nil?
|
|
33
|
+
def raw? = !raw.nil?
|
|
34
|
+
|
|
35
|
+
def decoded_bytes
|
|
36
|
+
return nil unless raw
|
|
37
|
+
Base64.strict_decode64(raw)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid?
|
|
41
|
+
[text, raw, url, data].compact.length == 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|