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.
@@ -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