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,57 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/01_basic_usage/server.rb
|
|
5
|
+
|
|
6
|
+
require_relative "../common_config"
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Agent executor — contains all of your agent's logic.
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
class BasicExecutor < A2A::Server::AgentExecutor
|
|
12
|
+
GREETINGS = %w[Hello Greetings Salutations Hey Howdy].freeze
|
|
13
|
+
|
|
14
|
+
def call(ctx)
|
|
15
|
+
input = ctx.message.text_content.strip
|
|
16
|
+
reply = "#{GREETINGS.sample}: #{input}"
|
|
17
|
+
|
|
18
|
+
ctx.task.complete!(artifacts: [
|
|
19
|
+
A2A::Models::Artifact.new(
|
|
20
|
+
name: "reply",
|
|
21
|
+
parts: [A2A::Models::Part.text(reply)]
|
|
22
|
+
)
|
|
23
|
+
])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Agent card — describes this agent to any client that asks.
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
card = A2A::Models::AgentCard.new(
|
|
31
|
+
name: "BasicAgent",
|
|
32
|
+
version: "1.0",
|
|
33
|
+
description: "A minimal A2A agent that greets every message it receives",
|
|
34
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
35
|
+
skills: [
|
|
36
|
+
A2A::Models::AgentSkill.new(
|
|
37
|
+
name: "greet",
|
|
38
|
+
description: "Echoes the input with a random greeting"
|
|
39
|
+
)
|
|
40
|
+
],
|
|
41
|
+
interfaces: [
|
|
42
|
+
A2A::Models::AgentInterface.new(
|
|
43
|
+
type: "json-rpc",
|
|
44
|
+
url: "http://localhost:9292",
|
|
45
|
+
version: "1.0"
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Start the server (blocks; Ctrl-C to stop).
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
puts "Starting BasicAgent on http://localhost:9292"
|
|
54
|
+
puts "Press Ctrl-C to stop."
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
A2A.server(agent_card: card, executor: BasicExecutor.new).run
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/02_streaming/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/02_streaming/server.rb
|
|
8
|
+
|
|
9
|
+
require_relative "../common_config"
|
|
10
|
+
|
|
11
|
+
URL = "http://localhost:9292"
|
|
12
|
+
|
|
13
|
+
client = A2A.sse_client(url: URL)
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# 1. Confirm the agent advertises streaming support
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
card = client.agent_card
|
|
19
|
+
puts "=== Agent Card ==="
|
|
20
|
+
puts " Name: #{card.name}"
|
|
21
|
+
puts " Description: #{card.description}"
|
|
22
|
+
puts " Streaming: #{card.capabilities&.streaming}"
|
|
23
|
+
puts " Source: https://lamplight.guide/blog/the-god-particle/"
|
|
24
|
+
puts
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# 2. Subscribe and print words as they stream in
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
puts "=== Streaming Article ==="
|
|
30
|
+
puts
|
|
31
|
+
|
|
32
|
+
event_count = 0
|
|
33
|
+
artifact_text = +""
|
|
34
|
+
word_count = 0
|
|
35
|
+
start_time = nil
|
|
36
|
+
interrupted = false
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
client.send_subscribe(message: A2A::Models::Message.user("stream")) do |event|
|
|
40
|
+
event_count += 1
|
|
41
|
+
|
|
42
|
+
case event
|
|
43
|
+
when A2A::Models::TaskStatusUpdateEvent
|
|
44
|
+
start_time = Time.now if event.status.state == "working"
|
|
45
|
+
|
|
46
|
+
when A2A::Models::TaskArtifactUpdateEvent
|
|
47
|
+
chunk = event.artifact.parts.map(&:text).join
|
|
48
|
+
artifact_text << chunk
|
|
49
|
+
word_count += chunk.split.length
|
|
50
|
+
print chunk
|
|
51
|
+
$stdout.flush
|
|
52
|
+
|
|
53
|
+
else
|
|
54
|
+
puts " [unknown] #{event.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
rescue Interrupt
|
|
58
|
+
interrupted = true
|
|
59
|
+
puts "\n\n(interrupted)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
elapsed = start_time ? (Time.now - start_time) : 0
|
|
63
|
+
wpm = elapsed > 0 ? (word_count / (elapsed / 60.0)).round : 0
|
|
64
|
+
|
|
65
|
+
puts
|
|
66
|
+
puts "=== Summary ==="
|
|
67
|
+
puts " Words received : #{word_count}"
|
|
68
|
+
puts " Events received : #{event_count}"
|
|
69
|
+
puts " Elapsed : #{elapsed.round(1)}s — #{wpm} WPM effective"
|
|
70
|
+
puts " Status : #{interrupted ? "interrupted" : "completed"}"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/02_streaming/server.rb
|
|
5
|
+
|
|
6
|
+
require_relative "../common_config"
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Streaming executor — streams an article at ~300 words per minute.
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
class StreamingExecutor < A2A::Server::AgentExecutor
|
|
12
|
+
SECONDS_PER_WORD = (60.0 / 600).freeze # 600 WPM → 0.1 s/word
|
|
13
|
+
|
|
14
|
+
ARTICLE = <<~ARTICLE.freeze
|
|
15
|
+
The Heaviest Word in the Bible — The God Particle
|
|
16
|
+
|
|
17
|
+
You did not expect to find physics here.
|
|
18
|
+
|
|
19
|
+
You picked up an essay about the Bible and now you are reading about mass, gravity, and particle accelerators. The suspicion is reasonable. These things belong in different rooms. Science over there. Scripture over here. Never introduce them.
|
|
20
|
+
|
|
21
|
+
That assumption is the first thing this essay will examine. Solomon wrote that "it is the glory of God to conceal things, but the glory of kings is to search things out" (Proverbs 25:2). The investigation is not a trespass. It is a vocation.
|
|
22
|
+
|
|
23
|
+
## It's Greek to Me
|
|
24
|
+
|
|
25
|
+
The word "physics" comes from the Greek word for nature — or more precisely, the way things are. Aristotle titled his foundational work on natural philosophy simply Physics. The discipline does not invent reality. It describes it. The apple fell before Newton named the force that pulled it. Spacetime curved before Einstein described its geometry. The field that gives particles their mass existed before Peter Higgs predicted it in 1964. Physics is the practice of looking at what is already there and reporting back accurately. The study of reality — not one interpretation of it, not a useful model of it. Reality itself.
|
|
26
|
+
|
|
27
|
+
Genesis 1 is doing the same thing from a different angle.
|
|
28
|
+
|
|
29
|
+
## In the Beginning
|
|
30
|
+
|
|
31
|
+
"In the beginning God created the heavens and the earth." (Genesis 1:1, ESV)
|
|
32
|
+
|
|
33
|
+
That is not a metaphor for an inner spiritual state. It is a claim about what actually happened to actual matter and actual space. The opening verses of Scripture make physical claims: there was a beginning — not an eternal universe cycling endlessly through time, but a moment when space and matter came into existence. The earth was without form and void (Genesis 1:2) — the Hebrew is tohu wabohu, formless and empty, maximum disorder, no structure and no content. Then God spoke, and the first thing He made was light (Genesis 1:3) — brightness before substance, energy before matter. The writer of Genesis had no knowledge of particle physics. But what they named first, physics has since confirmed is genuinely foundational: light is massless, travels as both wave and particle, and can exist where matter cannot. The correspondence is not a coincidence waiting to be explained. It is an invitation to pay attention.
|
|
34
|
+
|
|
35
|
+
The sequence that follows — three days of separation, three days of filling (Genesis 1:3–31) — is the account of physical reality being ordered and inhabited. Science asks how this reality behaves. Genesis asks whose idea it was and why. They are not enemies. They are two disciplines examining the same subject — one asking how it works, the other asking who made it and why.
|
|
36
|
+
|
|
37
|
+
The astronomer Robert Jastrow was not a believer. He spent his career measuring the universe. In God and the Astronomers he described what happened when cosmology finally confronted the beginning it had resisted for decades:
|
|
38
|
+
|
|
39
|
+
"For the scientist who has lived by his faith in the power of reason, the story ends like a bad dream. He has scaled the mountains of ignorance; he is about to conquer the highest peak; as he pulls himself over the final rock, he is greeted by a band of theologians who have been sitting there for centuries."
|
|
40
|
+
|
|
41
|
+
Einstein added a fudge factor to his own equations — the cosmological constant — because his mathematics implied an expanding universe and he did not want the implications of a beginning. When Edwin Hubble confirmed the expansion in 1929, the beginning became unavoidable. Theologians had been saying "in the beginning" for three thousand years. They were not surprised.
|
|
42
|
+
|
|
43
|
+
Physics and Scripture share a subject. What follows is both.
|
|
44
|
+
|
|
45
|
+
## Three Kinds of Mass
|
|
46
|
+
|
|
47
|
+
Before we talk about gravity, we need to talk about mass. And before we talk about mass in physics, a small detour is in order.
|
|
48
|
+
|
|
49
|
+
Catholics call their central act of worship "Mass." The name has nothing to do with what you are about to read. The liturgical word comes from the Latin missa — the past participle of mittere, to send or dismiss. The closing words of the service are Ite, missa est — Go, the dismissal is made. The congregation was gathered, fed spiritually and sacramentally, and then sent out. The service is named for its ending movement. You were assembled so that you could be dispatched.
|
|
50
|
+
|
|
51
|
+
The physics word "mass" comes from Latin massa and Greek maza, meaning a lump or heap — originally a lump of dough. A physical clump of stuff. No connection to the liturgical word whatsoever. Two identical English words pointing at entirely different realities. Keep that observation in mind. It will matter before this essay is finished.
|
|
52
|
+
|
|
53
|
+
"Mass" in everyday speech sits between the liturgical and the scientific. A mass of protesters. Mass hysteria. Weapons of mass destruction. Here the word means a large quantity of something gathered in one place — bulk, accumulation, a lot. Same Latin root as the physics term, stretched across general usage until it means something vague and large.
|
|
54
|
+
|
|
55
|
+
Then there is mass in physics, which is precise and specific and does not mean a heap of anything.
|
|
56
|
+
|
|
57
|
+
There are two kinds of mass in physics, and they turn out to be the same thing.
|
|
58
|
+
|
|
59
|
+
Inertial mass is resistance to acceleration. Force equals mass times acceleration. The more mass an object has, the harder it is to move, stop, or change direction. This is what you feel when you push a stalled car versus a shopping cart. The car resists you. The shopping cart does not. That resistance is inertial mass.
|
|
60
|
+
|
|
61
|
+
Gravitational mass is the property that causes objects to attract each other across space. It is the property that makes gravity work between bodies.
|
|
62
|
+
|
|
63
|
+
Einstein's equivalence principle established that these two are identical. Standing in a gravitational field is physically indistinguishable from being accelerated. Mass is mass, regardless of how you measure it.
|
|
64
|
+
|
|
65
|
+
Here is the crucial distinction: mass is intrinsic and weight is relational. You have the same mass on the moon, on Jupiter, floating in deep space. Your weight changes with every location because weight is the force your mass experiences in the presence of another mass. Weigh yourself on the moon and the scale reads one-sixth what it reads on earth. Your mass did not change. The moon simply has less mass than the earth and exerts less gravitational force on you.
|
|
66
|
+
|
|
67
|
+
Mass is what you fundamentally are. Weight is what you feel in a given environment.
|
|
68
|
+
|
|
69
|
+
For most of physics history, mass was treated as a brute fact. Things have it. That is simply the way things are. Nobody asked where it came from. Then the question became unavoidable.
|
|
70
|
+
|
|
71
|
+
## The God Particle
|
|
72
|
+
|
|
73
|
+
The Standard Model of particle physics describes all fundamental particles as excitations of quantum fields. This framework is extraordinarily accurate — the most precisely tested theory in the history of science. But it could not explain why some particles have mass and others do not. The photon, the particle of light, has zero mass. It travels at the speed of light because nothing with mass can reach that speed. The electron has mass. The W and Z bosons that carry the weak nuclear force have mass. The reason for this difference was a gap that embarrassed physicists for decades.
|
|
74
|
+
|
|
75
|
+
Peter Higgs and several colleagues proposed an answer in 1964. There is a field permeating all of space — invisible, everywhere, present at every point in the universe. Particles that interact with this field acquire mass. The more strongly a particle interacts with it, the more mass it has. Particles that do not interact with it — photons — pass through unimpeded at light speed.
|
|
76
|
+
|
|
77
|
+
The standard analogy is a crowded room at a party. A celebrity enters and immediately gets surrounded — they can barely move through the crowd. They have acquired effective mass from the interaction. An unknown walks through the same room unnoticed and reaches the far wall in seconds. The crowd is the Higgs field. The interaction is mass.
|
|
78
|
+
|
|
79
|
+
Without the Higgs field, no particle has mass. Without mass, there are no atoms. Without atoms, there is no matter. Without matter, there are no stars, no planets, no you. Everything races around at the speed of light — a universe of pure weightless energy. Substantial nothing.
|
|
80
|
+
|
|
81
|
+
The Higgs boson is the particle associated with the Higgs field, the way a photon is the particle of the electromagnetic field. Physicists searched for it for fifty years. It was detected at CERN's Large Hadron Collider in 2012. Peter Higgs and François Englert received the Nobel Prize in Physics the following year.
|
|
82
|
+
|
|
83
|
+
The name the particle carries is not what Higgs would have chosen. Physicist Leon Lederman coined it for his 1993 book. The original title was The Goddamn Particle — a tribute to how maddeningly difficult the thing was to find. His publisher refused it. They shortened it to The God Particle, and the name spread despite the protests of nearly every physicist who heard it.
|
|
84
|
+
|
|
85
|
+
It spread because it accidentally captured something true.
|
|
86
|
+
|
|
87
|
+
An invisible field, present everywhere in the universe, giving substance to particles that would otherwise have none. Without it, nothing has weight. Nothing has thereness. The field is what makes matter into something rather than nothing.
|
|
88
|
+
|
|
89
|
+
Secular physicists reached the bottom of the question "where does mass come from?" and what they described — an invisible field, present everywhere, the source of all substance — was something theologians had been naming for three thousand years. The popular name was an accident. The description was not.
|
|
90
|
+
|
|
91
|
+
A Hebrew word has been saying exactly this for three thousand years. We will get to it shortly.
|
|
92
|
+
|
|
93
|
+
## Gravity
|
|
94
|
+
|
|
95
|
+
First, gravity.
|
|
96
|
+
|
|
97
|
+
Gravity is one of the four fundamental forces of nature: the strong nuclear force, the weak nuclear force, electromagnetism, and gravity. If you ranked them by strength, gravity would come last — and not by a small margin. Electromagnetism is approximately ten to the power of thirty-six times stronger than gravity. A small magnet on your refrigerator holds itself against the gravitational pull of the entire planet without effort.
|
|
98
|
+
|
|
99
|
+
Yet gravity is the force that structures the universe at large scales. Galaxies, solar systems, planets, the orbits of moons — all of it is gravity's architecture. How does the weakest force become the dominant architect?
|
|
100
|
+
|
|
101
|
+
Two properties set it apart. Gravity is always attractive — it never repels. And it has infinite range. It weakens with distance according to a precise mathematical law — double the distance and the force drops to one quarter — but it never reaches zero. The most distant galaxy in the observable universe is still gravitationally connected to you. The pull is immeasurably small. It is not zero. The weakest force wins because it reaches everywhere and never turns off.
|
|
102
|
+
|
|
103
|
+
Isaac Newton described gravity as a force acting at a distance. His equation predicted the motion of planets with extraordinary precision. Newton did not know why masses attracted each other across empty space. He described the effect with perfect accuracy and admitted he could not explain the mechanism.
|
|
104
|
+
|
|
105
|
+
Albert Einstein could. In his general theory of relativity, gravity is not a force at all. Mass curves spacetime itself — the four-dimensional fabric of space and time in which everything exists. Objects do not fall toward each other because they are pulled by a mysterious force. They follow the straightest possible path through a space that has been bent by the presence of mass. The apple does not fall because Earth is pulling it. Earth's mass has curved the geometry of space around it, and the apple is following that geometry.
|
|
106
|
+
|
|
107
|
+
Gravity is not a thing that exists alongside matter. It is what happens to space when matter is present. Gravity is the shape of reality in the presence of mass.
|
|
108
|
+
|
|
109
|
+
Now watch what happens to the word.
|
|
110
|
+
|
|
111
|
+
"The gravity of the situation." "She carries herself with gravitas." "A grave matter." The word migrated from its precise physical meaning into everyday language, where it now means anything serious, weighty, or worthy of solemnity. A good speech has gravity. A funeral has gravity. A bad decision carries grave consequences.
|
|
112
|
+
|
|
113
|
+
The phenomenon did not change. The apple still falls at 9.8 meters per second squared. The planets still follow their curved paths through spacetime. Einstein's equations still hold. But the word "gravity" now does general duty for anything that feels heavy or serious, and no one reaching for it in conversation means spacetime curvature.
|
|
114
|
+
|
|
115
|
+
This is the pattern.
|
|
116
|
+
|
|
117
|
+
A word once pointed at a precise reality. It got borrowed across domains — the serious kind, the solemn kind, the impressive kind. Each borrowing added a thin layer of metaphor. Eventually the word stopped pointing at anything in particular and started functioning as a signal of weightiness in general. The reality did not change. The word became useless.
|
|
118
|
+
|
|
119
|
+
Hold that pattern in mind.
|
|
120
|
+
|
|
121
|
+
Because it happened to another word too. A word you have used in church. A word you have sung in hymns. A word that appears in Scripture more than four hundred times and has become so familiar, so borrowed, so spread across so many domains, that it now means whatever the context demands and nothing in particular.
|
|
122
|
+
|
|
123
|
+
That word is glory.
|
|
124
|
+
ARTICLE
|
|
125
|
+
|
|
126
|
+
WORDS = ARTICLE.split.freeze
|
|
127
|
+
|
|
128
|
+
def call(ctx)
|
|
129
|
+
ctx.task.start!
|
|
130
|
+
ctx.emit_status
|
|
131
|
+
|
|
132
|
+
WORDS.each_with_index do |word, i|
|
|
133
|
+
sleep SECONDS_PER_WORD
|
|
134
|
+
text = i.zero? ? word : " #{word}"
|
|
135
|
+
artifact = A2A::Models::Artifact.new(
|
|
136
|
+
index: 0,
|
|
137
|
+
parts: [A2A::Models::Part.text(text)],
|
|
138
|
+
append: i > 0,
|
|
139
|
+
last_chunk: i == WORDS.length - 1
|
|
140
|
+
)
|
|
141
|
+
ctx.emit_artifact(artifact, append: i > 0, last_chunk: i == WORDS.length - 1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ctx.task.complete!
|
|
145
|
+
ctx.emit_status(final: true)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Agent card
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
card = A2A::Models::AgentCard.new(
|
|
153
|
+
name: "StreamingAgent",
|
|
154
|
+
version: "1.0",
|
|
155
|
+
description: "Streams article content word-by-word at ~600 WPM via SSE",
|
|
156
|
+
capabilities: A2A::Models::AgentCapabilities.new(streaming: true),
|
|
157
|
+
skills: [
|
|
158
|
+
A2A::Models::AgentSkill.new(
|
|
159
|
+
name: "stream",
|
|
160
|
+
description: "Returns article text word-by-word via SSE at 600 WPM"
|
|
161
|
+
)
|
|
162
|
+
],
|
|
163
|
+
interfaces: [
|
|
164
|
+
A2A::Models::AgentInterface.new(
|
|
165
|
+
type: "json-rpc",
|
|
166
|
+
url: "http://localhost:9292",
|
|
167
|
+
version: "1.0"
|
|
168
|
+
)
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
puts "Starting StreamingAgent on http://localhost:9292"
|
|
173
|
+
puts "Streaming #{StreamingExecutor::WORDS.length} words at 600 WPM (~#{(StreamingExecutor::WORDS.length / 600.0).ceil} min)"
|
|
174
|
+
puts "Press Ctrl-C to stop."
|
|
175
|
+
puts
|
|
176
|
+
|
|
177
|
+
A2A.server(agent_card: card, executor: StreamingExecutor.new).run
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/03_llm_research/client.rb [topic]
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/03_llm_research/server.rb
|
|
8
|
+
|
|
9
|
+
require_relative "../common_config"
|
|
10
|
+
|
|
11
|
+
BASE_URL = "http://localhost:9292"
|
|
12
|
+
ANTHROPIC_URL = "#{BASE_URL}/anthropic"
|
|
13
|
+
OPENAI_URL = "#{BASE_URL}/openai"
|
|
14
|
+
EVALUATOR_URL = "#{BASE_URL}/evaluator"
|
|
15
|
+
|
|
16
|
+
topic = ARGV.first || "research all shortcomings and defects and criticisms of the agent-to-agent protocol specification; summarize what is wrong with the spec"
|
|
17
|
+
|
|
18
|
+
def banner(text)
|
|
19
|
+
bar = "─" * (text.length + 4)
|
|
20
|
+
puts "┌#{bar}┐"
|
|
21
|
+
puts "│ #{text} │"
|
|
22
|
+
puts "└#{bar}┘"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def collect_streaming_response(url, topic)
|
|
26
|
+
client = A2A.sse_client(url: url)
|
|
27
|
+
text = +""
|
|
28
|
+
|
|
29
|
+
client.send_subscribe(message: A2A::Models::Message.user(topic)) do |event|
|
|
30
|
+
case event
|
|
31
|
+
when A2A::Models::TaskArtifactUpdateEvent
|
|
32
|
+
text << event.artifact.parts.filter_map(&:text).join
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
text
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# 1. Research phase — query both agents in parallel
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
banner "Research Phase: \"#{topic}\""
|
|
43
|
+
puts
|
|
44
|
+
puts "Querying Anthropic (claude-sonnet-4-6) and OpenAI (gpt-5.4) in parallel…"
|
|
45
|
+
puts "This may take several minutes for complex topics."
|
|
46
|
+
puts
|
|
47
|
+
|
|
48
|
+
anthropic_text = nil
|
|
49
|
+
openai_text = nil
|
|
50
|
+
anthropic_err = nil
|
|
51
|
+
openai_err = nil
|
|
52
|
+
|
|
53
|
+
anthropic_thread = Thread.new do
|
|
54
|
+
anthropic_text = collect_streaming_response(ANTHROPIC_URL, topic)
|
|
55
|
+
rescue => e
|
|
56
|
+
anthropic_err = e
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
openai_thread = Thread.new do
|
|
60
|
+
openai_text = collect_streaming_response(OPENAI_URL, topic)
|
|
61
|
+
rescue => e
|
|
62
|
+
openai_err = e
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
start_time = Time.now
|
|
66
|
+
progress_thread = Thread.new do
|
|
67
|
+
loop do
|
|
68
|
+
sleep 30
|
|
69
|
+
elapsed = (Time.now - start_time).to_i
|
|
70
|
+
$stderr.puts " (still waiting for LLM responses... #{elapsed}s elapsed)"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
anthropic_thread.join
|
|
75
|
+
openai_thread.join
|
|
76
|
+
progress_thread.kill
|
|
77
|
+
|
|
78
|
+
if anthropic_err
|
|
79
|
+
warn "Anthropic agent error: #{anthropic_err.message}"
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if openai_err
|
|
84
|
+
warn "OpenAI agent error: #{openai_err.message}"
|
|
85
|
+
exit 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# 2. Display research responses
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
puts "=== Claude Response (#{anthropic_text.length} chars) ==="
|
|
92
|
+
puts anthropic_text
|
|
93
|
+
puts
|
|
94
|
+
puts "=== GPT-5.4 Response (#{openai_text.length} chars) ==="
|
|
95
|
+
puts openai_text
|
|
96
|
+
puts
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# 3. Evaluation phase — send both responses to the evaluator
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
banner "Evaluation Phase"
|
|
102
|
+
puts
|
|
103
|
+
|
|
104
|
+
eval_prompt = <<~PROMPT
|
|
105
|
+
You received two research responses on the same topic. Evaluate which is more
|
|
106
|
+
extensive and comprehensive.
|
|
107
|
+
|
|
108
|
+
Topic: #{topic}
|
|
109
|
+
|
|
110
|
+
== Response A: Claude (claude-sonnet-4-6) ==
|
|
111
|
+
#{anthropic_text}
|
|
112
|
+
|
|
113
|
+
== Response B: OpenAI (gpt-5.4) ==
|
|
114
|
+
#{openai_text}
|
|
115
|
+
|
|
116
|
+
Evaluate both on these dimensions:
|
|
117
|
+
1. Total length and detail
|
|
118
|
+
2. Breadth of subtopics covered
|
|
119
|
+
3. Depth of analysis
|
|
120
|
+
4. Use of concrete examples
|
|
121
|
+
5. Overall information density
|
|
122
|
+
|
|
123
|
+
Provide a clear verdict: which response (A or B) is more extensive, and why?
|
|
124
|
+
PROMPT
|
|
125
|
+
|
|
126
|
+
evaluator = A2A.client(url: EVALUATOR_URL)
|
|
127
|
+
puts "Sending both responses to evaluator…"
|
|
128
|
+
puts
|
|
129
|
+
|
|
130
|
+
eval_task = evaluator.send_task(message: A2A::Models::Message.user(eval_prompt))
|
|
131
|
+
eval_artifact = eval_task.artifacts&.first
|
|
132
|
+
|
|
133
|
+
if eval_artifact
|
|
134
|
+
puts "=== Evaluation ==="
|
|
135
|
+
puts eval_artifact.parts.filter_map(&:text).join
|
|
136
|
+
else
|
|
137
|
+
puts "(Evaluator returned no artifact — task state: #{eval_task.status&.state})"
|
|
138
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Lifecycle manager for the 03_llm_research web demo.
|
|
5
|
+
#
|
|
6
|
+
# Starts the A2A multi-agent server (port 9292) and the Sinatra web client
|
|
7
|
+
# (port 4567), then waits. Press Ctrl-C to stop both.
|
|
8
|
+
#
|
|
9
|
+
# Usage (from project root): ruby examples/run 03_llm_research
|
|
10
|
+
# Usage (from demo dir): ./run
|
|
11
|
+
#
|
|
12
|
+
# Requires:
|
|
13
|
+
# ANTHROPIC_API_KEY
|
|
14
|
+
# OPENAI_API_KEY
|
|
15
|
+
|
|
16
|
+
require "socket"
|
|
17
|
+
|
|
18
|
+
DEMO_DIR = File.expand_path("..", __FILE__)
|
|
19
|
+
RUBY = RbConfig.ruby
|
|
20
|
+
A2A_PORT = 9292
|
|
21
|
+
WEB_PORT = 4567
|
|
22
|
+
STARTUP_TIMEOUT = 30
|
|
23
|
+
|
|
24
|
+
def server_ready?(port)
|
|
25
|
+
TCPSocket.new("localhost", port).close
|
|
26
|
+
true
|
|
27
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def wait_for(name, port, timeout)
|
|
32
|
+
deadline = Time.now + timeout
|
|
33
|
+
sleep 0.1 until server_ready?(port) || Time.now > deadline
|
|
34
|
+
abort "#{name} did not start on port #{port} within #{timeout}s" unless server_ready?(port)
|
|
35
|
+
puts " #{name} ready → http://localhost:#{port}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Environment check
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
missing = %w[ANTHROPIC_API_KEY OPENAI_API_KEY].reject { |k| ENV[k] }
|
|
42
|
+
unless missing.empty?
|
|
43
|
+
warn "Missing required environment variables: #{missing.join(', ')}"
|
|
44
|
+
warn "Set them before running:"
|
|
45
|
+
missing.each { |k| warn " export #{k}=your_key_here" }
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Start servers
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
puts "Starting 03_llm_research demo…"
|
|
53
|
+
puts
|
|
54
|
+
|
|
55
|
+
a2a_pid = spawn(RUBY, File.join(DEMO_DIR, "server.rb"), out: $stdout, err: $stderr)
|
|
56
|
+
web_pid = spawn(RUBY, File.join(DEMO_DIR, "web_client.rb"), out: $stdout, err: $stderr)
|
|
57
|
+
|
|
58
|
+
wait_for("A2A server", A2A_PORT, STARTUP_TIMEOUT)
|
|
59
|
+
wait_for("Web client", WEB_PORT, STARTUP_TIMEOUT)
|
|
60
|
+
|
|
61
|
+
puts
|
|
62
|
+
puts "Open http://localhost:#{WEB_PORT} in your browser."
|
|
63
|
+
puts "Press Ctrl-C to stop."
|
|
64
|
+
puts
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Keep running until Ctrl-C
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
trap("INT") do
|
|
70
|
+
puts "\nStopping…"
|
|
71
|
+
[a2a_pid, web_pid].each { |pid| Process.kill("TERM", pid) rescue nil }
|
|
72
|
+
[a2a_pid, web_pid].each { |pid| Process.wait(pid) rescue nil }
|
|
73
|
+
exit 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
trap("TERM") do
|
|
77
|
+
[a2a_pid, web_pid].each { |pid| Process.kill("TERM", pid) rescue nil }
|
|
78
|
+
[a2a_pid, web_pid].each { |pid| Process.wait(pid) rescue nil }
|
|
79
|
+
exit 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
loop { sleep 1 }
|