agent2agent 1.0.8 → 1.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 +4 -4
- data/lib/a2a/agent.rb +165 -117
- data/lib/a2a/client.rb +470 -51
- data/lib/a2a/errors/json_rpc_error.rb +71 -0
- data/lib/a2a/errors/rest_error.rb +68 -0
- data/lib/a2a/errors.rb +535 -0
- data/lib/a2a/faraday/middleware/json_rpc/request.rb +96 -0
- data/lib/a2a/faraday/middleware/json_rpc/response.rb +131 -0
- data/lib/a2a/faraday/middleware/rest/request.rb +166 -0
- data/lib/a2a/faraday/middleware/rest/response.rb +144 -0
- data/lib/a2a/faraday/middleware/schema_request.rb +69 -0
- data/lib/a2a/middleware/extract_message.rb +120 -0
- data/lib/a2a/middleware/fetch_task.rb +228 -0
- data/lib/a2a/middleware/limit_history_length.rb +123 -0
- data/lib/a2a/middleware/limit_pagination_size.rb +133 -0
- data/lib/a2a/middleware/sse_stream.rb +235 -0
- data/lib/a2a/middleware.rb +7 -0
- data/lib/a2a/schema/definition.rb +35 -1
- data/lib/a2a/schema.rb +126 -0
- data/lib/a2a/{bindings → server/bindings}/json_rpc.rb +12 -8
- data/lib/a2a/{bindings → server/bindings}/rest.rb +12 -8
- data/lib/a2a/server/dispatcher.rb +52 -54
- data/lib/a2a/server/env.rb +4 -6
- data/lib/a2a/server/triage.rb +1 -1
- data/lib/a2a/server.rb +10 -10
- data/lib/a2a/sse/event_parser.rb +202 -0
- data/lib/a2a/sse/json_rpc_stream.rb +27 -5
- data/lib/a2a/sse/rest_stream.rb +17 -5
- data/lib/a2a/sse/stream.rb +135 -7
- data/lib/a2a/sse.rb +1 -0
- data/lib/a2a/test_helpers.rb +89 -0
- data/lib/a2a/version.rb +1 -1
- data/lib/a2a.rb +6 -2
- data/lib/traces/provider/a2a/{bindings → server/bindings}/json_rpc.rb +2 -2
- data/lib/traces/provider/a2a/{bindings → server/bindings}/rest.rb +2 -2
- data/lib/traces/provider/a2a.rb +2 -2
- metadata +49 -22
- data/lib/a2a/server/cancel_task.rb +0 -14
- data/lib/a2a/server/create_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/delete_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/get_extended_agent_card.rb +0 -15
- data/lib/a2a/server/get_task.rb +0 -14
- data/lib/a2a/server/get_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/list_task_push_notification_configs.rb +0 -14
- data/lib/a2a/server/list_tasks.rb +0 -14
- data/lib/a2a/server/send_message.rb +0 -14
- data/lib/a2a/server/send_streaming_message.rb +0 -14
- data/lib/a2a/server/subscribe_to_task.rb +0 -14
- data/lib/a2a/store/processor.rb +0 -136
- data/lib/a2a/store/pub_sub.rb +0 -149
- data/lib/a2a/store/sqlite.rb +0 -533
- data/lib/a2a/store/webhooks.rb +0 -94
- data/lib/a2a/store.rb +0 -6
- data/lib/a2a/task_store.rb +0 -315
data/lib/a2a/store/sqlite.rb
DELETED
|
@@ -1,533 +0,0 @@
|
|
|
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
|
data/lib/a2a/store/webhooks.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async"
|
|
4
|
-
require "async/http/internet"
|
|
5
|
-
require "json"
|
|
6
|
-
require "console"
|
|
7
|
-
|
|
8
|
-
module A2A
|
|
9
|
-
module Store
|
|
10
|
-
# Async webhook delivery for A2A push notifications.
|
|
11
|
-
#
|
|
12
|
-
# Following the gospel (async-http):
|
|
13
|
-
# - Uses Async::HTTP::Internet for non-blocking HTTP requests
|
|
14
|
-
# - Each delivery runs in its own Async task (fiber, not thread)
|
|
15
|
-
# - Failures are logged but do not propagate
|
|
16
|
-
#
|
|
17
|
-
# The A2A spec says:
|
|
18
|
-
# - Push notification payloads use StreamResponse format
|
|
19
|
-
# - Content-Type: application/a2a+json
|
|
20
|
-
# - Authentication via scheme + credentials from the config
|
|
21
|
-
# - Token header: X-A2A-Notification-Token
|
|
22
|
-
# - Delivery is at-least-once with possible retries
|
|
23
|
-
#
|
|
24
|
-
class Webhooks
|
|
25
|
-
def initialize
|
|
26
|
-
@internet = nil
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Deliver a payload to all push notification configs for a task.
|
|
30
|
-
#
|
|
31
|
-
# @parameter configs [Array(Hash)] push notification configs
|
|
32
|
-
# @parameter payload [Hash] the StreamResponse payload
|
|
33
|
-
#
|
|
34
|
-
def deliver(configs, payload)
|
|
35
|
-
return if configs.nil? || configs.empty?
|
|
36
|
-
|
|
37
|
-
configs.each do |config|
|
|
38
|
-
Async do
|
|
39
|
-
deliver_single(config, payload)
|
|
40
|
-
rescue => e
|
|
41
|
-
Console.error(self, "Webhook delivery failed for #{config["url"]}", e)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def internet
|
|
49
|
-
@internet ||= Async::HTTP::Internet.new
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def deliver_single(config, payload)
|
|
53
|
-
url = config["url"]
|
|
54
|
-
return unless url && !url.empty?
|
|
55
|
-
|
|
56
|
-
headers = {
|
|
57
|
-
"content-type" => "application/a2a+json",
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
# Authentication
|
|
61
|
-
if (auth = config["authentication"])
|
|
62
|
-
scheme = auth["scheme"] || "Bearer"
|
|
63
|
-
creds = auth["credentials"] || ""
|
|
64
|
-
headers["authorization"] = "#{scheme} #{creds}"
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Token header
|
|
68
|
-
if (token = config["token"])
|
|
69
|
-
headers["x-a2a-notification-token"] = token
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
body = JSON.generate(payload)
|
|
73
|
-
|
|
74
|
-
internet.post(url, headers, [body])
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
test do
|
|
81
|
-
describe "A2A::Store::Webhooks" do
|
|
82
|
-
it "can be instantiated" do
|
|
83
|
-
wh = A2A::Store::Webhooks.new
|
|
84
|
-
wh.should.not.be.nil
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
it "silently skips empty config lists" do
|
|
88
|
-
wh = A2A::Store::Webhooks.new
|
|
89
|
-
# Should not raise — returns nil for empty/nil configs
|
|
90
|
-
wh.deliver([], { "statusUpdate" => {} }).should.be.nil
|
|
91
|
-
wh.deliver(nil, { "statusUpdate" => {} }).should.be.nil
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|