agent2agent 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/data/a2a.json +1961 -0
- data/data/a2a.proto +796 -0
- data/data/download-a2a-resources +4 -0
- data/data/spec.txt +3611 -0
- data/lib/a2a/agent.rb +243 -0
- data/lib/a2a/bindings/json_rpc.rb +110 -0
- data/lib/a2a/bindings/rest.rb +106 -0
- data/lib/a2a/client.rb +85 -0
- data/lib/a2a/proto.rb +444 -0
- data/lib/a2a/schema/definition.rb +114 -0
- data/lib/a2a/schema/validation_error.rb +129 -0
- data/lib/a2a/schema.rb +429 -0
- data/lib/a2a/server/cancel_task.rb +14 -0
- data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/dispatcher.rb +127 -0
- data/lib/a2a/server/env.rb +28 -0
- data/lib/a2a/server/get_extended_agent_card.rb +15 -0
- data/lib/a2a/server/get_task.rb +14 -0
- data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
- data/lib/a2a/server/list_tasks.rb +14 -0
- data/lib/a2a/server/send_message.rb +14 -0
- data/lib/a2a/server/send_streaming_message.rb +14 -0
- data/lib/a2a/server/subscribe_to_task.rb +14 -0
- data/lib/a2a/server/triage.rb +96 -0
- data/lib/a2a/server.rb +98 -0
- data/lib/a2a/sse/json_rpc_stream.rb +66 -0
- data/lib/a2a/sse/rest_stream.rb +50 -0
- data/lib/a2a/sse/stream.rb +129 -0
- data/lib/a2a/sse.rb +5 -0
- data/lib/a2a/store/processor.rb +136 -0
- data/lib/a2a/store/pub_sub.rb +149 -0
- data/lib/a2a/store/sqlite.rb +533 -0
- data/lib/a2a/store/webhooks.rb +94 -0
- data/lib/a2a/store.rb +6 -0
- data/lib/a2a/task_store.rb +315 -0
- data/lib/a2a/version.rb +5 -0
- data/lib/a2a.rb +26 -0
- metadata +216 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/queue"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Store
|
|
8
|
+
# Fiber-safe pub/sub for task update streaming.
|
|
9
|
+
#
|
|
10
|
+
# Following the gospel (async-job / protocol-http):
|
|
11
|
+
# - Async::Queue is fiber-safe (no locks needed)
|
|
12
|
+
# - enqueue/dequeue yield the fiber cooperatively
|
|
13
|
+
# - nil sentinel signals end of stream
|
|
14
|
+
#
|
|
15
|
+
# Each subscriber gets an Async::Queue. State mutations push events
|
|
16
|
+
# to all subscribers for the affected task. Terminal states close
|
|
17
|
+
# all subscribers for that task.
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
#
|
|
21
|
+
# pub_sub = A2A::Store::PubSub.new
|
|
22
|
+
#
|
|
23
|
+
# # Subscribe (returns an Async::Queue)
|
|
24
|
+
# queue = pub_sub.subscribe("task-123")
|
|
25
|
+
#
|
|
26
|
+
# # In another fiber, consume events:
|
|
27
|
+
# Async do
|
|
28
|
+
# while event = queue.dequeue
|
|
29
|
+
# process(event)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # Publish an event to all subscribers:
|
|
34
|
+
# pub_sub.notify("task-123", { type: :status, data: { ... } })
|
|
35
|
+
#
|
|
36
|
+
# # Close all subscribers for a task (terminal state):
|
|
37
|
+
# pub_sub.close("task-123")
|
|
38
|
+
#
|
|
39
|
+
class PubSub
|
|
40
|
+
def initialize
|
|
41
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Subscribe to updates for a task.
|
|
45
|
+
# Returns an Async::Queue that will receive events.
|
|
46
|
+
# A nil sentinel signals end of stream.
|
|
47
|
+
def subscribe(task_id)
|
|
48
|
+
queue = Async::Queue.new
|
|
49
|
+
@subscribers[task_id] << queue
|
|
50
|
+
queue
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Remove a specific subscriber queue.
|
|
54
|
+
def unsubscribe(task_id, queue)
|
|
55
|
+
@subscribers[task_id].delete(queue)
|
|
56
|
+
@subscribers.delete(task_id) if @subscribers[task_id].empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Push an event to all subscribers for a task.
|
|
60
|
+
#
|
|
61
|
+
# @param task_id [String]
|
|
62
|
+
# @param event [Hash] e.g. { type: :status, data: { ... } }
|
|
63
|
+
#
|
|
64
|
+
def notify(task_id, event)
|
|
65
|
+
@subscribers[task_id].each do |queue|
|
|
66
|
+
queue.enqueue(event)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Close all subscribers for a task.
|
|
71
|
+
# Sends nil sentinel and removes all subscriptions.
|
|
72
|
+
def close(task_id)
|
|
73
|
+
@subscribers[task_id].each do |queue|
|
|
74
|
+
queue.enqueue(nil) # sentinel: end of stream
|
|
75
|
+
end
|
|
76
|
+
@subscribers.delete(task_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Number of active subscribers for a task.
|
|
80
|
+
def subscriber_count(task_id)
|
|
81
|
+
@subscribers[task_id].size
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Total number of tasks with active subscribers.
|
|
85
|
+
def task_count
|
|
86
|
+
@subscribers.size
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test do
|
|
93
|
+
require "async"
|
|
94
|
+
|
|
95
|
+
describe "A2A::Store::PubSub" do
|
|
96
|
+
it "subscribe returns an Async::Queue" do
|
|
97
|
+
ps = A2A::Store::PubSub.new
|
|
98
|
+
q = ps.subscribe("t1")
|
|
99
|
+
q.is_a?(Async::Queue).should == true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "notify pushes events to all subscribers" do
|
|
103
|
+
ps = A2A::Store::PubSub.new
|
|
104
|
+
q1 = ps.subscribe("t1")
|
|
105
|
+
q2 = ps.subscribe("t1")
|
|
106
|
+
|
|
107
|
+
ps.notify("t1", { type: :status, data: "hello" })
|
|
108
|
+
|
|
109
|
+
Sync do
|
|
110
|
+
q1.dequeue.should == { type: :status, data: "hello" }
|
|
111
|
+
q2.dequeue.should == { type: :status, data: "hello" }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "close sends nil sentinel and removes subscriptions" do
|
|
116
|
+
ps = A2A::Store::PubSub.new
|
|
117
|
+
q = ps.subscribe("t1")
|
|
118
|
+
|
|
119
|
+
ps.close("t1")
|
|
120
|
+
|
|
121
|
+
Sync do
|
|
122
|
+
q.dequeue.should.be.nil
|
|
123
|
+
end
|
|
124
|
+
ps.subscriber_count("t1").should == 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "unsubscribe removes a single subscriber" do
|
|
128
|
+
ps = A2A::Store::PubSub.new
|
|
129
|
+
q1 = ps.subscribe("t1")
|
|
130
|
+
q2 = ps.subscribe("t1")
|
|
131
|
+
|
|
132
|
+
ps.unsubscribe("t1", q1)
|
|
133
|
+
ps.subscriber_count("t1").should == 1
|
|
134
|
+
|
|
135
|
+
ps.notify("t1", { data: "only q2" })
|
|
136
|
+
|
|
137
|
+
Sync do
|
|
138
|
+
q2.dequeue[:data].should == "only q2"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "does not leak when all subscribers unsubscribed" do
|
|
143
|
+
ps = A2A::Store::PubSub.new
|
|
144
|
+
q = ps.subscribe("t1")
|
|
145
|
+
ps.unsubscribe("t1", q)
|
|
146
|
+
ps.task_count.should == 0
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "console"
|
|
7
|
+
|
|
8
|
+
require_relative "pub_sub"
|
|
9
|
+
require_relative "webhooks"
|
|
10
|
+
|
|
11
|
+
module A2A
|
|
12
|
+
module Store
|
|
13
|
+
# SQLite-backed task store with async pub/sub and webhook delivery.
|
|
14
|
+
#
|
|
15
|
+
# This is the enlightened replacement for the in-memory TaskStore.
|
|
16
|
+
# Follows the gospel:
|
|
17
|
+
# - async-job's schema patterns (indexed by state, updated_at DESC)
|
|
18
|
+
# - async-job's duck-typed delegate protocol (#call, #start, #stop)
|
|
19
|
+
# - Async::Queue-based pub/sub (fiber-safe, no threads)
|
|
20
|
+
# - Async::HTTP::Internet for webhook delivery (no Net::HTTP)
|
|
21
|
+
#
|
|
22
|
+
# The store composes three concerns:
|
|
23
|
+
# 1. SQLite — persistent CRUD for tasks, push configs
|
|
24
|
+
# 2. PubSub — in-process streaming subscriptions
|
|
25
|
+
# 3. Webhooks — push notification delivery
|
|
26
|
+
#
|
|
27
|
+
# SQLite is safe for fiber-based concurrency within a single process
|
|
28
|
+
# because Ruby fibers are cooperatively scheduled — only one fiber
|
|
29
|
+
# runs at a time, so no concurrent writes can collide.
|
|
30
|
+
#
|
|
31
|
+
class SQLite
|
|
32
|
+
TERMINAL_STATES = %w[
|
|
33
|
+
TASK_STATE_COMPLETED TASK_STATE_FAILED
|
|
34
|
+
TASK_STATE_CANCELED TASK_STATE_REJECTED
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :pub_sub, :webhooks
|
|
38
|
+
|
|
39
|
+
# @param path [String] path to SQLite database file (":memory:" for in-memory)
|
|
40
|
+
#
|
|
41
|
+
def initialize(path: ":memory:")
|
|
42
|
+
@db = ::SQLite3::Database.new(path)
|
|
43
|
+
@db.results_as_hash = true
|
|
44
|
+
@db.execute("PRAGMA journal_mode = WAL")
|
|
45
|
+
@db.execute("PRAGMA synchronous = NORMAL")
|
|
46
|
+
@db.execute("PRAGMA foreign_keys = ON")
|
|
47
|
+
|
|
48
|
+
@pub_sub = PubSub.new
|
|
49
|
+
@webhooks = Webhooks.new
|
|
50
|
+
|
|
51
|
+
create_tables
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ── Task CRUD ──────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def create(id, context_id, push_config = nil)
|
|
57
|
+
now = now_ts
|
|
58
|
+
|
|
59
|
+
@db.execute(<<~SQL, [id, context_id, "TASK_STATE_SUBMITTED", "[]", "[]", now, now])
|
|
60
|
+
INSERT INTO tasks (id, context_id, state, artifacts, history, created_at, updated_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
SQL
|
|
63
|
+
|
|
64
|
+
if push_config
|
|
65
|
+
cfg_id = push_config["id"] || SecureRandom.uuid
|
|
66
|
+
push_config = push_config.merge("id" => cfg_id, "taskId" => id)
|
|
67
|
+
insert_push_config(id, push_config)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
get(id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get(id)
|
|
74
|
+
row = @db.get_first_row("SELECT * FROM tasks WHERE id = ?", [id])
|
|
75
|
+
return nil unless row
|
|
76
|
+
row_to_task(row)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def update_state(id, state, message: nil)
|
|
80
|
+
now = now_ts
|
|
81
|
+
@db.execute("UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?", [state, now, id])
|
|
82
|
+
|
|
83
|
+
task = get(id)
|
|
84
|
+
return nil unless task
|
|
85
|
+
|
|
86
|
+
event = build_status_event(task, message)
|
|
87
|
+
@pub_sub.notify(id, { type: :status, data: event })
|
|
88
|
+
@webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
|
|
89
|
+
|
|
90
|
+
if TERMINAL_STATES.include?(state)
|
|
91
|
+
@pub_sub.close(id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
task
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def complete(id, result)
|
|
98
|
+
now = now_ts
|
|
99
|
+
result_json = result ? JSON.generate(result) : nil
|
|
100
|
+
@db.execute(
|
|
101
|
+
"UPDATE tasks SET state = ?, result = ?, updated_at = ? WHERE id = ?",
|
|
102
|
+
["TASK_STATE_COMPLETED", result_json, now, id]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
task = get(id)
|
|
106
|
+
return nil unless task
|
|
107
|
+
|
|
108
|
+
event = build_status_event(task)
|
|
109
|
+
@pub_sub.notify(id, { type: :status, data: event })
|
|
110
|
+
@webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
|
|
111
|
+
@pub_sub.close(id)
|
|
112
|
+
|
|
113
|
+
task
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fail(id, msg)
|
|
117
|
+
now = now_ts
|
|
118
|
+
result_json = msg ? JSON.generate(msg) : nil
|
|
119
|
+
@db.execute(
|
|
120
|
+
"UPDATE tasks SET state = ?, result = ?, updated_at = ? WHERE id = ?",
|
|
121
|
+
["TASK_STATE_FAILED", result_json, now, id]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
task = get(id)
|
|
125
|
+
return nil unless task
|
|
126
|
+
|
|
127
|
+
event = build_status_event(task)
|
|
128
|
+
@pub_sub.notify(id, { type: :status, data: event })
|
|
129
|
+
@webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
|
|
130
|
+
@pub_sub.close(id)
|
|
131
|
+
|
|
132
|
+
task
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cancel(id)
|
|
136
|
+
update_state(id, "TASK_STATE_CANCELED")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def terminal?(id)
|
|
140
|
+
task = get(id)
|
|
141
|
+
task && TERMINAL_STATES.include?(task[:state])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ── Artifacts ──────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def add_artifact(id, artifact)
|
|
147
|
+
task = get(id)
|
|
148
|
+
return nil unless task
|
|
149
|
+
|
|
150
|
+
artifacts = task[:artifacts]
|
|
151
|
+
artifacts << artifact
|
|
152
|
+
now = now_ts
|
|
153
|
+
|
|
154
|
+
@db.execute(
|
|
155
|
+
"UPDATE tasks SET artifacts = ?, updated_at = ? WHERE id = ?",
|
|
156
|
+
[JSON.generate(artifacts), now, id]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
event = {
|
|
160
|
+
"taskId" => id,
|
|
161
|
+
"contextId" => task[:context_id],
|
|
162
|
+
"artifact" => artifact,
|
|
163
|
+
"append" => false,
|
|
164
|
+
"lastChunk" => true,
|
|
165
|
+
}
|
|
166
|
+
@pub_sub.notify(id, { type: :artifact, data: event })
|
|
167
|
+
@webhooks.deliver(list_push_configs(id), { "artifactUpdate" => event })
|
|
168
|
+
|
|
169
|
+
get(id)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── History ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def add_message(id, msg)
|
|
175
|
+
task = get(id)
|
|
176
|
+
return nil unless task
|
|
177
|
+
|
|
178
|
+
history = task[:history]
|
|
179
|
+
history << msg
|
|
180
|
+
|
|
181
|
+
@db.execute(
|
|
182
|
+
"UPDATE tasks SET history = ?, updated_at = ? WHERE id = ?",
|
|
183
|
+
[JSON.generate(history), now_ts, id]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
get(id)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ── Listing ────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def list(context_id: nil, state: nil)
|
|
192
|
+
conditions = []
|
|
193
|
+
params = []
|
|
194
|
+
|
|
195
|
+
if context_id
|
|
196
|
+
conditions << "context_id = ?"
|
|
197
|
+
params << context_id
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if state
|
|
201
|
+
conditions << "state = ?"
|
|
202
|
+
params << state
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
where = conditions.empty? ? "" : "WHERE #{conditions.join(" AND ")}"
|
|
206
|
+
sql = "SELECT * FROM tasks #{where} ORDER BY updated_at DESC"
|
|
207
|
+
|
|
208
|
+
@db.execute(sql, params).map { |row| row_to_task(row) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# ── Push Notification Config CRUD ──────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def create_push_config(task_id, config)
|
|
214
|
+
task = get(task_id)
|
|
215
|
+
return nil unless task
|
|
216
|
+
|
|
217
|
+
cfg_id = config["id"] || SecureRandom.uuid
|
|
218
|
+
config = config.merge("id" => cfg_id, "taskId" => task_id)
|
|
219
|
+
insert_push_config(task_id, config)
|
|
220
|
+
config
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def get_push_config(task_id, config_id)
|
|
224
|
+
row = @db.get_first_row(
|
|
225
|
+
"SELECT * FROM push_configs WHERE task_id = ? AND id = ?",
|
|
226
|
+
[task_id, config_id]
|
|
227
|
+
)
|
|
228
|
+
return nil unless row
|
|
229
|
+
row_to_push_config(row)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def list_push_configs(task_id)
|
|
233
|
+
@db.execute(
|
|
234
|
+
"SELECT * FROM push_configs WHERE task_id = ? ORDER BY created_at",
|
|
235
|
+
[task_id]
|
|
236
|
+
).map { |row| row_to_push_config(row) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def delete_push_config(task_id, config_id)
|
|
240
|
+
@db.execute(
|
|
241
|
+
"DELETE FROM push_configs WHERE task_id = ? AND id = ?",
|
|
242
|
+
[task_id, config_id]
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ── Streaming / Pub-Sub ────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def subscribe(task_id)
|
|
249
|
+
task = get(task_id)
|
|
250
|
+
return nil unless task
|
|
251
|
+
@pub_sub.subscribe(task_id)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def unsubscribe(task_id, queue)
|
|
255
|
+
@pub_sub.unsubscribe(task_id, queue)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ── Lifecycle (async-job duck-type) ────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def start
|
|
261
|
+
# No-op for now. Could start background cleanup tasks.
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def stop
|
|
265
|
+
@db.close if @db
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def create_tables
|
|
271
|
+
@db.execute_batch(<<~SQL)
|
|
272
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
273
|
+
id TEXT PRIMARY KEY,
|
|
274
|
+
context_id TEXT NOT NULL,
|
|
275
|
+
state TEXT NOT NULL DEFAULT 'TASK_STATE_SUBMITTED',
|
|
276
|
+
result TEXT,
|
|
277
|
+
artifacts TEXT NOT NULL DEFAULT '[]',
|
|
278
|
+
history TEXT NOT NULL DEFAULT '[]',
|
|
279
|
+
created_at TEXT NOT NULL,
|
|
280
|
+
updated_at TEXT NOT NULL
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_context
|
|
284
|
+
ON tasks(context_id);
|
|
285
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_state
|
|
286
|
+
ON tasks(state);
|
|
287
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_updated
|
|
288
|
+
ON tasks(updated_at DESC);
|
|
289
|
+
|
|
290
|
+
CREATE TABLE IF NOT EXISTS push_configs (
|
|
291
|
+
id TEXT PRIMARY KEY,
|
|
292
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
293
|
+
url TEXT NOT NULL,
|
|
294
|
+
token TEXT,
|
|
295
|
+
auth_scheme TEXT,
|
|
296
|
+
auth_credentials TEXT,
|
|
297
|
+
created_at TEXT NOT NULL
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_push_configs_task
|
|
301
|
+
ON push_configs(task_id);
|
|
302
|
+
SQL
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def now_ts
|
|
306
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def row_to_task(row)
|
|
310
|
+
{
|
|
311
|
+
id: row["id"],
|
|
312
|
+
context_id: row["context_id"],
|
|
313
|
+
state: row["state"],
|
|
314
|
+
result: row["result"] ? JSON.parse(row["result"]) : nil,
|
|
315
|
+
artifacts: JSON.parse(row["artifacts"] || "[]"),
|
|
316
|
+
history: JSON.parse(row["history"] || "[]"),
|
|
317
|
+
created_at: row["created_at"],
|
|
318
|
+
updated_at: row["updated_at"],
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def row_to_push_config(row)
|
|
323
|
+
config = {
|
|
324
|
+
"id" => row["id"],
|
|
325
|
+
"taskId" => row["task_id"],
|
|
326
|
+
"url" => row["url"],
|
|
327
|
+
}
|
|
328
|
+
config["token"] = row["token"] if row["token"]
|
|
329
|
+
if row["auth_scheme"]
|
|
330
|
+
config["authentication"] = {
|
|
331
|
+
"scheme" => row["auth_scheme"],
|
|
332
|
+
"credentials" => row["auth_credentials"],
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
config
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def insert_push_config(task_id, config)
|
|
339
|
+
auth = config["authentication"] || {}
|
|
340
|
+
params = [
|
|
341
|
+
config["id"], task_id, config["url"],
|
|
342
|
+
config["token"],
|
|
343
|
+
auth["scheme"], auth["credentials"],
|
|
344
|
+
now_ts
|
|
345
|
+
]
|
|
346
|
+
@db.execute(<<~SQL, params)
|
|
347
|
+
INSERT INTO push_configs (id, task_id, url, token, auth_scheme, auth_credentials, created_at)
|
|
348
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
349
|
+
SQL
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def build_status_event(task, message = nil)
|
|
353
|
+
event = {
|
|
354
|
+
"taskId" => task[:id],
|
|
355
|
+
"contextId" => task[:context_id],
|
|
356
|
+
"status" => {
|
|
357
|
+
"state" => task[:state],
|
|
358
|
+
"timestamp" => task[:updated_at],
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
event["status"]["message"] = message if message
|
|
362
|
+
event
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
test do
|
|
369
|
+
describe "A2A::Store::SQLite" do
|
|
370
|
+
it "creates and retrieves tasks" do
|
|
371
|
+
store = A2A::Store::SQLite.new
|
|
372
|
+
task = store.create("t1", "ctx1")
|
|
373
|
+
|
|
374
|
+
task[:id].should == "t1"
|
|
375
|
+
task[:context_id].should == "ctx1"
|
|
376
|
+
task[:state].should == "TASK_STATE_SUBMITTED"
|
|
377
|
+
task[:artifacts].should == []
|
|
378
|
+
task[:history].should == []
|
|
379
|
+
|
|
380
|
+
fetched = store.get("t1")
|
|
381
|
+
fetched[:id].should == "t1"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "returns nil for missing tasks" do
|
|
385
|
+
store = A2A::Store::SQLite.new
|
|
386
|
+
store.get("nonexistent").should.be.nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
it "updates task state" do
|
|
390
|
+
store = A2A::Store::SQLite.new
|
|
391
|
+
store.create("t1", "ctx1")
|
|
392
|
+
|
|
393
|
+
store.update_state("t1", "TASK_STATE_WORKING")
|
|
394
|
+
task = store.get("t1")
|
|
395
|
+
task[:state].should == "TASK_STATE_WORKING"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "completes tasks" do
|
|
399
|
+
store = A2A::Store::SQLite.new
|
|
400
|
+
store.create("t1", "ctx1")
|
|
401
|
+
|
|
402
|
+
store.complete("t1", { "answer" => 42 })
|
|
403
|
+
task = store.get("t1")
|
|
404
|
+
task[:state].should == "TASK_STATE_COMPLETED"
|
|
405
|
+
task[:result].should == { "answer" => 42 }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
it "fails tasks" do
|
|
409
|
+
store = A2A::Store::SQLite.new
|
|
410
|
+
store.create("t1", "ctx1")
|
|
411
|
+
|
|
412
|
+
store.fail("t1", "something broke")
|
|
413
|
+
task = store.get("t1")
|
|
414
|
+
task[:state].should == "TASK_STATE_FAILED"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "cancels tasks" do
|
|
418
|
+
store = A2A::Store::SQLite.new
|
|
419
|
+
store.create("t1", "ctx1")
|
|
420
|
+
|
|
421
|
+
store.cancel("t1")
|
|
422
|
+
task = store.get("t1")
|
|
423
|
+
task[:state].should == "TASK_STATE_CANCELED"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
it "detects terminal states" do
|
|
427
|
+
store = A2A::Store::SQLite.new
|
|
428
|
+
store.create("t1", "ctx1")
|
|
429
|
+
|
|
430
|
+
store.terminal?("t1").should == false
|
|
431
|
+
store.complete("t1", nil)
|
|
432
|
+
store.terminal?("t1").should == true
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
it "adds artifacts" do
|
|
436
|
+
store = A2A::Store::SQLite.new
|
|
437
|
+
store.create("t1", "ctx1")
|
|
438
|
+
|
|
439
|
+
artifact = { "artifactId" => "a1", "parts" => [{ "text" => "hello" }] }
|
|
440
|
+
store.add_artifact("t1", artifact)
|
|
441
|
+
|
|
442
|
+
task = store.get("t1")
|
|
443
|
+
task[:artifacts].length.should == 1
|
|
444
|
+
task[:artifacts].first["artifactId"].should == "a1"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it "adds messages to history" do
|
|
448
|
+
store = A2A::Store::SQLite.new
|
|
449
|
+
store.create("t1", "ctx1")
|
|
450
|
+
|
|
451
|
+
msg = { "messageId" => "m1", "role" => "ROLE_USER", "parts" => [{ "text" => "hi" }] }
|
|
452
|
+
store.add_message("t1", msg)
|
|
453
|
+
|
|
454
|
+
task = store.get("t1")
|
|
455
|
+
task[:history].length.should == 1
|
|
456
|
+
task[:history].first["messageId"].should == "m1"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
it "lists tasks with filtering" do
|
|
460
|
+
store = A2A::Store::SQLite.new
|
|
461
|
+
store.create("t1", "ctx1")
|
|
462
|
+
store.create("t2", "ctx1")
|
|
463
|
+
store.create("t3", "ctx2")
|
|
464
|
+
store.complete("t2", nil)
|
|
465
|
+
|
|
466
|
+
# All tasks
|
|
467
|
+
store.list.length.should == 3
|
|
468
|
+
|
|
469
|
+
# By context
|
|
470
|
+
store.list(context_id: "ctx1").length.should == 2
|
|
471
|
+
|
|
472
|
+
# By state
|
|
473
|
+
store.list(state: "TASK_STATE_COMPLETED").length.should == 1
|
|
474
|
+
store.list(state: "TASK_STATE_COMPLETED").first[:id].should == "t2"
|
|
475
|
+
|
|
476
|
+
# Both filters
|
|
477
|
+
store.list(context_id: "ctx1", state: "TASK_STATE_SUBMITTED").length.should == 1
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
it "manages push notification configs" do
|
|
481
|
+
store = A2A::Store::SQLite.new
|
|
482
|
+
store.create("t1", "ctx1")
|
|
483
|
+
|
|
484
|
+
config = store.create_push_config("t1", {
|
|
485
|
+
"url" => "https://example.com/hook",
|
|
486
|
+
"token" => "secret",
|
|
487
|
+
"authentication" => { "scheme" => "Bearer", "credentials" => "abc123" },
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
config["url"].should == "https://example.com/hook"
|
|
491
|
+
config["token"].should == "secret"
|
|
492
|
+
config["id"].should.not.be.nil
|
|
493
|
+
|
|
494
|
+
# Get
|
|
495
|
+
fetched = store.get_push_config("t1", config["id"])
|
|
496
|
+
fetched["url"].should == "https://example.com/hook"
|
|
497
|
+
fetched["authentication"]["scheme"].should == "Bearer"
|
|
498
|
+
|
|
499
|
+
# List
|
|
500
|
+
store.list_push_configs("t1").length.should == 1
|
|
501
|
+
|
|
502
|
+
# Delete
|
|
503
|
+
store.delete_push_config("t1", config["id"])
|
|
504
|
+
store.list_push_configs("t1").length.should == 0
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
it "creates tasks with inline push config" do
|
|
508
|
+
store = A2A::Store::SQLite.new
|
|
509
|
+
store.create("t1", "ctx1", {
|
|
510
|
+
"url" => "https://example.com/hook",
|
|
511
|
+
"token" => "tok",
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
configs = store.list_push_configs("t1")
|
|
515
|
+
configs.length.should == 1
|
|
516
|
+
configs.first["url"].should == "https://example.com/hook"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
it "provides pub/sub subscriptions" do
|
|
520
|
+
store = A2A::Store::SQLite.new
|
|
521
|
+
store.create("t1", "ctx1")
|
|
522
|
+
|
|
523
|
+
queue = store.subscribe("t1")
|
|
524
|
+
queue.should.not.be.nil
|
|
525
|
+
queue.is_a?(Async::Queue).should == true
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
it "returns nil subscribing to nonexistent task" do
|
|
529
|
+
store = A2A::Store::SQLite.new
|
|
530
|
+
store.subscribe("nonexistent").should.be.nil
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|