ag-ui 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/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
data/lib/ag_ui/server.rb
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "console"
|
|
5
|
+
require "ag_ui"
|
|
6
|
+
|
|
7
|
+
require "ag_ui/server/info"
|
|
8
|
+
require "ag_ui/server/triage"
|
|
9
|
+
|
|
10
|
+
module AgUi
|
|
11
|
+
# Rack application exposing the AG-UI runtime surface the CopilotKit
|
|
12
|
+
# client expects (doc 09): /info, /agent/:id/run (SSE), /agent/:id/connect
|
|
13
|
+
# (stub), /agent/:id/stop/:threadId (ack).
|
|
14
|
+
#
|
|
15
|
+
# The block is the run handler. It receives the rack env with
|
|
16
|
+
# env["ag_ui.input"] (parsed RunAgentInput) and env["ag_ui.stream"]
|
|
17
|
+
# (StreamBuilder) and drives the run:
|
|
18
|
+
#
|
|
19
|
+
# app = AgUi.agent(agent_id: "default") do |env|
|
|
20
|
+
# input = env["ag_ui.input"]
|
|
21
|
+
# env["ag_ui.stream"].open(thread_id: input.thread_id, run_id: input.run_id) do |s|
|
|
22
|
+
# s.run_started
|
|
23
|
+
# s.text_message_start(message_id: "m1")
|
|
24
|
+
# s.text_message_content(message_id: "m1", delta: "Hello")
|
|
25
|
+
# s.text_message_end(message_id: "m1")
|
|
26
|
+
# s.run_finished
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # config.ru / Rails: mount at the CopilotKit runtimeUrl
|
|
31
|
+
# map("/api/copilotkit") { run app }
|
|
32
|
+
#
|
|
33
|
+
class Server
|
|
34
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
35
|
+
|
|
36
|
+
# store: run bookkeeping for /connect replay + /stop cancellation.
|
|
37
|
+
# Defaults to the in-memory store; pass store: nil for the stateless
|
|
38
|
+
# stub behaviour (connect closes immediately, stop only acks).
|
|
39
|
+
def initialize(agent_id: "default", description: nil, a2ui_enabled: false,
|
|
40
|
+
info_overrides: {}, validate: true,
|
|
41
|
+
store: RunStore::InMemory.new, &block)
|
|
42
|
+
unless block
|
|
43
|
+
raise ArgumentError, "Server requires a run-handler block"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@agent_id = agent_id
|
|
47
|
+
@info = Info.payload(
|
|
48
|
+
agent_id: agent_id,
|
|
49
|
+
description: description,
|
|
50
|
+
a2ui_enabled: a2ui_enabled,
|
|
51
|
+
overrides: info_overrides,
|
|
52
|
+
)
|
|
53
|
+
@validate = validate
|
|
54
|
+
@store = store
|
|
55
|
+
@handler = block
|
|
56
|
+
@app = build_app
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(env)
|
|
60
|
+
@app.call(env)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def build_app
|
|
66
|
+
server = self
|
|
67
|
+
|
|
68
|
+
Rack::Builder.app do
|
|
69
|
+
use AgUi::Server::Triage
|
|
70
|
+
use AgUi::Server::Middleware::SSEStream
|
|
71
|
+
run ->(env) { server.send(:dispatch, env) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dispatch(env)
|
|
76
|
+
case env["ag_ui.operation"]
|
|
77
|
+
in "info" then respond_info
|
|
78
|
+
in "run" then respond_run(env)
|
|
79
|
+
in "connect" then respond_connect(env)
|
|
80
|
+
in "stop" then respond_stop(env)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def respond_info
|
|
85
|
+
[200, JSON_HEADERS.dup, [JSON.generate(@info)]]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The handler opens env["ag_ui.stream"]; the opened stream becomes
|
|
89
|
+
# the response body — Falcon streams it natively while the run's
|
|
90
|
+
# Async fiber keeps writing. With a store, the builder is wrapped so
|
|
91
|
+
# every event is recorded and the run's task handle registered.
|
|
92
|
+
def respond_run(env)
|
|
93
|
+
if @store
|
|
94
|
+
env["ag_ui.stream"] = RecordingBuilder.new(env["ag_ui.stream"], @store)
|
|
95
|
+
end
|
|
96
|
+
@handler.call(env)
|
|
97
|
+
|
|
98
|
+
stream = env["ag_ui.stream"]
|
|
99
|
+
if stream.is_a?(SSE::Stream)
|
|
100
|
+
[200, SSE::Stream.headers, stream]
|
|
101
|
+
else
|
|
102
|
+
Console.error(self, "run handler completed without opening the stream")
|
|
103
|
+
[500, JSON_HEADERS.dup, [JSON.generate({ "error" => "Internal error" })]]
|
|
104
|
+
end
|
|
105
|
+
rescue => e
|
|
106
|
+
Console.error(self, "run handler raised #{e.class}: #{e.message}", e)
|
|
107
|
+
[500, JSON_HEADERS.dup, [JSON.generate({ "error" => "Internal error" })]]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Resume/reattach (doc 09 §2): replay everything recorded for the
|
|
111
|
+
# thread, then — when a run is in flight — stream its events live
|
|
112
|
+
# until it finishes. Unknown thread (or no store): 200 SSE that
|
|
113
|
+
# completes immediately, the Node runtime's exact behaviour.
|
|
114
|
+
def respond_connect(env)
|
|
115
|
+
input = env["ag_ui.input"]
|
|
116
|
+
thread_id = input&.thread_id.to_s
|
|
117
|
+
stream = SSE::Stream.new(
|
|
118
|
+
thread_id: thread_id,
|
|
119
|
+
run_id: input&.run_id.to_s,
|
|
120
|
+
validate: false,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if @store
|
|
124
|
+
replay(stream, @store.open_subscription(thread_id))
|
|
125
|
+
else
|
|
126
|
+
stream.finish
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
[200, SSE::Stream.headers, stream]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def replay(stream, subscription)
|
|
133
|
+
Async do
|
|
134
|
+
subscription[:events].each { |payload| stream.event(payload) }
|
|
135
|
+
queue = subscription[:queue]
|
|
136
|
+
if queue
|
|
137
|
+
loop do
|
|
138
|
+
item = queue.dequeue
|
|
139
|
+
if item == RunStore::InMemory::FINISHED
|
|
140
|
+
break
|
|
141
|
+
end
|
|
142
|
+
stream.event(item)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
ensure
|
|
146
|
+
stream.finish
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Cancel the thread's in-flight run (the store stops its Async
|
|
151
|
+
# task; the stream closes via the builder's ensure) and ack.
|
|
152
|
+
def respond_stop(env)
|
|
153
|
+
stopped = @store ? @store.stop(env["ag_ui.thread_id"].to_s) : false
|
|
154
|
+
[200, JSON_HEADERS.dup, [JSON.generate({ "stopped" => stopped })]]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Wraps the StreamBuilder so opened runs record into the store.
|
|
158
|
+
class RecordingBuilder
|
|
159
|
+
def initialize(builder, store)
|
|
160
|
+
@builder = builder
|
|
161
|
+
@store = store
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def open(thread_id:, run_id:, validate: true, &block)
|
|
165
|
+
@store.begin_run(thread_id, run_id)
|
|
166
|
+
|
|
167
|
+
@builder.open(
|
|
168
|
+
thread_id: thread_id,
|
|
169
|
+
run_id: run_id,
|
|
170
|
+
validate: validate,
|
|
171
|
+
on_event: ->(payload) { @store.record(thread_id, payload) },
|
|
172
|
+
on_finish: -> { @store.finish_run(thread_id) },
|
|
173
|
+
on_task: ->(task) { @store.attach_task(thread_id, task) },
|
|
174
|
+
&block
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# One-call entrypoint, mirroring A2A.agent:
|
|
181
|
+
#
|
|
182
|
+
# run AgUi.agent(agent_id: "default") { |env| ... }
|
|
183
|
+
#
|
|
184
|
+
def self.agent(**options, &block) = Server.new(**options, &block)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
__END__
|
|
188
|
+
|
|
189
|
+
describe "AgUi::Server" do
|
|
190
|
+
minimal_input = JSON.generate({
|
|
191
|
+
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
|
192
|
+
"messages" => [], "tools" => [], "context" => [], "forwardedProps" => nil,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
request = ->(app, method, path, body: "") do
|
|
196
|
+
app.call({
|
|
197
|
+
"REQUEST_METHOD" => method,
|
|
198
|
+
"PATH_INFO" => path,
|
|
199
|
+
"rack.input" => StringIO.new(body),
|
|
200
|
+
})
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
echo_agent = AgUi.agent(agent_id: "default") do |env|
|
|
204
|
+
input = env["ag_ui.input"]
|
|
205
|
+
env["ag_ui.stream"].open(thread_id: input.thread_id, run_id: input.run_id) do |s|
|
|
206
|
+
s.run_started
|
|
207
|
+
s.text_message_start(message_id: "m1")
|
|
208
|
+
s.text_message_content(message_id: "m1", delta: "Hello")
|
|
209
|
+
s.text_message_end(message_id: "m1")
|
|
210
|
+
s.run_finished
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
read_frames = ->(stream) do
|
|
215
|
+
frames = []
|
|
216
|
+
while (chunk = stream.read)
|
|
217
|
+
frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
218
|
+
end
|
|
219
|
+
frames
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "requires a run-handler block" do
|
|
223
|
+
lambda { AgUi::Server.new }.should.raise(ArgumentError)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "serves GET /info with the runtime envelope" do
|
|
227
|
+
status, headers, body = request.(echo_agent, "GET", "/info")
|
|
228
|
+
status.should == 200
|
|
229
|
+
headers["content-type"].should == "application/json"
|
|
230
|
+
|
|
231
|
+
payload = JSON.parse(body.first)
|
|
232
|
+
payload["agents"]["default"]["className"].should == "BuiltInAgent"
|
|
233
|
+
payload["a2uiEnabled"].should == false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it "streams a run as SSE from the handler's stream" do
|
|
237
|
+
status, headers, body = request.(echo_agent, "POST", "/agent/default/run", body: minimal_input)
|
|
238
|
+
|
|
239
|
+
status.should == 200
|
|
240
|
+
headers["content-type"].should == "text/event-stream"
|
|
241
|
+
body.should.be.kind_of(AgUi::Server::SSE::Stream)
|
|
242
|
+
|
|
243
|
+
frames = read_frames.(body)
|
|
244
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
245
|
+
RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
|
|
246
|
+
]
|
|
247
|
+
frames.first["threadId"].should == "t1"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it "serves /connect as an SSE stream that completes immediately" do
|
|
251
|
+
status, headers, body = request.(echo_agent, "POST", "/agent/default/connect")
|
|
252
|
+
status.should == 200
|
|
253
|
+
headers["content-type"].should == "text/event-stream"
|
|
254
|
+
body.read.should.be.nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "acks /stop with 200 JSON (stopped: false when no run is live)" do
|
|
258
|
+
status, _headers, body = request.(echo_agent, "POST", "/agent/default/stop/t1")
|
|
259
|
+
status.should == 200
|
|
260
|
+
JSON.parse(body.first).should == { "stopped" => false }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "replays a recorded run on /connect for the same thread" do
|
|
264
|
+
agent = AgUi.agent(agent_id: "default") do |env|
|
|
265
|
+
input = env["ag_ui.input"]
|
|
266
|
+
env["ag_ui.stream"].open(thread_id: input.thread_id, run_id: input.run_id) do |s|
|
|
267
|
+
s.run_started
|
|
268
|
+
s.text_message_start(message_id: "m1")
|
|
269
|
+
s.text_message_content(message_id: "m1", delta: "recorded")
|
|
270
|
+
s.text_message_end(message_id: "m1")
|
|
271
|
+
s.run_finished
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
_status, _headers, run_stream = request.(agent, "POST", "/agent/default/run", body: minimal_input)
|
|
276
|
+
read_frames.(run_stream).length.should == 5
|
|
277
|
+
|
|
278
|
+
_status, headers, connect_stream = request.(agent, "POST", "/agent/default/connect", body: minimal_input)
|
|
279
|
+
headers["content-type"].should == "text/event-stream"
|
|
280
|
+
frames = read_frames.(connect_stream)
|
|
281
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
282
|
+
RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
|
|
283
|
+
]
|
|
284
|
+
frames[2]["delta"].should == "recorded"
|
|
285
|
+
|
|
286
|
+
# A different thread replays nothing.
|
|
287
|
+
other = minimal_input.sub("\"t1\"", "\"t-other\"")
|
|
288
|
+
_status, _headers, empty_stream = request.(agent, "POST", "/agent/default/connect", body: other)
|
|
289
|
+
read_frames.(empty_stream).should == []
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "returns 400 for an invalid run body" do
|
|
293
|
+
status, _headers, body = request.(echo_agent, "POST", "/agent/default/run", body: "{}")
|
|
294
|
+
status.should == 400
|
|
295
|
+
JSON.parse(body.first)["error"].should == "Invalid request body"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "returns 500 when the handler never opens the stream" do
|
|
299
|
+
lazy_agent = AgUi.agent { |_env| :did_nothing }
|
|
300
|
+
status, _headers, body = request.(lazy_agent, "POST", "/agent/default/run", body: minimal_input)
|
|
301
|
+
status.should == 500
|
|
302
|
+
JSON.parse(body.first)["error"].should == "Internal error"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "returns 500 when the handler raises before opening the stream" do
|
|
306
|
+
angry_agent = AgUi.agent { |_env| raise "boom" }
|
|
307
|
+
status, _headers, _body = request.(angry_agent, "POST", "/agent/default/run", body: minimal_input)
|
|
308
|
+
status.should == 500
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it "works under a mount prefix" do
|
|
312
|
+
status, _headers, body = request.(echo_agent, "GET", "/api/copilotkit/info")
|
|
313
|
+
status.should == 200
|
|
314
|
+
JSON.parse(body.first)["mode"].should == "sse"
|
|
315
|
+
end
|
|
316
|
+
end
|