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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+ require "protocol/http/body/writable"
6
+
7
+ module AgUi
8
+ class Server
9
+ module SSE
10
+ # Async-native SSE body built on ::Protocol::HTTP::Body::Writable,
11
+ # ported from a2a's Server::SSE::Stream with the AG-UI vocabulary.
12
+ #
13
+ # Falcon's protocol-rack passes Readable subclasses through untouched,
14
+ # giving true async streaming with backpressure. write() pushes frames,
15
+ # read() pops them (the HTTP server does this), close_write signals EOF.
16
+ #
17
+ # One typed emitter per AG-UI event, generated from the schema bundle:
18
+ #
19
+ # stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
20
+ # stream.run_started
21
+ # stream.text_message_start(message_id: "m1", role: "assistant")
22
+ # stream.text_message_content(message_id: "m1", delta: "Hello")
23
+ # stream.text_message_end(message_id: "m1")
24
+ # stream.run_finished
25
+ # stream.finish
26
+ #
27
+ # threadId / runId are injected automatically wherever the event schema
28
+ # has those properties (RUN_STARTED, RUN_FINISHED, ...); kwargs override.
29
+ # Every event is schema-validated before hitting the wire (validate:
30
+ # false to skip) — a wire-contract bug should raise, not stream.
31
+ class Stream < ::Protocol::HTTP::Body::Writable
32
+ # Response headers for the /run SSE response. Matches the Node
33
+ # runtime (Cache-Control: no-cache, keep-alive) plus
34
+ # x-accel-buffering to defeat proxy buffering.
35
+ SSE_HEADERS = {
36
+ "content-type" => "text/event-stream",
37
+ "cache-control" => "no-cache",
38
+ "x-accel-buffering" => "no",
39
+ "connection" => "keep-alive",
40
+ }.freeze
41
+
42
+ attr_reader :thread_id, :run_id
43
+
44
+ # on_event: optional tap receiving every wire payload — the run
45
+ # store records through it for /connect replay.
46
+ def initialize(thread_id:, run_id:, validate: true, on_event: nil, **options)
47
+ @thread_id = thread_id
48
+ @run_id = run_id
49
+ @validate = validate
50
+ @on_event = on_event
51
+ @encoder = EventEncoder.new
52
+ super(**options)
53
+ end
54
+
55
+ # Emit a pre-built event payload (Hash with camelCase keys).
56
+ def event(payload)
57
+ @on_event&.call(payload)
58
+ write(@encoder.encode(payload))
59
+ end
60
+
61
+ # Signal end-of-stream; the reader receives nil and closes the
62
+ # SSE connection.
63
+ def finish
64
+ close_write
65
+ end
66
+
67
+ # A fresh mutable copy — upstream middleware mutates response
68
+ # headers in place.
69
+ def self.headers
70
+ SSE_HEADERS.dup
71
+ end
72
+
73
+ # --- Typed event emitters -------------------------------------------
74
+ #
75
+ # One method per concrete event definition in the schema bundle,
76
+ # named after the wire type: RUN_STARTED -> #run_started,
77
+ # TEXT_MESSAGE_CONTENT -> #text_message_content, ...
78
+ AgUi::Protocol::JsonSchema.event_types.each do |wire_type, definition_name|
79
+ definition_class = AgUi::Protocol::JsonSchema[definition_name]
80
+ injects_thread = definition_class.schema_properties.include?("threadId")
81
+ injects_run = definition_class.schema_properties.include?("runId")
82
+
83
+ # Pydantic serializes non-null field defaults onto the wire
84
+ # (e.g. TEXT_MESSAGE_START carries role: "assistant" when
85
+ # omitted); null defaults are dropped by exclude_none. Mirror
86
+ # that: pre-fill non-null defaults, let kwargs override. Fields
87
+ # whose schema is a const (e.g. REASONING_MESSAGE_START's
88
+ # role: "reasoning") can only hold that value — fill them too,
89
+ # matching the reference SDKs' constructor defaults.
90
+ property_defaults = AgUi::Protocol::JsonSchema.raw_schema
91
+ .dig("definitions", definition_name, "properties")
92
+ .each_with_object({}) do |(camel, prop), defaults|
93
+ value = prop["default"].nil? ? prop["const"] : prop["default"]
94
+ unless value.nil?
95
+ defaults[camel] = value
96
+ end
97
+ end
98
+
99
+ define_method(wire_type.downcase) do |**kwargs|
100
+ # "type" inserted first so it leads the wire JSON, like the
101
+ # reference SDKs — keeps oracle byte-diffs quiet.
102
+ defaults = { "type" => wire_type }.merge(property_defaults)
103
+ if injects_thread
104
+ defaults["threadId"] = @thread_id
105
+ end
106
+ if injects_run
107
+ defaults["runId"] = @run_id
108
+ end
109
+
110
+ definition = definition_class.new(defaults.merge(kwargs))
111
+ if @validate
112
+ definition.valid!
113
+ end
114
+
115
+ event(definition.to_h)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ __END__
124
+
125
+ describe "AgUi::Server::SSE::Stream" do
126
+ read_frames = ->(stream) do
127
+ frames = []
128
+ while (chunk = stream.read)
129
+ frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
130
+ end
131
+ frames
132
+ end
133
+
134
+ it "emits the phase-1 minimum viable event sequence" do
135
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
136
+
137
+ stream.run_started
138
+ stream.text_message_start(message_id: "m1")
139
+ stream.text_message_content(message_id: "m1", delta: "Hello")
140
+ stream.text_message_end(message_id: "m1")
141
+ stream.run_finished
142
+ stream.finish
143
+
144
+ frames = read_frames.(stream)
145
+ frames.map { |f| f["type"] }.should == %w[
146
+ RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
147
+ ]
148
+ frames.first.should == { "type" => "RUN_STARTED", "threadId" => "t1", "runId" => "r1" }
149
+ frames[1]["role"].should == "assistant"
150
+ frames[2]["delta"].should == "Hello"
151
+ end
152
+
153
+ it "frames every event as data:-only SSE" do
154
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
155
+ stream.run_started
156
+ stream.finish
157
+
158
+ chunk = stream.read
159
+ chunk.should.start_with("data: ")
160
+ chunk.should.end_with("\n\n")
161
+ chunk.lines.length.should == 2
162
+ end
163
+
164
+ it "injects thread/run ids only where the schema has them" do
165
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
166
+ stream.tool_call_start(tool_call_id: "tc1", tool_call_name: "navigate")
167
+ stream.finish
168
+
169
+ frame = read_frames.(stream).first
170
+ frame.should == {
171
+ "type" => "TOOL_CALL_START", "toolCallId" => "tc1", "toolCallName" => "navigate",
172
+ }
173
+ end
174
+
175
+ it "allows kwargs to override injected ids" do
176
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
177
+ stream.run_finished(run_id: "override", result: { "ok" => true })
178
+ stream.finish
179
+
180
+ frame = read_frames.(stream).first
181
+ frame["runId"].should == "override"
182
+ frame["threadId"].should == "t1"
183
+ frame["result"].should == { "ok" => true }
184
+ end
185
+
186
+ it "emits activity snapshots in the flat wire shape" do
187
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
188
+ stream.activity_snapshot(
189
+ message_id: "a2ui-surface-tc1",
190
+ activity_type: "a2ui-surface",
191
+ content: { "a2ui_operations" => [] },
192
+ replace: true,
193
+ )
194
+ stream.finish
195
+
196
+ frame = read_frames.(stream).first
197
+ frame["messageId"].should == "a2ui-surface-tc1"
198
+ frame["activityType"].should == "a2ui-surface"
199
+ frame.key?("activity").should == false
200
+ end
201
+
202
+ it "raises ValidationError on schema-invalid events before writing" do
203
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
204
+
205
+ begin
206
+ stream.text_message_content(message_id: "m1") # missing delta
207
+ raise "expected ValidationError"
208
+ rescue AgUi::Protocol::JsonSchema::ValidationError => e
209
+ e.message.should.include?("delta")
210
+ end
211
+
212
+ stream.finish
213
+ stream.read.should.be.nil
214
+ end
215
+
216
+ it "skips validation when validate: false" do
217
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1", validate: false)
218
+ stream.text_message_content(message_id: "m1")
219
+ stream.finish
220
+
221
+ read_frames.(stream).first["type"].should == "TEXT_MESSAGE_CONTENT"
222
+ end
223
+
224
+ it "provides the SSE response headers as a fresh mutable copy" do
225
+ headers = AgUi::Server::SSE::Stream.headers
226
+ headers["content-type"].should == "text/event-stream"
227
+ headers["cache-control"].should == "no-cache"
228
+ headers.frozen?.should == false
229
+ end
230
+
231
+ it "is a ::Protocol::HTTP::Body::Readable" do
232
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
233
+ stream.is_a?(::Protocol::HTTP::Body::Readable).should == true
234
+ end
235
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ class Server
8
+ # Resolves the incoming request to an AG-UI operation, mirroring the
9
+ # Node runtime's suffix-matching fetch-router (doc 09 §2):
10
+ #
11
+ # GET …/info → "info"
12
+ # POST …/agent/:agentId/run → "run" (body = RunAgentInput)
13
+ # POST …/agent/:agentId/connect → "connect" (body tolerated, not required)
14
+ # POST …/agent/:agentId/stop/:tid → "stop"
15
+ # POST <mount root> → "run" (bare AG-UI endpoint —
16
+ # what @ag-ui/client's HttpAgent
17
+ # POSTs when given a plain URL)
18
+ # OPTIONS * → 204 (permissive CORS preflight)
19
+ #
20
+ # Sets env["ag_ui.operation"], env["ag_ui.agent_id"] and, for runs,
21
+ # env["ag_ui.input"] (parsed RunInput). Short-circuits 400 on a bad
22
+ # run body and 404 on unknown routes — downstream only ever sees a
23
+ # resolved operation.
24
+ #
25
+ # Works both mounted (Rails/Rack map set SCRIPT_NAME, PATH_INFO is
26
+ # relative) and standalone: matching is on trailing path segments.
27
+ class Triage
28
+ AGENT_ROUTE = %r{/agent/(?<agent_id>[^/]+)/(?<action>run|connect)\z}
29
+ STOP_ROUTE = %r{/agent/(?<agent_id>[^/]+)/stop/(?<thread_id>[^/]+)\z}
30
+ INFO_ROUTE = %r{(\A|/)info\z}
31
+
32
+ def initialize(app)
33
+ @app = app
34
+ end
35
+
36
+ def call(env)
37
+ method = env["REQUEST_METHOD"]
38
+ path = env["PATH_INFO"].to_s.chomp("/")
39
+
40
+ if method == "OPTIONS"
41
+ preflight
42
+ elsif method == "GET" && path.match?(INFO_ROUTE)
43
+ @app.call(env.merge("ag_ui.operation" => "info"))
44
+ elsif method == "POST" && (m = path.match(STOP_ROUTE))
45
+ @app.call(
46
+ env.merge(
47
+ "ag_ui.operation" => "stop",
48
+ "ag_ui.agent_id" => m[:agent_id],
49
+ "ag_ui.thread_id" => m[:thread_id],
50
+ ),
51
+ )
52
+ elsif method == "POST" && (m = path.match(AGENT_ROUTE))
53
+ dispatch_agent(env, m)
54
+ elsif method == "POST" && path.empty?
55
+ dispatch_bare_run(env)
56
+ else
57
+ not_found
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def dispatch_bare_run(env)
64
+ input = parse_input(env, required: true)
65
+
66
+ @app.call(
67
+ env.merge(
68
+ "ag_ui.operation" => "run",
69
+ "ag_ui.agent_id" => "default",
70
+ "ag_ui.input" => input,
71
+ ),
72
+ )
73
+ rescue RunInput::InvalidError => e
74
+ bad_request(e.message)
75
+ end
76
+
77
+ def dispatch_agent(env, match)
78
+ input = parse_input(env, required: match[:action] == "run")
79
+
80
+ @app.call(
81
+ env.merge(
82
+ "ag_ui.operation" => match[:action],
83
+ "ag_ui.agent_id" => match[:agent_id],
84
+ "ag_ui.input" => input,
85
+ ),
86
+ )
87
+ rescue RunInput::InvalidError => e
88
+ bad_request(e.message)
89
+ end
90
+
91
+ # /run requires a valid RunAgentInput; /connect bodies are only
92
+ # parsed opportunistically (the stub ignores them).
93
+ def parse_input(env, required:)
94
+ body = env["rack.input"]&.read.to_s
95
+
96
+ if required
97
+ RunInput.parse(body)
98
+ else
99
+ begin
100
+ RunInput.parse(body)
101
+ rescue RunInput::InvalidError
102
+ nil
103
+ end
104
+ end
105
+ end
106
+
107
+ def preflight
108
+ [204, {
109
+ "access-control-allow-origin" => "*",
110
+ "access-control-allow-methods" => "GET, POST, OPTIONS",
111
+ "access-control-allow-headers" => "*",
112
+ }, []]
113
+ end
114
+
115
+ def bad_request(details)
116
+ [400, { "content-type" => "application/json" },
117
+ [JSON.generate({ "error" => "Invalid request body", "details" => details })]]
118
+ end
119
+
120
+ def not_found
121
+ [404, { "content-type" => "application/json" },
122
+ [JSON.generate({ "error" => "Not found" })]]
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ __END__
129
+
130
+ describe "AgUi::Server::Triage" do
131
+ minimal_input = JSON.generate({
132
+ "threadId" => "t1", "runId" => "r1", "state" => nil,
133
+ "messages" => [], "tools" => [], "context" => [], "forwardedProps" => nil,
134
+ })
135
+
136
+ seen = nil
137
+ app = ->(env) { seen = env; [200, {}, ["ok"]] }
138
+ triage = AgUi::Server::Triage.new(app)
139
+
140
+ request = ->(method, path, body: "") do
141
+ seen = nil
142
+ triage.call({
143
+ "REQUEST_METHOD" => method,
144
+ "PATH_INFO" => path,
145
+ "rack.input" => StringIO.new(body),
146
+ })
147
+ end
148
+
149
+ it "resolves GET /info" do
150
+ request.("GET", "/info")
151
+ seen["ag_ui.operation"].should == "info"
152
+ end
153
+
154
+ it "resolves /info under a mount prefix" do
155
+ request.("GET", "/api/copilotkit/info")
156
+ seen["ag_ui.operation"].should == "info"
157
+ end
158
+
159
+ it "resolves POST /agent/:id/run with a parsed input" do
160
+ request.("POST", "/agent/default/run", body: minimal_input)
161
+ seen["ag_ui.operation"].should == "run"
162
+ seen["ag_ui.agent_id"].should == "default"
163
+ seen["ag_ui.input"].thread_id.should == "t1"
164
+ end
165
+
166
+ it "resolves POST /agent/:id/stop/:threadId" do
167
+ request.("POST", "/agent/default/stop/t99")
168
+ seen["ag_ui.operation"].should == "stop"
169
+ seen["ag_ui.thread_id"].should == "t99"
170
+ end
171
+
172
+ it "resolves POST /agent/:id/connect without requiring a valid body" do
173
+ request.("POST", "/agent/default/connect", body: "")
174
+ seen["ag_ui.operation"].should == "connect"
175
+ seen["ag_ui.input"].should.be.nil
176
+ end
177
+
178
+ it "returns 400 with details for a bad run body" do
179
+ status, _headers, body = request.("POST", "/agent/default/run", body: "{}")
180
+ seen.should.be.nil
181
+ status.should == 400
182
+ parsed = JSON.parse(body.first)
183
+ parsed["error"].should == "Invalid request body"
184
+ parsed["details"].should.include?("threadId")
185
+ end
186
+
187
+ it "resolves a bare POST to the mount root as a run (HttpAgent shape)" do
188
+ request.("POST", "/", body: minimal_input)
189
+ seen["ag_ui.operation"].should == "run"
190
+ seen["ag_ui.agent_id"].should == "default"
191
+ seen["ag_ui.input"].thread_id.should == "t1"
192
+
193
+ status, = request.("POST", "/", body: "{}")
194
+ status.should == 400
195
+ end
196
+
197
+ it "returns 404 for unknown routes and wrong methods" do
198
+ request.("GET", "/agent/default/run")[0].should == 404
199
+ request.("POST", "/nope")[0].should == 404
200
+ seen.should.be.nil
201
+ end
202
+
203
+ it "answers OPTIONS preflight with 204" do
204
+ status, headers, _body = request.("OPTIONS", "/agent/default/run")
205
+ status.should == 204
206
+ headers["access-control-allow-origin"].should == "*"
207
+ end
208
+ end