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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class PushNotificationConfig < Base
|
|
6
|
+
attribute :id
|
|
7
|
+
attribute :task_id
|
|
8
|
+
attribute :webhook_url, required: true
|
|
9
|
+
attribute :authentication_info, type: AuthenticationInfo
|
|
10
|
+
attribute :event_types, default: -> { [] }
|
|
11
|
+
|
|
12
|
+
def valid?
|
|
13
|
+
!webhook_url.nil? && !webhook_url.empty?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class SecurityScheme < Base
|
|
6
|
+
attribute :type, required: true
|
|
7
|
+
attribute :description
|
|
8
|
+
attribute :scheme
|
|
9
|
+
attribute :bearer_format
|
|
10
|
+
attribute :flows
|
|
11
|
+
attribute :open_id_connect_url
|
|
12
|
+
attribute :in
|
|
13
|
+
attribute :name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class SendMessageConfiguration < Base
|
|
6
|
+
attribute :accepted_output_modes, default: -> { [] }
|
|
7
|
+
attribute :task_push_notification_config
|
|
8
|
+
attribute :history_length
|
|
9
|
+
attribute :return_immediately, default: false
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class StreamResponse < Base
|
|
6
|
+
attribute :task
|
|
7
|
+
attribute :message
|
|
8
|
+
attribute :status_update
|
|
9
|
+
attribute :artifact_update
|
|
10
|
+
|
|
11
|
+
def task? = !task.nil?
|
|
12
|
+
def message? = !message.nil?
|
|
13
|
+
def status_update? = !status_update.nil?
|
|
14
|
+
def artifact_update? = !artifact_update.nil?
|
|
15
|
+
|
|
16
|
+
def self.from_hash(hash)
|
|
17
|
+
return nil if hash.nil?
|
|
18
|
+
if hash["task"]
|
|
19
|
+
new(task: Task.from_hash(hash["task"]))
|
|
20
|
+
elsif hash["message"]
|
|
21
|
+
new(message: Message.from_hash(hash["message"]))
|
|
22
|
+
elsif hash["statusUpdate"]
|
|
23
|
+
new(status_update: hash["statusUpdate"])
|
|
24
|
+
elsif hash["artifactUpdate"]
|
|
25
|
+
new(artifact_update: hash["artifactUpdate"])
|
|
26
|
+
else
|
|
27
|
+
new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class Task < Base
|
|
6
|
+
attribute :id
|
|
7
|
+
attribute :context_id
|
|
8
|
+
attribute :status, type: TaskStatus, required: true
|
|
9
|
+
attribute :artifacts, type: [Artifact], default: -> { [] }
|
|
10
|
+
attribute :history, type: [Message], default: -> { [] }
|
|
11
|
+
attribute :metadata
|
|
12
|
+
|
|
13
|
+
def initialize(**kwargs)
|
|
14
|
+
kwargs[:id] ||= SecureRandom.uuid
|
|
15
|
+
kwargs[:context_id] ||= SecureRandom.uuid
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def state = status&.state
|
|
20
|
+
def terminal? = status&.terminal? || false
|
|
21
|
+
def interrupted? = status&.interrupted? || false
|
|
22
|
+
|
|
23
|
+
def start!
|
|
24
|
+
transition!(Types::TaskState::WORKING)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def complete!(artifacts: [])
|
|
28
|
+
self.artifacts = artifacts unless artifacts.empty?
|
|
29
|
+
transition!(Types::TaskState::COMPLETED)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fail!(message: nil)
|
|
33
|
+
transition!(Types::TaskState::FAILED, message: message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def cancel!
|
|
37
|
+
transition!(Types::TaskState::CANCELED)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reject!(message: nil)
|
|
41
|
+
transition!(Types::TaskState::REJECTED, message: message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def require_input!(message: nil)
|
|
45
|
+
transition!(Types::TaskState::INPUT_REQUIRED, message: message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def require_auth!(message: nil)
|
|
49
|
+
transition!(Types::TaskState::AUTH_REQUIRED, message: message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def transition!(new_state, message: nil)
|
|
53
|
+
self.status = TaskStatus.new(state: new_state, message: message)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class TaskArtifactUpdateEvent < Base
|
|
6
|
+
attribute :task_id, required: true
|
|
7
|
+
attribute :context_id, required: true
|
|
8
|
+
attribute :artifact, type: Artifact, required: true
|
|
9
|
+
attribute :append, default: false
|
|
10
|
+
attribute :last_chunk, default: false
|
|
11
|
+
attribute :metadata
|
|
12
|
+
|
|
13
|
+
def append? = !!append
|
|
14
|
+
def last_chunk? = !!last_chunk
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
super.merge("type" => "TaskArtifactUpdateEvent")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class TaskStatus < Base
|
|
6
|
+
attribute :state, required: true
|
|
7
|
+
attribute :message, type: Message
|
|
8
|
+
attribute :timestamp
|
|
9
|
+
|
|
10
|
+
def initialize(**kwargs)
|
|
11
|
+
kwargs[:timestamp] ||= Time.now.iso8601
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def terminal? = Types::TaskState.terminal?(state)
|
|
16
|
+
def interrupted? = Types::TaskState.interrupted?(state)
|
|
17
|
+
def active? = Types::TaskState.active?(state)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
class TaskStatusUpdateEvent < Base
|
|
6
|
+
attribute :task_id, required: true
|
|
7
|
+
attribute :context_id, required: true
|
|
8
|
+
attribute :status, type: TaskStatus, required: true
|
|
9
|
+
attribute :final, default: false
|
|
10
|
+
attribute :metadata
|
|
11
|
+
|
|
12
|
+
def final? = !!final
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
super.merge("type" => "TaskStatusUpdateEvent")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Models
|
|
5
|
+
module Types
|
|
6
|
+
module TaskState
|
|
7
|
+
SUBMITTED = "submitted"
|
|
8
|
+
WORKING = "working"
|
|
9
|
+
COMPLETED = "completed"
|
|
10
|
+
FAILED = "failed"
|
|
11
|
+
CANCELED = "canceled"
|
|
12
|
+
REJECTED = "rejected"
|
|
13
|
+
INPUT_REQUIRED = "input_required"
|
|
14
|
+
AUTH_REQUIRED = "auth_required"
|
|
15
|
+
|
|
16
|
+
TERMINAL = [COMPLETED, FAILED, CANCELED, REJECTED].freeze
|
|
17
|
+
INTERRUPTED = [INPUT_REQUIRED, AUTH_REQUIRED].freeze
|
|
18
|
+
ACTIVE = [SUBMITTED, WORKING].freeze
|
|
19
|
+
ALL = (TERMINAL + INTERRUPTED + ACTIVE).freeze
|
|
20
|
+
|
|
21
|
+
def self.terminal?(state) = TERMINAL.include?(state)
|
|
22
|
+
def self.interrupted?(state) = INTERRUPTED.include?(state)
|
|
23
|
+
def self.active?(state) = ACTIVE.include?(state)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module Role
|
|
27
|
+
USER = "user"
|
|
28
|
+
AGENT = "agent"
|
|
29
|
+
ALL = [USER, AGENT].freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module BindingType
|
|
33
|
+
JSON_RPC = "json-rpc"
|
|
34
|
+
HTTP = "http"
|
|
35
|
+
GRPC = "grpc"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Server
|
|
5
|
+
class AgentExecutor
|
|
6
|
+
def call(context)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def cancel(context)
|
|
11
|
+
context.task.cancel!
|
|
12
|
+
context.emit_status(final: true)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "roda"
|
|
4
|
+
require "protocol/http/body/writable"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Server
|
|
8
|
+
class App < Roda
|
|
9
|
+
SUPPORTED_VERSIONS = %w[1.0 0.3].freeze
|
|
10
|
+
|
|
11
|
+
plugin :json
|
|
12
|
+
plugin :json_parser
|
|
13
|
+
plugin :halt
|
|
14
|
+
plugin :all_verbs
|
|
15
|
+
|
|
16
|
+
def self.configure(agent_card:, storage:, executor:, event_router:, push_sender: nil)
|
|
17
|
+
@agent_card = agent_card
|
|
18
|
+
@storage = storage
|
|
19
|
+
@executor = executor
|
|
20
|
+
@event_router = event_router
|
|
21
|
+
@push_sender = push_sender
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_reader :agent_card, :storage, :executor, :event_router, :push_sender
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
route do |r|
|
|
29
|
+
# A2A version negotiation
|
|
30
|
+
a2a_version = request.env["HTTP_A2A_VERSION"]
|
|
31
|
+
if a2a_version && !SUPPORTED_VERSIONS.include?(a2a_version)
|
|
32
|
+
err = JsonRpc::Response.error(
|
|
33
|
+
id: nil,
|
|
34
|
+
code: JsonRpc::ErrorCode::VERSION_NOT_SUPPORTED,
|
|
35
|
+
message: "Unsupported A2A version: #{a2a_version}"
|
|
36
|
+
)
|
|
37
|
+
r.halt([200, { "Content-Type" => "application/json" }, [err]])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
r.get "agentCard" do
|
|
41
|
+
self.class.agent_card.to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
r.post do
|
|
45
|
+
body = request.body.read
|
|
46
|
+
rpc_req = begin
|
|
47
|
+
JsonRpc::Request.parse(body)
|
|
48
|
+
rescue JsonRpc::ParseError => e
|
|
49
|
+
err = JsonRpc::Response.error(id: nil, code: JsonRpc::ErrorCode::PARSE_ERROR, message: e.message)
|
|
50
|
+
r.halt([200, { "Content-Type" => "application/json" }, [err]])
|
|
51
|
+
rescue JsonRpc::InvalidRequestError => e
|
|
52
|
+
err = JsonRpc::Response.error(id: nil, code: JsonRpc::ErrorCode::INVALID_REQUEST, message: e.message)
|
|
53
|
+
r.halt([200, { "Content-Type" => "application/json" }, [err]])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# SSE has different headers and body — intercept before normal dispatch.
|
|
57
|
+
# The body is an Enumerator so Falcon streams each event to the client
|
|
58
|
+
# as the executor yields it, rather than buffering the whole response.
|
|
59
|
+
if rpc_req.method == "tasks/sendSubscribe"
|
|
60
|
+
r.halt([200, {
|
|
61
|
+
"Content-Type" => "text/event-stream",
|
|
62
|
+
"Cache-Control" => "no-cache",
|
|
63
|
+
"X-Accel-Buffering" => "no"
|
|
64
|
+
}, handle_send_subscribe(rpc_req)])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result = dispatch(rpc_req)
|
|
68
|
+
response["Content-Type"] = "application/json"
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def dispatch(rpc_req)
|
|
76
|
+
case rpc_req.method
|
|
77
|
+
when "tasks/send" then handle_send(rpc_req)
|
|
78
|
+
when "tasks/get" then handle_get(rpc_req)
|
|
79
|
+
when "tasks/list" then handle_list(rpc_req)
|
|
80
|
+
when "tasks/cancel" then handle_cancel(rpc_req)
|
|
81
|
+
when "tasks/pushNotification/set" then handle_push_set(rpc_req)
|
|
82
|
+
when "tasks/pushNotification/get" then handle_push_get(rpc_req)
|
|
83
|
+
when "tasks/pushNotification/delete" then handle_push_delete(rpc_req)
|
|
84
|
+
when "tasks/pushNotification/list" then handle_push_list(rpc_req)
|
|
85
|
+
else
|
|
86
|
+
JsonRpc::Response.error(
|
|
87
|
+
id: rpc_req.id,
|
|
88
|
+
code: JsonRpc::ErrorCode::METHOD_NOT_FOUND,
|
|
89
|
+
message: "Method not found: #{rpc_req.method}"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
rescue A2A::Error => e
|
|
93
|
+
JsonRpc::Response.from_error(id: rpc_req.id, error: e)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
JsonRpc::Response.from_error(id: rpc_req.id, error: e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_send(rpc_req)
|
|
99
|
+
params = rpc_req.params || {}
|
|
100
|
+
msg_hash = params["message"]
|
|
101
|
+
raise JsonRpc::InvalidParamsError, "message is required" unless msg_hash.is_a?(Hash)
|
|
102
|
+
message = Models::Message.from_hash(msg_hash)
|
|
103
|
+
|
|
104
|
+
task = Models::Task.new(
|
|
105
|
+
status: Models::TaskStatus.new(state: Models::Types::TaskState::SUBMITTED)
|
|
106
|
+
)
|
|
107
|
+
self.class.storage.save(task)
|
|
108
|
+
|
|
109
|
+
ctx = Server::Context.new(
|
|
110
|
+
task: task,
|
|
111
|
+
message: message,
|
|
112
|
+
storage: self.class.storage,
|
|
113
|
+
event_router: self.class.event_router
|
|
114
|
+
)
|
|
115
|
+
self.class.executor.call(ctx)
|
|
116
|
+
self.class.storage.save(task)
|
|
117
|
+
|
|
118
|
+
JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def handle_get(rpc_req)
|
|
122
|
+
params = rpc_req.params || {}
|
|
123
|
+
task_id = params["id"] || params["taskId"]
|
|
124
|
+
raise JsonRpc::InvalidParamsError, "id is required" unless task_id
|
|
125
|
+
|
|
126
|
+
task = self.class.storage.find!(task_id)
|
|
127
|
+
JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_list(rpc_req)
|
|
131
|
+
tasks = self.class.storage.list
|
|
132
|
+
JsonRpc::Response.success(id: rpc_req.id, result: tasks.map(&:to_h))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_cancel(rpc_req)
|
|
136
|
+
params = rpc_req.params || {}
|
|
137
|
+
task_id = params["id"] || params["taskId"]
|
|
138
|
+
raise JsonRpc::InvalidParamsError, "id is required" unless task_id
|
|
139
|
+
|
|
140
|
+
task = self.class.storage.find!(task_id)
|
|
141
|
+
raise TaskNotCancelableError, "Task #{task_id} is already terminal" if task.terminal?
|
|
142
|
+
|
|
143
|
+
ctx = Server::Context.new(
|
|
144
|
+
task: task,
|
|
145
|
+
message: nil,
|
|
146
|
+
storage: self.class.storage,
|
|
147
|
+
event_router: self.class.event_router
|
|
148
|
+
)
|
|
149
|
+
self.class.executor.cancel(ctx)
|
|
150
|
+
self.class.storage.save(task)
|
|
151
|
+
|
|
152
|
+
JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_send_subscribe(rpc_req)
|
|
156
|
+
params = rpc_req.params || {}
|
|
157
|
+
msg_hash = params["message"]
|
|
158
|
+
raise JsonRpc::InvalidParamsError, "message is required" unless msg_hash.is_a?(Hash)
|
|
159
|
+
message = Models::Message.from_hash(msg_hash)
|
|
160
|
+
|
|
161
|
+
task = Models::Task.new(
|
|
162
|
+
status: Models::TaskStatus.new(state: Models::Types::TaskState::SUBMITTED)
|
|
163
|
+
)
|
|
164
|
+
self.class.storage.save(task)
|
|
165
|
+
|
|
166
|
+
storage = self.class.storage
|
|
167
|
+
executor = self.class.executor
|
|
168
|
+
|
|
169
|
+
# Protocol::HTTP::Body::Writable is a Readable subclass.
|
|
170
|
+
# Protocol::Rack detects this and does NOT wrap it in an Enumerable
|
|
171
|
+
# fiber — Falcon reads it directly via queue.dequeue in its async task.
|
|
172
|
+
# The executor runs in a sibling async task and pushes events via
|
|
173
|
+
# output.write (queue.enqueue), which is fully async-safe.
|
|
174
|
+
# This avoids the FiberError that Enumerator::Yielder#<< raises when
|
|
175
|
+
# called from inside an LLM streaming callback (an async task context
|
|
176
|
+
# where the Enumerator consumer fiber is not currently waiting).
|
|
177
|
+
output = Protocol::HTTP::Body::Writable.new
|
|
178
|
+
|
|
179
|
+
Async::Task.current.async do
|
|
180
|
+
streaming_router = Object.new.tap do |ro|
|
|
181
|
+
ro.define_singleton_method(:publish) { |_, ev| output.write("data: #{JSON.generate({ 'jsonrpc' => '2.0', 'result' => ev.to_h })}\n\n") }
|
|
182
|
+
ro.define_singleton_method(:open) { |*| }
|
|
183
|
+
ro.define_singleton_method(:close) { |*| }
|
|
184
|
+
ro.define_singleton_method(:channel?) { |*| false }
|
|
185
|
+
ro.define_singleton_method(:subscribe) { |*| }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
ctx = Server::Context.new(
|
|
189
|
+
task: task,
|
|
190
|
+
message: message,
|
|
191
|
+
storage: storage,
|
|
192
|
+
event_router: streaming_router
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
executor.call(ctx)
|
|
196
|
+
storage.save(task)
|
|
197
|
+
rescue => e
|
|
198
|
+
output.write("data: #{JSON.generate({ 'jsonrpc' => '2.0', 'error' => { 'code' => -32000, 'message' => e.message } })}\n\n") rescue nil
|
|
199
|
+
ensure
|
|
200
|
+
output.close_write rescue nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
output
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def handle_push_set(rpc_req)
|
|
207
|
+
raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
|
|
208
|
+
JsonRpc::Response.success(id: rpc_req.id, result: true)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def handle_push_get(rpc_req)
|
|
212
|
+
raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
|
|
213
|
+
JsonRpc::Response.success(id: rpc_req.id, result: nil)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def handle_push_delete(rpc_req)
|
|
217
|
+
raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
|
|
218
|
+
JsonRpc::Response.success(id: rpc_req.id, result: true)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def handle_push_list(rpc_req)
|
|
222
|
+
raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
|
|
223
|
+
JsonRpc::Response.success(id: rpc_req.id, result: [])
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Server
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :agent_card, :executor, :storage, :event_router, :push_sender
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
agent_card:,
|
|
10
|
+
executor:,
|
|
11
|
+
storage: Storage::Memory.new,
|
|
12
|
+
push_sender: nil,
|
|
13
|
+
host: "localhost",
|
|
14
|
+
port: 9292
|
|
15
|
+
)
|
|
16
|
+
@agent_card = agent_card
|
|
17
|
+
@executor = executor
|
|
18
|
+
@storage = storage
|
|
19
|
+
@event_router = EventRouter.new
|
|
20
|
+
@push_sender = push_sender
|
|
21
|
+
@host = host
|
|
22
|
+
@port = port
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rack_app
|
|
26
|
+
klass = Class.new(App)
|
|
27
|
+
klass.configure(
|
|
28
|
+
agent_card: @agent_card,
|
|
29
|
+
storage: @storage,
|
|
30
|
+
executor: @executor,
|
|
31
|
+
event_router: @event_router,
|
|
32
|
+
push_sender: @push_sender
|
|
33
|
+
)
|
|
34
|
+
klass.freeze.app
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
app = rack_app
|
|
39
|
+
FalconRunner.new(app, host: @host, port: @port).run
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Server
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :task, :message, :storage, :event_router, :config
|
|
7
|
+
|
|
8
|
+
def initialize(task:, message:, storage:, event_router:, config: nil)
|
|
9
|
+
@task = task
|
|
10
|
+
@message = message
|
|
11
|
+
@storage = storage
|
|
12
|
+
@event_router = event_router
|
|
13
|
+
@config = config || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def save_task
|
|
17
|
+
storage.save(task)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit_status(final: false)
|
|
21
|
+
event = Models::TaskStatusUpdateEvent.new(
|
|
22
|
+
task_id: task.id,
|
|
23
|
+
context_id: task.context_id,
|
|
24
|
+
status: task.status,
|
|
25
|
+
final: final
|
|
26
|
+
)
|
|
27
|
+
storage.save(task)
|
|
28
|
+
event_router.publish(task.id, event)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def emit_artifact(artifact, append: false, last_chunk: false)
|
|
32
|
+
event = Models::TaskArtifactUpdateEvent.new(
|
|
33
|
+
task_id: task.id,
|
|
34
|
+
context_id: task.context_id,
|
|
35
|
+
artifact: artifact,
|
|
36
|
+
append: append,
|
|
37
|
+
last_chunk: last_chunk
|
|
38
|
+
)
|
|
39
|
+
event_router.publish(task.id, event)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "typed_bus"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
module Server
|
|
7
|
+
class EventRouter
|
|
8
|
+
def initialize
|
|
9
|
+
@bus = TypedBus::MessageBus.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def open(task_id)
|
|
13
|
+
return if @bus.channel?(task_id.to_sym)
|
|
14
|
+
@bus.add_channel(task_id.to_sym, type: nil, timeout: nil)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def close(task_id)
|
|
18
|
+
@bus.remove_channel(task_id.to_sym)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def publish(task_id, event)
|
|
22
|
+
sym = task_id.to_sym
|
|
23
|
+
open(task_id) unless @bus.channel?(sym)
|
|
24
|
+
@bus.publish(sym, event)
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subscribe(task_id, &block)
|
|
30
|
+
sym = task_id.to_sym
|
|
31
|
+
open(task_id) unless @bus.channel?(sym)
|
|
32
|
+
@bus.subscribe(sym) do |delivery|
|
|
33
|
+
block.call(delivery.message)
|
|
34
|
+
delivery.ack!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unsubscribe(task_id, id_or_block)
|
|
39
|
+
return unless @bus.channel?(task_id.to_sym)
|
|
40
|
+
@bus.unsubscribe(task_id.to_sym, id_or_block)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def channel?(task_id)
|
|
46
|
+
@bus.channel?(task_id.to_sym)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "falcon"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
|
|
7
|
+
module A2A
|
|
8
|
+
module Server
|
|
9
|
+
class FalconRunner
|
|
10
|
+
DEFAULT_HOST = "localhost"
|
|
11
|
+
DEFAULT_PORT = 9292
|
|
12
|
+
|
|
13
|
+
def initialize(app, host: DEFAULT_HOST, port: DEFAULT_PORT)
|
|
14
|
+
@app = app
|
|
15
|
+
@host = host
|
|
16
|
+
@port = port
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
|
|
21
|
+
server = Falcon::Server.new(Falcon::Server.middleware(@app), endpoint)
|
|
22
|
+
|
|
23
|
+
Async do
|
|
24
|
+
server.run
|
|
25
|
+
end
|
|
26
|
+
rescue Interrupt
|
|
27
|
+
# Ctrl-C — clean shutdown, no backtrace needed
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|