simple_a2a 0.1.0 → 0.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +78 -38
  4. data/compare_agent2agent.md +460 -0
  5. data/docs/api/client/index.md +19 -0
  6. data/docs/api/index.md +4 -3
  7. data/docs/api/models/index.md +13 -11
  8. data/docs/api/server/index.md +42 -10
  9. data/docs/api/storage/index.md +0 -1
  10. data/docs/architecture/index.md +17 -15
  11. data/docs/architecture/protocol.md +16 -1
  12. data/docs/assets/images/simple_a2a.jpg +0 -0
  13. data/docs/examples/agent-chaining.md +107 -0
  14. data/docs/examples/auth-headers.md +105 -0
  15. data/docs/examples/cancellation.md +105 -0
  16. data/docs/examples/index.md +123 -52
  17. data/docs/examples/interrupted-states.md +114 -0
  18. data/docs/examples/multipart.md +103 -0
  19. data/docs/examples/push-notifications.md +117 -0
  20. data/docs/examples/resubscribe.md +129 -0
  21. data/docs/examples/sqlite-storage.md +131 -0
  22. data/docs/examples/streaming.md +1 -4
  23. data/docs/guides/push-notifications.md +4 -1
  24. data/docs/guides/streaming.md +34 -5
  25. data/docs/index.md +55 -27
  26. data/examples/04_resubscribe/client.rb +140 -0
  27. data/examples/04_resubscribe/server.rb +75 -0
  28. data/examples/05_cancellation/client.rb +150 -0
  29. data/examples/05_cancellation/server.rb +77 -0
  30. data/examples/06_push_notifications/client.rb +192 -0
  31. data/examples/06_push_notifications/server.rb +123 -0
  32. data/examples/07_agent_chaining/client.rb +120 -0
  33. data/examples/07_agent_chaining/server.rb +150 -0
  34. data/examples/08_interrupted_states/client.rb +148 -0
  35. data/examples/08_interrupted_states/server.rb +142 -0
  36. data/examples/09_multipart/client.rb +117 -0
  37. data/examples/09_multipart/server.rb +97 -0
  38. data/examples/10_auth_headers/client.rb +92 -0
  39. data/examples/10_auth_headers/server.rb +98 -0
  40. data/examples/11_sqlite_storage/Brewfile +1 -0
  41. data/examples/11_sqlite_storage/Gemfile +9 -0
  42. data/examples/11_sqlite_storage/client.rb +114 -0
  43. data/examples/11_sqlite_storage/run +154 -0
  44. data/examples/11_sqlite_storage/server.rb +131 -0
  45. data/examples/README.md +384 -0
  46. data/lib/simple_a2a/client/sse.rb +15 -0
  47. data/lib/simple_a2a/server/app.rb +131 -45
  48. data/lib/simple_a2a/server/base.rb +19 -17
  49. data/lib/simple_a2a/server/broadcast_registry.rb +24 -0
  50. data/lib/simple_a2a/server/multi_agent.rb +1 -1
  51. data/lib/simple_a2a/server/push_config_store.rb +29 -0
  52. data/lib/simple_a2a/server/push_sender.rb +1 -0
  53. data/lib/simple_a2a/server/task_broadcast.rb +46 -0
  54. data/lib/simple_a2a/version.rb +1 -1
  55. metadata +38 -20
  56. data/lib/simple_a2a/server/event_router.rb +0 -50
@@ -0,0 +1 @@
1
+ brew "sqlite3"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Pull in the gem's own runtime + development dependencies.
6
+ gemspec path: "../../"
7
+
8
+ gem "irb"
9
+ gem "sqlite3"
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage:
5
+ # bundle exec ruby examples/11_sqlite_storage/client.rb populate [ids_file]
6
+ # bundle exec ruby examples/11_sqlite_storage/client.rb verify [ids_file]
7
+ #
8
+ # populate — sends three tasks and writes their IDs to ids_file (default:
9
+ # /tmp/a2a_sqlite_demo_ids.json) so the verify phase can find them.
10
+ #
11
+ # verify — reads ids_file, retrieves each task from the (restarted) server,
12
+ # and confirms all tasks are still present with state "completed".
13
+ #
14
+ # The run script in this directory manages both phases automatically.
15
+
16
+ require_relative "../common_config"
17
+ require "json"
18
+
19
+ URL = "http://localhost:9292"
20
+ IDS_FILE = ARGV[1] || "/tmp/a2a_sqlite_demo_ids.json"
21
+ PHASE = ARGV[0]
22
+
23
+ unless %w[populate verify].include?(PHASE)
24
+ abort "Usage: client.rb <populate|verify> [ids_file]"
25
+ end
26
+
27
+ def divider = puts("─" * 60)
28
+
29
+ client = A2A.client(url: URL)
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Phase: populate
33
+ # ---------------------------------------------------------------------------
34
+ if PHASE == "populate"
35
+ puts
36
+ puts "=== Phase 1: Populate — sending tasks to SQLite-backed server ==="
37
+ puts
38
+
39
+ card = client.agent_card
40
+ puts " Agent : #{card.name}"
41
+ puts " DB : shown in server output"
42
+ puts
43
+
44
+ messages = [
45
+ "alpha — first message",
46
+ "beta — second message",
47
+ "gamma — third message"
48
+ ]
49
+
50
+ ids = []
51
+ messages.each do |text|
52
+ task = client.send_task(message: A2A::Models::Message.user(text))
53
+ ids << task.id
54
+ puts " sent : #{text.strip}"
55
+ puts " id : #{task.id}"
56
+ puts " state : #{task.status.state}"
57
+ puts " reply : #{task.artifacts.first&.parts&.first&.text}"
58
+ puts
59
+ end
60
+
61
+ File.write(IDS_FILE, JSON.generate(ids))
62
+ puts " IDs written to #{IDS_FILE}"
63
+ divider
64
+ puts
65
+ puts "Populate complete. The server will now be stopped and restarted."
66
+ puts "The same database file will be passed to the new server instance."
67
+ puts
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Phase: verify
71
+ # ---------------------------------------------------------------------------
72
+ else
73
+ puts
74
+ puts "=== Phase 2: Verify — confirming persistence after server restart ==="
75
+ puts
76
+
77
+ unless File.exist?(IDS_FILE)
78
+ abort "IDs file not found: #{IDS_FILE} — run the populate phase first."
79
+ end
80
+
81
+ ids = JSON.parse(File.read(IDS_FILE))
82
+ puts " Reading #{ids.length} task IDs from #{IDS_FILE}"
83
+ puts
84
+
85
+ results = ids.map do |id|
86
+ task = client.get_task(id)
87
+ puts " id : #{id}"
88
+ puts " state : #{task.status.state}"
89
+ puts " reply : #{task.artifacts.first&.parts&.first&.text}"
90
+ puts
91
+ task
92
+ rescue A2A::Error => e
93
+ puts " id : #{id} MISSING — #{e.message}"
94
+ puts
95
+ nil
96
+ end
97
+
98
+ divider
99
+
100
+ all_present = results.none?(&:nil?)
101
+ all_completed = results.compact.all? { |t| t.status.state == "completed" }
102
+ count_ok = results.length == ids.length
103
+
104
+ puts
105
+ puts "=== Verification ==="
106
+ puts " All tasks present after restart : #{all_present ? 'PASS' : 'FAIL'}"
107
+ puts " All tasks in completed state : #{all_completed ? 'PASS' : 'FAIL'}"
108
+ puts " Task count matches (#{ids.length}) : #{count_ok ? 'PASS' : 'FAIL'}"
109
+ puts
110
+
111
+ all_ok = all_present && all_completed && count_ok
112
+ puts(all_ok ? "All assertions passed." : "One or more assertions failed.")
113
+ exit(all_ok ? 0 : 1)
114
+ end
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Custom run script for demo 11 — SQLite3 persistent storage.
5
+ #
6
+ # This demo proves *persistence across server restarts*, so it needs a
7
+ # two-phase lifecycle:
8
+ #
9
+ # Phase 1 — start server, populate tasks via client, stop server
10
+ # Phase 2 — restart server with the same DB file, verify tasks, stop server
11
+ #
12
+ # Dependencies are declared in the Gemfile and Brewfile alongside this script
13
+ # rather than in-lined into the application code:
14
+ #
15
+ # Brewfile — sqlite3 binary (macOS only; other platforms must provide it)
16
+ # Gemfile — sqlite3 gem + simple_a2a gem (via gemspec path)
17
+ #
18
+ # The top-level `examples/run` launcher detects this file and delegates here.
19
+
20
+ require "socket"
21
+
22
+ DEMO_DIR = File.expand_path(__dir__)
23
+ RUBY = RbConfig.ruby
24
+ SERVER_PORT = 9292
25
+ STARTUP_TIMEOUT = 20
26
+ DB_FILE = "/tmp/a2a_sqlite_demo_tasks.db"
27
+ IDS_FILE = "/tmp/a2a_sqlite_demo_ids.json"
28
+
29
+ BREWFILE = File.join(DEMO_DIR, "Brewfile")
30
+ GEMFILE = File.join(DEMO_DIR, "Gemfile")
31
+ SERVER_RB = File.join(DEMO_DIR, "server.rb")
32
+ CLIENT_RB = File.join(DEMO_DIR, "client.rb")
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+ def banner(text)
38
+ bar = "─" * (text.length + 4)
39
+ puts "┌#{bar}┐"
40
+ puts "│ #{text} │"
41
+ puts "└#{bar}┘"
42
+ end
43
+
44
+ def server_ready?(port)
45
+ TCPSocket.new("localhost", port).close
46
+ true
47
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT
48
+ false
49
+ end
50
+
51
+ def wait_for_server(port, timeout)
52
+ deadline = Time.now + timeout
53
+ sleep 0.1 until server_ready?(port) || Time.now > deadline
54
+ server_ready?(port)
55
+ end
56
+
57
+ def bundle_env(gemfile)
58
+ { "BUNDLE_GEMFILE" => gemfile }
59
+ end
60
+
61
+ def start_server(gemfile, db_path)
62
+ pid = spawn(bundle_env(gemfile), RUBY, SERVER_RB, db_path, out: $stdout, err: $stderr)
63
+ unless wait_for_server(SERVER_PORT, STARTUP_TIMEOUT)
64
+ Process.kill("TERM", pid) rescue nil
65
+ abort "\nServer did not become ready within #{STARTUP_TIMEOUT}s — aborting."
66
+ end
67
+ puts "\n(server ready on port #{SERVER_PORT})\n\n"
68
+ pid
69
+ end
70
+
71
+ def stop_server(pid)
72
+ puts "\n(stopping server…)"
73
+ Process.kill("TERM", pid)
74
+ Process.wait(pid)
75
+ rescue Errno::ESRCH, Errno::ECHILD
76
+ # already gone
77
+ end
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Step 1 — ensure sqlite3 binary is available
81
+ # ---------------------------------------------------------------------------
82
+ banner "11_sqlite_storage — checking dependencies"
83
+
84
+ unless system("which sqlite3 > /dev/null 2>&1")
85
+ if RUBY_PLATFORM =~ /darwin/
86
+ puts "sqlite3 binary not found — installing via Homebrew (Brewfile)..."
87
+ if system("brew bundle check --file=#{BREWFILE} > /dev/null 2>&1")
88
+ puts "(already satisfied)"
89
+ else
90
+ system("brew bundle install --file=#{BREWFILE}") or
91
+ abort "brew bundle install failed. Check #{BREWFILE} and try again."
92
+ end
93
+ else
94
+ abort <<~MSG
95
+
96
+ sqlite3 binary not found.
97
+ Install sqlite3 for your platform (e.g. apt install sqlite3) and retry.
98
+ MSG
99
+ end
100
+ else
101
+ puts "(sqlite3 binary found)"
102
+ end
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Step 2 — bundle install using the local Gemfile (adds sqlite3 to the bundle)
106
+ # ---------------------------------------------------------------------------
107
+ puts "(running bundle install for demo 11 Gemfile...)"
108
+ system(bundle_env(GEMFILE), "bundle", "install", "--quiet") or
109
+ abort "bundle install failed. Check #{GEMFILE} and try again."
110
+ puts "(bundle ready)"
111
+ puts
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Clean up any leftover files from a previous run
115
+ # ---------------------------------------------------------------------------
116
+ File.delete(DB_FILE) if File.exist?(DB_FILE)
117
+ File.delete(IDS_FILE) if File.exist?(IDS_FILE)
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Phase 1 — populate
121
+ # ---------------------------------------------------------------------------
122
+ banner "11_sqlite_storage — Phase 1: populate"
123
+ server_pid = start_server(GEMFILE, DB_FILE)
124
+
125
+ banner "11_sqlite_storage — client (populate)"
126
+ populate_ok = system(bundle_env(GEMFILE), RUBY, CLIENT_RB, "populate", IDS_FILE)
127
+
128
+ stop_server(server_pid)
129
+
130
+ abort "\nPopulate phase failed — aborting." unless populate_ok
131
+
132
+ puts
133
+ puts "(database preserved at #{DB_FILE})"
134
+ puts "(sleeping 1s before restart...)"
135
+ sleep 1
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Phase 2 — verify (same DB file, brand-new server process)
139
+ # ---------------------------------------------------------------------------
140
+ banner "11_sqlite_storage — Phase 2: verify (server restarted)"
141
+ server_pid = start_server(GEMFILE, DB_FILE)
142
+
143
+ banner "11_sqlite_storage — client (verify)"
144
+ verify_ok = system(bundle_env(GEMFILE), RUBY, CLIENT_RB, "verify", IDS_FILE)
145
+
146
+ stop_server(server_pid)
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Clean up temp files
150
+ # ---------------------------------------------------------------------------
151
+ File.delete(DB_FILE) if File.exist?(DB_FILE)
152
+ File.delete(IDS_FILE) if File.exist?(IDS_FILE)
153
+
154
+ exit(verify_ok ? 0 : 1)
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: BUNDLE_GEMFILE=examples/11_sqlite_storage/Gemfile \
5
+ # bundle exec ruby examples/11_sqlite_storage/server.rb [db_path]
6
+ #
7
+ # The run script in this directory manages the full lifecycle automatically.
8
+ #
9
+ # Demonstrates injecting a custom Storage::Base implementation (SQLite3) into
10
+ # the simple_a2a server. The db_path argument lets two consecutive server
11
+ # instances share the same database file, proving that tasks created in the
12
+ # first run survive a full process restart.
13
+
14
+ require "sqlite3"
15
+ require_relative "../common_config"
16
+ require "json"
17
+ require "time"
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # SqliteStorage — A2A::Storage::Base backed by SQLite3
21
+ # ---------------------------------------------------------------------------
22
+ class SqliteStorage < A2A::Storage::Base
23
+ def initialize(path)
24
+ @db = SQLite3::Database.new(path)
25
+ @mutex = Mutex.new
26
+ @db.execute("PRAGMA journal_mode=WAL")
27
+ @db.execute("PRAGMA busy_timeout=5000")
28
+ setup_schema
29
+ end
30
+
31
+ def save(task)
32
+ @mutex.synchronize do
33
+ now = Time.now.iso8601
34
+ @db.execute(
35
+ "INSERT INTO tasks (id, data, created_at, updated_at) VALUES (?, ?, ?, ?) " \
36
+ "ON CONFLICT(id) DO UPDATE SET data=excluded.data, updated_at=excluded.updated_at",
37
+ [task.id, task.to_h.to_json, now, now]
38
+ )
39
+ end
40
+ task
41
+ end
42
+
43
+ def find(id)
44
+ row = @mutex.synchronize { @db.get_first_row("SELECT data FROM tasks WHERE id=?", [id]) }
45
+ return nil unless row
46
+ A2A::Models::Task.from_hash(JSON.parse(row[0]))
47
+ end
48
+
49
+ def find!(id)
50
+ find(id) or raise A2A::TaskNotFoundError, "Task #{id} not found"
51
+ end
52
+
53
+ def delete(id)
54
+ @mutex.synchronize { @db.execute("DELETE FROM tasks WHERE id=?", [id]) }
55
+ end
56
+
57
+ def list
58
+ rows = @mutex.synchronize { @db.execute("SELECT data FROM tasks ORDER BY rowid") }
59
+ rows.map { |row| A2A::Models::Task.from_hash(JSON.parse(row[0])) }
60
+ end
61
+
62
+ def size
63
+ @mutex.synchronize { @db.get_first_value("SELECT COUNT(*) FROM tasks") }
64
+ end
65
+
66
+ def clear
67
+ @mutex.synchronize { @db.execute("DELETE FROM tasks") }
68
+ end
69
+
70
+ private
71
+
72
+ def setup_schema
73
+ @db.execute(<<~SQL)
74
+ CREATE TABLE IF NOT EXISTS tasks (
75
+ id TEXT PRIMARY KEY,
76
+ data TEXT NOT NULL,
77
+ created_at TEXT NOT NULL,
78
+ updated_at TEXT NOT NULL
79
+ )
80
+ SQL
81
+ end
82
+ end
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Executor — simple echo with a completion timestamp
86
+ # ---------------------------------------------------------------------------
87
+ class EchoExecutor < A2A::Server::AgentExecutor
88
+ def call(ctx)
89
+ input = ctx.message.text_content.strip
90
+ ctx.task.complete!(artifacts: [
91
+ A2A::Models::Artifact.new(
92
+ name: "reply",
93
+ parts: [A2A::Models::Part.text("echo: #{input} (completed at #{Time.now.utc.iso8601})")]
94
+ )
95
+ ])
96
+ end
97
+ end
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Startup
101
+ # ---------------------------------------------------------------------------
102
+ db_path = ARGV.first || File.join(__dir__, "tasks.db")
103
+ storage = SqliteStorage.new(db_path)
104
+
105
+ card = A2A::Models::AgentCard.new(
106
+ name: "PersistentEchoAgent",
107
+ version: "1.0",
108
+ description: "Stores tasks in SQLite3; survives server restarts",
109
+ capabilities: A2A::Models::AgentCapabilities.new,
110
+ skills: [
111
+ A2A::Models::AgentSkill.new(
112
+ name: "echo",
113
+ description: "Echoes any text back with a completion timestamp"
114
+ )
115
+ ],
116
+ interfaces: [
117
+ A2A::Models::AgentInterface.new(
118
+ type: "json-rpc",
119
+ url: "http://localhost:9292",
120
+ version: "1.0"
121
+ )
122
+ ]
123
+ )
124
+
125
+ puts "Starting PersistentEchoAgent on http://localhost:9292"
126
+ puts " database: #{db_path}"
127
+ puts " existing tasks in DB: #{storage.size}"
128
+ puts "Press Ctrl-C to stop."
129
+ puts
130
+
131
+ A2A.server(agent_card: card, executor: EchoExecutor.new, storage: storage).run