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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/deploy-github-pages.yml +52 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +192 -0
  6. data/Rakefile +13 -0
  7. data/docs/api/client/index.md +124 -0
  8. data/docs/api/index.md +27 -0
  9. data/docs/api/models/index.md +233 -0
  10. data/docs/api/server/index.md +162 -0
  11. data/docs/api/storage/index.md +84 -0
  12. data/docs/architecture/index.md +63 -0
  13. data/docs/architecture/protocol.md +112 -0
  14. data/docs/assets/css/custom.css +6 -0
  15. data/docs/examples/basic-usage.md +77 -0
  16. data/docs/examples/index.md +92 -0
  17. data/docs/examples/llm-research.md +92 -0
  18. data/docs/examples/streaming.md +81 -0
  19. data/docs/getting-started/installation.md +48 -0
  20. data/docs/getting-started/quick-start.md +100 -0
  21. data/docs/guides/custom-storage.md +69 -0
  22. data/docs/guides/push-notifications.md +104 -0
  23. data/docs/guides/streaming.md +75 -0
  24. data/docs/index.md +98 -0
  25. data/examples/01_basic_usage/client.rb +75 -0
  26. data/examples/01_basic_usage/server.rb +57 -0
  27. data/examples/02_streaming/client.rb +70 -0
  28. data/examples/02_streaming/server.rb +177 -0
  29. data/examples/03_llm_research/client.rb +138 -0
  30. data/examples/03_llm_research/run +82 -0
  31. data/examples/03_llm_research/server.rb +203 -0
  32. data/examples/03_llm_research/web_client.rb +501 -0
  33. data/examples/common_config.rb +4 -0
  34. data/examples/run +108 -0
  35. data/lib/simple_a2a/client/base.rb +101 -0
  36. data/lib/simple_a2a/client/sse.rb +58 -0
  37. data/lib/simple_a2a/errors.rb +15 -0
  38. data/lib/simple_a2a/json_rpc.rb +89 -0
  39. data/lib/simple_a2a/models/agent_capabilities.rb +11 -0
  40. data/lib/simple_a2a/models/agent_card.rb +23 -0
  41. data/lib/simple_a2a/models/agent_interface.rb +11 -0
  42. data/lib/simple_a2a/models/agent_provider.rb +11 -0
  43. data/lib/simple_a2a/models/agent_skill.rb +12 -0
  44. data/lib/simple_a2a/models/artifact.rb +23 -0
  45. data/lib/simple_a2a/models/authentication_info.rb +11 -0
  46. data/lib/simple_a2a/models/base.rb +111 -0
  47. data/lib/simple_a2a/models/message.rb +45 -0
  48. data/lib/simple_a2a/models/part.rb +45 -0
  49. data/lib/simple_a2a/models/push_notification_config.rb +17 -0
  50. data/lib/simple_a2a/models/security_scheme.rb +16 -0
  51. data/lib/simple_a2a/models/send_message_configuration.rb +12 -0
  52. data/lib/simple_a2a/models/stream_response.rb +32 -0
  53. data/lib/simple_a2a/models/task.rb +57 -0
  54. data/lib/simple_a2a/models/task_artifact_update_event.rb +21 -0
  55. data/lib/simple_a2a/models/task_status.rb +20 -0
  56. data/lib/simple_a2a/models/task_status_update_event.rb +19 -0
  57. data/lib/simple_a2a/models/types.rb +39 -0
  58. data/lib/simple_a2a/server/agent_executor.rb +16 -0
  59. data/lib/simple_a2a/server/app.rb +227 -0
  60. data/lib/simple_a2a/server/base.rb +43 -0
  61. data/lib/simple_a2a/server/context.rb +44 -0
  62. data/lib/simple_a2a/server/event_router.rb +50 -0
  63. data/lib/simple_a2a/server/falcon_runner.rb +31 -0
  64. data/lib/simple_a2a/server/multi_agent.rb +50 -0
  65. data/lib/simple_a2a/server/push_sender.rb +80 -0
  66. data/lib/simple_a2a/server/resume_context.rb +14 -0
  67. data/lib/simple_a2a/storage/base.rb +12 -0
  68. data/lib/simple_a2a/storage/memory.rb +41 -0
  69. data/lib/simple_a2a/version.rb +5 -0
  70. data/lib/simple_a2a.rb +49 -0
  71. data/mkdocs.yml +143 -0
  72. data/sig/simple_a2a.rbs +4 -0
  73. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class AgentCapabilities < Base
6
+ attribute :streaming, default: false
7
+ attribute :push_notifications, default: false
8
+ attribute :extended_agent_card, default: false
9
+ end
10
+ end
11
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class AgentInterface < Base
6
+ attribute :type, required: true
7
+ attribute :url, required: true
8
+ attribute :version, required: true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class AgentProvider < Base
6
+ attribute :name, required: true
7
+ attribute :url
8
+ attribute :description
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class AgentSkill < Base
6
+ attribute :name, required: true
7
+ attribute :description
8
+ attribute :input_schema
9
+ attribute :output_schema
10
+ end
11
+ end
12
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class AuthenticationInfo < Base
6
+ attribute :scheme, required: true
7
+ attribute :value, required: true
8
+ attribute :header_name
9
+ end
10
+ end
11
+ 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