ask-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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a496b953fbbbf38bde6bfb2cc1771fc67d8b47a8ed6789ef453e18b8a7d68161
4
+ data.tar.gz: 94edd256f17e7b5de58a3de085d2d857adc06ec35bb58e83d1e9f92feac457bc
5
+ SHA512:
6
+ metadata.gz: 711674f104ef94ed5c9e44293781b74970beaf08222cec1e42865bf304b70e8a94ce80849c0f1884f5d4be254d841dfb85547d610d15892d23c61f17bbe76496
7
+ data.tar.gz: cafe0507a38288621887eb3d31cb663676326d4da15814fd4716cd16fd2c045d9587347ed97c421cb71ecf979a6add14775b5bdc52a34f829f5ddca3f1a923af
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to ask-ag-ui are documented here, following
4
+ the keep-a-changelog format.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] — 2026-09-28
9
+
10
+ ### Added
11
+
12
+ - `Ask::AGUI::Emitter` — the seam that turns ask-rb agent/session events
13
+ into AG-UI protocol frames. Given the run context (thread id, run id,
14
+ input messages) and a stream of duck-typed agent events, it emits
15
+ `RUN_STARTED`, the text/reasoning/tool-call chains, `RUN_FINISHED` /
16
+ `RUN_ERROR`, and a generic `CUSTOM` passthrough for app-defined events.
17
+ All frames are built with `AgUiProtocol::Core::Events::*` and encoded
18
+ with `AgUiProtocol::Encoder::EventEncoder`. Adds the `ag-ui-protocol`
19
+ runtime dependency.
20
+ - `Ask::AGUI::Server` — the mountable Rack surface AG-UI clients expect:
21
+ `GET /info` (agents map with name, class, and capability information
22
+ built from ag-ui-protocol's capability and identity types, plus the
23
+ transport mode), `POST /agent/:id/run` (parses the run input, drives one
24
+ `Emitter` for the run, streams its SSE frames as the host block's agent
25
+ events arrive; malformed input answers 400 JSON),
26
+ `POST /agent/:id/connect` (replays recorded frames, completing
27
+ immediately when there is nothing to replay), and
28
+ `POST /agent/:id/stop/:thread_id` (JSON acknowledgement with cooperative
29
+ cancel). The host supplies the work as a block; the gem owns the socket
30
+ and the framing, with no Rails or ask-agent dependency. Plain Rack 3 and
31
+ plain Ruby — an enumerable body, no async or falcon requirement.
32
+ - `Ask::AGUI::Run` — the parsed run input handed to the host block
33
+ (thread id, run id, messages, tools, context, forwarded props, state),
34
+ with best-effort coercion to `AgUiProtocol::Core::Types`.
35
+ - `Ask::AGUI::RunStore` — the run-store interface plus the in-memory
36
+ implementation backing replay and stop. In-memory frames live in one
37
+ process only; back replay and stop with a shared store past one process.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # ask-ag-ui
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/ask-ag-ui.svg)](https://badge.fury.io/rb/ask-ag-ui)
4
+
5
+ The AG-UI (Agent-User Interaction) protocol server for the ask-rb ecosystem — SSE event streaming and the runtime surface that assistant-ui and CopilotKit frontends expect.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "ask-ag-ui"
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ruby
16
+ require "ask-ag-ui"
17
+
18
+ Ask::AGUI::VERSION # => "0.1.0"
19
+ ```
20
+
21
+ ## The seam: `Ask::AGUI::Emitter`
22
+
23
+ The emitter turns ask-rb agent/session events into AG-UI protocol frames.
24
+ A transport drives one emitter per run: it hands over the AG-UI run context,
25
+ feeds it agent events, and writes the SSE frames it answers to the stream.
26
+ The transport owns the socket — the emitter only translates and encodes.
27
+
28
+ ```ruby
29
+ require "ask-ag-ui"
30
+
31
+ messages = [AgUiProtocol::Core::Types::UserMessage.new(id: "u1", content: "Hi")]
32
+ emitter = Ask::AGUI::Emitter.new(thread_id: "t1", run_id: "r1", messages: messages)
33
+
34
+ emitter.handle(turn_start_event).each { |frame| stream.write(frame) }
35
+ # => ["data: {\"type\":\"RUN_STARTED\",...}\n\n", ...]
36
+
37
+ emitter.finish.each { |frame| stream.write(frame) }
38
+ # => ["data: {\"type\":\"RUN_FINISHED\",...}\n\n"]
39
+ ```
40
+
41
+ The vocabulary, matched by class name so the emitter never depends on
42
+ ask-agent internals:
43
+
44
+ | Agent event | AG-UI events |
45
+ |---|---|
46
+ | `TurnStart` | `RUN_STARTED` |
47
+ | `TextDelta` | `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` → `TEXT_MESSAGE_END` (only empty deltas dropped — a space is content) |
48
+ | `ThinkingDelta` | `REASONING_START` → `REASONING_MESSAGE_START` → `REASONING_MESSAGE_CONTENT` → `REASONING_MESSAGE_END` → `REASONING_END` (empty deltas dropped) |
49
+ | `ToolCallDelta`, `ToolExecutionStart`, `ToolExecutionEnd` | `TOOL_CALL_START` → `TOOL_CALL_ARGS` → `TOOL_CALL_END` → `TOOL_CALL_RESULT` (empty args deltas dropped) |
50
+ | `SessionEnd` / `#finish` | `RUN_FINISHED` |
51
+ | `Error` / `#fail` | `RUN_ERROR` |
52
+ | anything else | one generic `CUSTOM` passthrough (`name` + `value`) |
53
+
54
+ Every frame is built with `AgUiProtocol::Core::Events::*` and encoded with
55
+ `AgUiProtocol::Encoder::EventEncoder` — event JSON is never hand-rolled.
56
+
57
+ ## Mounting: `Ask::AGUI::Server`
58
+
59
+ The server is the conventional Rack surface AG-UI clients expect. The host
60
+ owns the agent and answers agent events; the gem owns the socket, the
61
+ framing, and one `Emitter` per run. Plain Rack 3 with an enumerable body —
62
+ it runs under any Rack server, with no Rails, ask-agent, or async
63
+ dependency.
64
+
65
+ ```ruby
66
+ require "ask-ag-ui"
67
+
68
+ app = Ask::AGUI::Server.new(agent_id: "default") do |run|
69
+ # run.thread_id, run.run_id, run.messages, run.tools,
70
+ # run.context, run.forwarded_props — answer agent events:
71
+ [TurnStart.new, TextDelta.new(content: "Hello")]
72
+ end
73
+ ```
74
+
75
+ Rackup:
76
+
77
+ ```ruby
78
+ # config.ru
79
+ require "ask-ag-ui"
80
+
81
+ run Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) }
82
+ ```
83
+
84
+ Rails:
85
+
86
+ ```ruby
87
+ # config/routes.rb
88
+ mount Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) },
89
+ at: "/api/copilotkit"
90
+ ```
91
+
92
+ Routes: `GET /info`, `POST /agent/:id/run` (SSE), `POST /agent/:id/connect`
93
+ (replays recorded frames, or an immediately-completed empty stream when
94
+ there is nothing to replay), `POST /agent/:id/stop/:thread_id` (JSON ack).
95
+ Malformed run input answers `400` with a JSON error body.
96
+
97
+ One curl example (run a thread through the stub above):
98
+
99
+ ```
100
+ curl -N -X POST http://localhost:9292/agent/default/run \
101
+ -H 'Content-Type: application/json' \
102
+ -d '{"threadId":"t1","runId":"r1","messages":[{"id":"u1","role":"user","content":"Hi"}],"tools":[],"context":[]}'
103
+ ```
104
+
105
+ The in-memory run store keeps frames in this process only — replay and
106
+ stop need a shared store once you run more than one process.
107
+
108
+ ## Development
109
+
110
+ ```
111
+ bundle install
112
+ bundle exec rake test
113
+ ```
114
+
115
+ The suite validates every emitted SSE frame against the AG-UI protocol's
116
+ canonical JSON Schema, vendored at `test/fixtures/ag_ui.json` (generated
117
+ from the reference Python SDK; the copy came from the reference
118
+ implementation's `data/ag_ui.json`). See `test/fixtures/README.md` for
119
+ provenance and refresh instructions. The fixture resolves through a
120
+ repo-relative path, so a fresh clone needs nothing outside the repo — and
121
+ a missing fixture fails loudly (`ENOENT`) rather than skipping validation.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "ag_ui_protocol"
6
+
7
+ module Ask
8
+ module AGUI
9
+ # The seam between ask-rb agent/session events and the AG-UI protocol.
10
+ #
11
+ # A transport drives one Emitter per run: it hands the emitter the AG-UI
12
+ # run context (thread id, run id, input messages), feeds it the stream of
13
+ # agent events via {#handle}, and collects the SSE frames it returns.
14
+ # The transport owns the socket; the emitter only translates and encodes.
15
+ #
16
+ # emitter = Ask::AGUI::Emitter.new(thread_id: "t1", run_id: "r1", messages: [...])
17
+ # frames = emitter.handle(agent_event) # => ["data: {...}\n\n", ...]
18
+ # frames = emitter.finish # => ["data: {\"type\":\"RUN_FINISHED\",...}\n\n"]
19
+ #
20
+ # Event vocabulary (matched by class name, so the emitter never depends
21
+ # on ask-agent — any duck-typed object with the same shape works):
22
+ #
23
+ # * `TurnStart` / `SessionStart` → `RUN_STARTED` (first one wins)
24
+ # * `TextDelta` (`content`) → `TEXT_MESSAGE_START`, then
25
+ # `TEXT_MESSAGE_CONTENT` per delta (only empty deltas are dropped — a
26
+ # space is content, and dropping it would fuse the words around it),
27
+ # then `TEXT_MESSAGE_END` on `MessageEnd`
28
+ # * `ThinkingDelta` (`content`) → `REASONING_START` →
29
+ # `REASONING_MESSAGE_START` → `REASONING_MESSAGE_CONTENT` per delta
30
+ # (empty deltas dropped) → `REASONING_MESSAGE_END` → `REASONING_END`
31
+ # on `MessageEnd`
32
+ # * `ToolCallDelta` (`name`, `arguments`, `id`) → `TOOL_CALL_START`
33
+ # (once per id) → `TOOL_CALL_ARGS` per non-empty delta;
34
+ # `ToolExecutionStart` opens calls the stream never announced;
35
+ # `MessageEnd` closes open calls with `TOOL_CALL_END`;
36
+ # `ToolExecutionEnd` (`name`, `id`, `result`, …) closes the call if
37
+ # still open and emits `TOOL_CALL_RESULT`
38
+ # * `SessionEnd` → `RUN_FINISHED` (first one wins); `Error` (`error`) →
39
+ # `RUN_ERROR`. {#finish} and {#fail} drive the same endings manually.
40
+ # * Anything else rides one generic `CUSTOM` passthrough
41
+ # (`name` = event class name, `value` = its `to_h`): the emitter knows
42
+ # nothing about any chat application's specific states.
43
+ #
44
+ # `MessageEnd` and `TurnEnd` carry no AG-UI counterpart of their own —
45
+ # they only close whatever text, reasoning, or tool call is still open.
46
+ #
47
+ # Every frame is built with `AgUiProtocol::Core::Events::*` and encoded
48
+ # with `AgUiProtocol::Encoder::EventEncoder` — event JSON is never
49
+ # hand-rolled. Each {#handle}/{#start}/{#finish}/{#fail} call answers an
50
+ # array of `"data: <json>\n\n"` SSE frame strings (possibly empty).
51
+ class Emitter
52
+ # @return [String] the AG-UI thread this emitter streams for.
53
+ attr_reader :thread_id
54
+
55
+ # @return [String] the AG-UI run this emitter streams for.
56
+ attr_reader :run_id
57
+
58
+ # @param thread_id [String] AG-UI thread id (echoed on RUN_STARTED/RUN_FINISHED).
59
+ # @param run_id [String] AG-UI run id (echoed on RUN_STARTED/RUN_FINISHED).
60
+ # @param messages [Array<AgUiProtocol::Core::Types::BaseMessage,
61
+ # AgUiProtocol::Core::Types::ActivityMessage>] input messages carried
62
+ # on the RUN_STARTED event.
63
+ def initialize(thread_id:, run_id:, messages: [])
64
+ @thread_id = thread_id
65
+ @run_id = run_id
66
+ @input = AgUiProtocol::Core::Types::RunAgentInput.new(
67
+ thread_id: thread_id,
68
+ run_id: run_id,
69
+ state: {},
70
+ messages: messages,
71
+ tools: [],
72
+ context: [],
73
+ forwarded_props: {}
74
+ )
75
+ @encoder = AgUiProtocol::Encoder::EventEncoder.new
76
+ @started = false
77
+ @terminal = false
78
+ @text_message_id = nil
79
+ @reasoning_message_id = nil
80
+ @seen_tool_calls = []
81
+ @open_tool_calls = []
82
+ end
83
+
84
+ # Feed one agent event through the translator.
85
+ #
86
+ # @param event [Object] a duck-typed agent event (see class docs).
87
+ # @return [Array<String>] SSE frames to write to the stream, in order.
88
+ def handle(event)
89
+ case event_name(event)
90
+ when "TurnStart", "SessionStart"
91
+ start
92
+ when "TextDelta"
93
+ handle_text_delta(event)
94
+ when "ThinkingDelta"
95
+ handle_reasoning_delta(event)
96
+ when "ToolCallDelta"
97
+ handle_tool_call_delta(event)
98
+ when "ToolExecutionStart"
99
+ handle_tool_execution_start(event)
100
+ when "ToolExecutionEnd"
101
+ handle_tool_execution_end(event)
102
+ when "MessageEnd", "TurnEnd"
103
+ close_open_messages
104
+ when "SessionEnd"
105
+ finish
106
+ when "Error"
107
+ fail_with(event_error(event))
108
+ else
109
+ handle_custom(event)
110
+ end
111
+ end
112
+
113
+ # Open the run. Idempotent: only the first call emits RUN_STARTED.
114
+ #
115
+ # @return [Array<String>] zero or one SSE frames.
116
+ def start
117
+ return [] if @started
118
+
119
+ @started = true
120
+ [encode(AgUiProtocol::Core::Events::RunStartedEvent.new(
121
+ thread_id: @thread_id, run_id: @run_id, input: @input
122
+ ))]
123
+ end
124
+
125
+ # Close the run normally. Closes any open message first, then emits
126
+ # RUN_FINISHED once — late calls only close stragglers.
127
+ #
128
+ # @return [Array<String>] SSE frames to write to the stream, in order.
129
+ def finish
130
+ frames = close_open_messages
131
+ return frames if @terminal
132
+
133
+ @terminal = true
134
+ frames << encode(AgUiProtocol::Core::Events::RunFinishedEvent.new(
135
+ thread_id: @thread_id, run_id: @run_id
136
+ ))
137
+ frames
138
+ end
139
+
140
+ # Close the run with an error. Accepts an exception, a message string,
141
+ # or a duck-typed `Error` event (anything answering `error`).
142
+ # Idempotent like {#finish}; after a terminal event the run stays shut.
143
+ #
144
+ # @param reason [Exception, String, Object] what went wrong.
145
+ # @return [Array<String>] SSE frames to write to the stream, in order.
146
+ def fail(reason)
147
+ fail_with(reason)
148
+ end
149
+
150
+ private
151
+
152
+ def fail_with(reason)
153
+ frames = close_open_messages
154
+ return frames if @terminal
155
+
156
+ @terminal = true
157
+ frames << encode(AgUiProtocol::Core::Events::RunErrorEvent.new(message: error_message(reason)))
158
+ frames
159
+ end
160
+
161
+ def handle_text_delta(event)
162
+ frames = start
163
+ delta = event.respond_to?(:content) ? event.content : nil
164
+ return frames if delta.to_s.empty?
165
+
166
+ unless @text_message_id
167
+ @text_message_id = SecureRandom.uuid
168
+ frames << encode(AgUiProtocol::Core::Events::TextMessageStartEvent.new(message_id: @text_message_id))
169
+ end
170
+ frames << encode(AgUiProtocol::Core::Events::TextMessageContentEvent.new(
171
+ message_id: @text_message_id, delta: delta.to_s
172
+ ))
173
+ frames
174
+ end
175
+
176
+ def handle_reasoning_delta(event)
177
+ frames = start
178
+ delta = event.respond_to?(:content) ? event.content : nil
179
+ return frames if delta.to_s.empty?
180
+
181
+ unless @reasoning_message_id
182
+ @reasoning_message_id = SecureRandom.uuid
183
+ frames << encode(AgUiProtocol::Core::Events::ReasoningStartEvent.new(message_id: @reasoning_message_id))
184
+ frames << encode(AgUiProtocol::Core::Events::ReasoningMessageStartEvent.new(
185
+ message_id: @reasoning_message_id
186
+ ))
187
+ end
188
+ frames << encode(AgUiProtocol::Core::Events::ReasoningMessageContentEvent.new(
189
+ message_id: @reasoning_message_id, delta: delta.to_s
190
+ ))
191
+ frames
192
+ end
193
+
194
+ def handle_tool_call_delta(event)
195
+ frames = start
196
+ id = event_id(event)
197
+ open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
198
+ args = args_delta(event_arguments(event))
199
+ frames << encode(AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: id, delta: args)) if args
200
+ frames
201
+ end
202
+
203
+ def handle_tool_execution_start(event)
204
+ frames = start
205
+ id = event_id(event)
206
+ return frames if @seen_tool_calls.include?(id)
207
+
208
+ open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
209
+ args = args_delta(event_arguments(event))
210
+ frames << encode(AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: id, delta: args)) if args
211
+ frames
212
+ end
213
+
214
+ def handle_tool_execution_end(event)
215
+ frames = start
216
+ id = event_id(event)
217
+ unless @seen_tool_calls.include?(id)
218
+ open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
219
+ end
220
+ if @open_tool_calls.delete(id)
221
+ frames << encode(AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: id))
222
+ end
223
+ frames << encode(AgUiProtocol::Core::Events::ToolCallResultEvent.new(
224
+ message_id: SecureRandom.uuid, tool_call_id: id, content: result_content(event_result(event)), role: "tool"
225
+ ))
226
+ frames
227
+ end
228
+
229
+ def handle_custom(event)
230
+ value = event.respond_to?(:to_h) ? event.to_h : {}
231
+ value = {} if value.nil?
232
+ [encode(AgUiProtocol::Core::Events::CustomEvent.new(name: event_name(event), value: value))]
233
+ end
234
+
235
+ def close_open_messages
236
+ frames = []
237
+ if @reasoning_message_id
238
+ frames << encode(AgUiProtocol::Core::Events::ReasoningMessageEndEvent.new(message_id: @reasoning_message_id))
239
+ frames << encode(AgUiProtocol::Core::Events::ReasoningEndEvent.new(message_id: @reasoning_message_id))
240
+ @reasoning_message_id = nil
241
+ end
242
+ if @text_message_id
243
+ frames << encode(AgUiProtocol::Core::Events::TextMessageEndEvent.new(message_id: @text_message_id))
244
+ @text_message_id = nil
245
+ end
246
+ @open_tool_calls.each do |id|
247
+ frames << encode(AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: id))
248
+ end
249
+ @open_tool_calls = []
250
+ frames
251
+ end
252
+
253
+ def open_tool_call(frames, id:, name:, parent_message_id:)
254
+ return if @seen_tool_calls.include?(id)
255
+
256
+ @seen_tool_calls << id
257
+ @open_tool_calls << id
258
+ frames << encode(AgUiProtocol::Core::Events::ToolCallStartEvent.new(
259
+ tool_call_id: id, tool_call_name: name, parent_message_id: parent_message_id
260
+ ))
261
+ end
262
+
263
+ def encode(event)
264
+ @encoder.encode(event)
265
+ end
266
+
267
+ def event_name(event)
268
+ event.class.name.to_s.split("::").last
269
+ end
270
+
271
+ def event_id(event)
272
+ event.respond_to?(:id) ? event.id.to_s : SecureRandom.uuid
273
+ end
274
+
275
+ def event_name_or(event, fallback)
276
+ name = event.respond_to?(:name) ? event.name : nil
277
+ name = nil if name.to_s.empty?
278
+ name ? name.to_s : fallback
279
+ end
280
+
281
+ def event_arguments(event)
282
+ event.respond_to?(:arguments) ? event.arguments : nil
283
+ end
284
+
285
+ def event_result(event)
286
+ event.respond_to?(:result) ? event.result : nil
287
+ end
288
+
289
+ def event_error(event)
290
+ event.respond_to?(:error) ? event.error : event
291
+ end
292
+
293
+ def error_message(reason)
294
+ if reason.is_a?(Exception)
295
+ reason.message
296
+ elsif reason.respond_to?(:error)
297
+ reason.error.to_s
298
+ else
299
+ reason.to_s
300
+ end
301
+ end
302
+
303
+ # Tool arguments arrive as a JSON string or a Hash — the wire wants a
304
+ # string delta. Answers nil when there is nothing to send.
305
+ def args_delta(arguments)
306
+ return nil if arguments.nil?
307
+ return nil if arguments.respond_to?(:empty?) && arguments.empty?
308
+
309
+ delta = arguments.is_a?(String) ? arguments : JSON.generate(arguments)
310
+ delta.empty? ? nil : delta
311
+ end
312
+
313
+ # Tool results arrive as the executor's legacy hash ({message:, result:,
314
+ # …}), a plain string, or a duck-typed object — the wire wants a string.
315
+ def result_content(result)
316
+ return result.to_s if result.nil? || result.is_a?(String)
317
+
318
+ hash = result_hash(result)
319
+ if hash
320
+ content = hash[:message] || hash["message"] || hash[:result] || hash["result"]
321
+ return content.to_s unless content.nil?
322
+ end
323
+ result.to_s
324
+ end
325
+
326
+ def result_hash(result)
327
+ return result if result.is_a?(Hash)
328
+ return result.to_h if result.respond_to?(:to_h)
329
+
330
+ nil
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ag_ui_protocol"
5
+
6
+ module Ask
7
+ module AGUI
8
+ # The parsed AG-UI run input for one `POST /agent/:id/run`.
9
+ #
10
+ # The server parses the request body into a Run and hands it to the
11
+ # host's block. The host reads what it needs (messages, tools, context,
12
+ # forwarded props) and answers the stream of duck-typed agent events;
13
+ # the server drives one {Emitter} with those events and owns the socket.
14
+ #
15
+ # Message, tool, and context entries are coerced to their
16
+ # `AgUiProtocol::Core::Types` counterparts on a best-effort basis:
17
+ # entries that do not coerce are skipped, never fatal. `state` and
18
+ # `forwarded_props` are opaque user data and ride through verbatim.
19
+ class Run
20
+ # Raised on malformed JSON or a missing `threadId` / `runId`. The
21
+ # server renders this as `400 {"error": ..., "details": ...}`.
22
+ class InvalidError < StandardError; end
23
+
24
+ # @return [String] the `:id` segment of `/agent/:id/run`.
25
+ attr_reader :agent_id
26
+
27
+ # @return [String] the AG-UI thread id.
28
+ attr_reader :thread_id
29
+
30
+ # @return [String] the AG-UI run id.
31
+ attr_reader :run_id
32
+
33
+ # @return [Array<AgUiProtocol::Core::Types::BaseMessage,
34
+ # AgUiProtocol::Core::Types::ActivityMessage>] coerced input messages.
35
+ attr_reader :messages
36
+
37
+ # @return [Array<AgUiProtocol::Core::Types::Tool>] coerced tools.
38
+ attr_reader :tools
39
+
40
+ # @return [Array<AgUiProtocol::Core::Types::Context>] coerced context.
41
+ attr_reader :context
42
+
43
+ # @return [Object] opaque forwarded props, verbatim from the client.
44
+ attr_reader :forwarded_props
45
+
46
+ # @return [Object] opaque agent state, verbatim from the client.
47
+ attr_reader :state
48
+
49
+ # @return [Hash] the raw parsed body.
50
+ attr_reader :raw
51
+
52
+ # @param agent_id [String]
53
+ # @param thread_id [String]
54
+ # @param run_id [String]
55
+ # @param messages [Array]
56
+ # @param tools [Array]
57
+ # @param context [Array]
58
+ # @param forwarded_props [Object]
59
+ # @param state [Object]
60
+ # @param raw [Hash]
61
+ def initialize(agent_id:, thread_id:, run_id:, messages: [], tools: [],
62
+ context: [], forwarded_props: nil, state: nil, raw: {})
63
+ @agent_id = agent_id
64
+ @thread_id = thread_id
65
+ @run_id = run_id
66
+ @messages = messages
67
+ @tools = tools
68
+ @context = context
69
+ @forwarded_props = forwarded_props
70
+ @state = state
71
+ @raw = raw
72
+ end
73
+
74
+ # Parse a `POST /agent/:id/run` body into a Run.
75
+ #
76
+ # @param agent_id [String] the `:id` segment of the request path.
77
+ # @param body [String] the raw request body.
78
+ # @return [Run]
79
+ # @raise [InvalidError] when the body is not a JSON object carrying
80
+ # string `threadId` and `runId` members.
81
+ def self.parse(agent_id, body)
82
+ raw = begin
83
+ JSON.parse(body.to_s)
84
+ rescue JSON::ParserError => e
85
+ raise InvalidError, "malformed JSON: #{e.message}"
86
+ end
87
+
88
+ unless raw.is_a?(Hash)
89
+ raise InvalidError, "expected a JSON object, got #{raw.class}"
90
+ end
91
+
92
+ thread_id = raw["threadId"] || raw["thread_id"]
93
+ run_id = raw["runId"] || raw["run_id"]
94
+
95
+ unless thread_id.is_a?(String) && !thread_id.empty?
96
+ raise InvalidError, "missing required member \"threadId\""
97
+ end
98
+
99
+ unless run_id.is_a?(String) && !run_id.empty?
100
+ raise InvalidError, "missing required member \"runId\""
101
+ end
102
+
103
+ new(
104
+ agent_id: agent_id.to_s,
105
+ thread_id: thread_id,
106
+ run_id: run_id,
107
+ messages: coerce_messages(raw["messages"]),
108
+ tools: coerce_tools(raw["tools"]),
109
+ context: coerce_context(raw["context"]),
110
+ forwarded_props: raw.key?("forwardedProps") ? raw["forwardedProps"] : raw["forwarded_props"],
111
+ state: raw["state"],
112
+ raw: raw
113
+ )
114
+ end
115
+
116
+ # Best-effort message coercion. Unknown roles and malformed entries
117
+ # are skipped so one bad message cannot fail the run.
118
+ #
119
+ # @param value [Object]
120
+ # @return [Array]
121
+ def self.coerce_messages(value)
122
+ return [] unless value.is_a?(Array)
123
+
124
+ value.filter_map do |entry|
125
+ coerce_message(entry)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+ end
130
+
131
+ # Coerce one raw message hash to its protocol type.
132
+ #
133
+ # @param entry [Object]
134
+ # @return [AgUiProtocol::Core::Types::Model, nil]
135
+ def self.coerce_message(entry)
136
+ return nil unless entry.is_a?(Hash)
137
+
138
+ types = AgUiProtocol::Core::Types
139
+ id = (entry["id"] || entry[:id]).to_s
140
+ return nil if id.empty?
141
+
142
+ case entry["role"] || entry[:role]
143
+ when "user"
144
+ types::UserMessage.new(id: id, content: entry["content"] || entry[:content] || "")
145
+ when "assistant"
146
+ types::AssistantMessage.new(
147
+ id: id,
148
+ content: entry["content"] || entry[:content],
149
+ tool_calls: entry["toolCalls"] || entry["tool_calls"] || entry[:tool_calls]
150
+ )
151
+ when "system"
152
+ types::SystemMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
153
+ when "developer"
154
+ types::DeveloperMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
155
+ when "tool"
156
+ tool_call_id = entry["toolCallId"] || entry["tool_call_id"] || entry[:tool_call_id]
157
+ return nil if tool_call_id.to_s.empty?
158
+
159
+ types::ToolMessage.new(
160
+ id: id,
161
+ content: (entry["content"] || entry[:content]).to_s,
162
+ tool_call_id: tool_call_id.to_s
163
+ )
164
+ when "activity"
165
+ content = entry["content"] || entry[:content]
166
+ return nil unless content.is_a?(Hash)
167
+
168
+ types::ActivityMessage.new(
169
+ id: id,
170
+ activity_type: (entry["activityType"] || entry["activity_type"] || "activity").to_s,
171
+ content: content
172
+ )
173
+ when "reasoning"
174
+ types::ReasoningMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
175
+ end
176
+ rescue StandardError
177
+ nil
178
+ end
179
+
180
+ # @param value [Object]
181
+ # @return [Array<AgUiProtocol::Core::Types::Tool>]
182
+ def self.coerce_tools(value)
183
+ return [] unless value.is_a?(Array)
184
+
185
+ types = AgUiProtocol::Core::Types
186
+ value.filter_map do |entry|
187
+ next unless entry.is_a?(Hash)
188
+
189
+ name = entry["name"] || entry[:name]
190
+ description = entry["description"] || entry[:description]
191
+ parameters = entry["parameters"] || entry[:parameters]
192
+ next if name.to_s.empty?
193
+
194
+ types::Tool.new(
195
+ name: name.to_s,
196
+ description: description.to_s,
197
+ parameters: parameters.nil? ? {} : parameters
198
+ )
199
+ rescue StandardError
200
+ nil
201
+ end
202
+ end
203
+
204
+ # @param value [Object]
205
+ # @return [Array<AgUiProtocol::Core::Types::Context>]
206
+ def self.coerce_context(value)
207
+ return [] unless value.is_a?(Array)
208
+
209
+ types = AgUiProtocol::Core::Types
210
+ value.filter_map do |entry|
211
+ next unless entry.is_a?(Hash)
212
+
213
+ description = entry["description"] || entry[:description]
214
+ content = entry["value"] || entry[:value]
215
+ next if description.to_s.empty? || content.nil?
216
+
217
+ types::Context.new(description: description.to_s, value: content.to_s)
218
+ rescue StandardError
219
+ nil
220
+ end
221
+ end
222
+
223
+ private_class_method :coerce_message
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module AGUI
5
+ # Per-thread run bookkeeping backing `POST /agent/:id/connect`
6
+ # (replay) and `POST /agent/:id/stop/:thread_id` (cooperative cancel).
7
+ #
8
+ # The interface is duck-typed: anything implementing {#begin_run},
9
+ # {#record}, {#finish_run}, {#replay}, {#request_stop} and
10
+ # {#stop_requested?} works as a store. A future shared store (Redis,
11
+ # database) can be dropped in without touching the server.
12
+ #
13
+ # store.begin_run("t1", "r1")
14
+ # store.record("t1", "data: {...}\n\n")
15
+ # store.finish_run("t1")
16
+ # store.replay("t1") # => ["data: {...}\n\n", ...]
17
+ # store.request_stop("t1") # => true
18
+ # store.stop_requested?("t1") # => true
19
+ module RunStore
20
+ # Single-process in-memory store.
21
+ #
22
+ # NOTE: frames live in this process's memory only. Two processes
23
+ # (two Puma workers, two machines) cannot see each other's runs, so
24
+ # `/connect` replay and `/stop` only work when the client keeps
25
+ # hitting the same process. Back replay and stop with a shared store
26
+ # when running more than one process.
27
+ #
28
+ # Mutex-guarded, so threads sharing one process are safe.
29
+ class InMemory
30
+ # Build an empty store.
31
+ def initialize
32
+ @mutex = Mutex.new
33
+ @frames = Hash.new { |hash, key| hash[key] = [] }
34
+ @stop_requested = {}
35
+ end
36
+
37
+ # Mark the start of a run. Clears any stale stop request for the
38
+ # thread so a previous `/stop` cannot cancel the new run.
39
+ #
40
+ # @param thread_id [String]
41
+ # @param run_id [String]
42
+ # @return [void]
43
+ def begin_run(thread_id, run_id)
44
+ @mutex.synchronize do
45
+ @stop_requested.delete(thread_id.to_s)
46
+ end
47
+ nil
48
+ end
49
+
50
+ # Append one SSE frame for later `/connect` replay.
51
+ #
52
+ # @param thread_id [String]
53
+ # @param frame [String] one `"data: <json>\n\n"` frame.
54
+ # @return [void]
55
+ def record(thread_id, frame)
56
+ @mutex.synchronize do
57
+ @frames[thread_id.to_s] << frame
58
+ end
59
+ nil
60
+ end
61
+
62
+ # Mark the run finished. Frames stay available for replay.
63
+ #
64
+ # @param thread_id [String]
65
+ # @return [void]
66
+ def finish_run(thread_id)
67
+ nil
68
+ end
69
+
70
+ # Answer every frame recorded for the thread, in order.
71
+ #
72
+ # @param thread_id [String]
73
+ # @return [Array<String>] possibly empty when nothing was recorded.
74
+ def replay(thread_id)
75
+ @mutex.synchronize do
76
+ @frames[thread_id.to_s].dup
77
+ end
78
+ end
79
+
80
+ # Flag the thread's run for cooperative cancel. The run loop
81
+ # checks {#stop_requested?} between events and ends the run early.
82
+ #
83
+ # @param thread_id [String]
84
+ # @return [true]
85
+ def request_stop(thread_id)
86
+ @mutex.synchronize do
87
+ @stop_requested[thread_id.to_s] = true
88
+ end
89
+ true
90
+ end
91
+
92
+ alias stop request_stop
93
+
94
+ # Whether {#request_stop} was called since the last {#begin_run}.
95
+ #
96
+ # @param thread_id [String]
97
+ # @return [Boolean]
98
+ def stop_requested?(thread_id)
99
+ @mutex.synchronize do
100
+ @stop_requested.fetch(thread_id.to_s, false)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ag_ui_protocol"
5
+
6
+ module Ask
7
+ module AGUI
8
+ # Mountable Rack application serving the AG-UI runtime surface.
9
+ #
10
+ # This is the conventional surface third-party AG-UI clients
11
+ # (assistant-ui, CopilotKit) expect when pointed at the gem:
12
+ #
13
+ # * `GET /info` — the runtime envelope: the agents map (name, class,
14
+ # capabilities), the transport mode, and the gem version.
15
+ # * `POST /agent/:id/run` — parses the run input, drives one {Emitter}
16
+ # for the run, and streams its SSE frames.
17
+ # * `POST /agent/:id/connect` — replays recorded frames, or completes
18
+ # immediately when there is nothing to replay.
19
+ # * `POST /agent/:id/stop/:thread_id` — flags the run for cooperative
20
+ # cancel and answers a JSON acknowledgement.
21
+ #
22
+ # The host supplies the work as a block and owns the agent; the gem
23
+ # owns the socket and the framing and never depends on Rails or
24
+ # ask-agent:
25
+ #
26
+ # app = Ask::AGUI::Server.new(agent_id: "default") do |run|
27
+ # # run.thread_id, run.run_id, run.messages, run.tools,
28
+ # # run.context, run.forwarded_props — answer agent events:
29
+ # [TurnStart.new, TextDelta.new(content: "Hello")]
30
+ # end
31
+ #
32
+ # The block answers an enumerable of duck-typed agent events (the same
33
+ # vocabulary {Emitter} handles). It may answer a lazy enumerator — the
34
+ # response body pulls events only as it streams, so the run streams
35
+ # under any Rack server. Every run opens with `RUN_STARTED` and closes
36
+ # with `RUN_FINISHED` / `RUN_ERROR`, even when the host answers no
37
+ # events or raises mid-run.
38
+ #
39
+ # Matching is on trailing path segments, so the app works standalone
40
+ # and mounted (`map("/api/copilotkit") { run app }`, Rails `mount`).
41
+ class Server
42
+ # Response headers for the SSE streams. Matches the reference
43
+ # runtime: `text/event-stream`, no caching, keep-alive.
44
+ SSE_HEADERS = {
45
+ "content-type" => "text/event-stream",
46
+ "cache-control" => "no-cache",
47
+ "connection" => "keep-alive",
48
+ "x-accel-buffering" => "no"
49
+ }.freeze
50
+
51
+ # Response headers for the JSON endpoints.
52
+ JSON_HEADERS = { "content-type" => "application/json" }.freeze
53
+
54
+ AGENT_RUN_ROUTE = %r{/agent/(?<agent_id>[^/]+)/run\z}
55
+ AGENT_CONNECT_ROUTE = %r{/agent/(?<agent_id>[^/]+)/connect\z}
56
+ AGENT_STOP_ROUTE = %r{/agent/(?<agent_id>[^/]+)/stop/(?<thread_id>[^/]+)\z}
57
+ INFO_ROUTE = %r{(?:\A|/)info\z}
58
+
59
+ # @return [String] the agent id advertised on `GET /info`.
60
+ attr_reader :agent_id
61
+
62
+ # Build the Rack application.
63
+ #
64
+ # @param agent_id [String] the agent name advertised on `GET /info`.
65
+ # @param description [String, nil] one-line agent description for discovery UIs.
66
+ # @param agent_class_name [String] the agent class named on `GET /info`.
67
+ # @param capabilities [AgUiProtocol::Core::Capabilities::AgentCapabilities, nil]
68
+ # capability information for `GET /info`. Defaults to what the
69
+ # {Emitter} actually drives: SSE transport, client-provided tools,
70
+ # streaming reasoning.
71
+ # @param store [Ask::AGUI::RunStore::InMemory, nil] run bookkeeping
72
+ # for `/connect` replay and `/stop`. Pass `nil` for the stateless
73
+ # behaviour (connect completes immediately, stop only acks).
74
+ # @yield [Run] the run input; answers an enumerable of agent events.
75
+ # @raise [ArgumentError] when no run-handler block is given.
76
+ def initialize(agent_id: "default", description: nil, agent_class_name: "BuiltInAgent",
77
+ capabilities: nil, store: RunStore::InMemory.new, &block)
78
+ raise ArgumentError, "Server requires a run-handler block" unless block
79
+
80
+ @agent_id = agent_id.to_s
81
+ @description = description
82
+ @agent_class_name = agent_class_name.to_s
83
+ @capabilities = capabilities || default_capabilities
84
+ @store = store
85
+ @handler = block
86
+ end
87
+
88
+ # Rack entrypoint.
89
+ #
90
+ # @param env [Hash] the Rack environment.
91
+ # @return [Array(Integer, Hash, Object)] the Rack response.
92
+ def call(env)
93
+ method = env["REQUEST_METHOD"].to_s
94
+ path = env["PATH_INFO"].to_s.chomp("/")
95
+
96
+ if method == "OPTIONS"
97
+ return preflight
98
+ elsif method == "GET" && path.match?(INFO_ROUTE)
99
+ return respond_info
100
+ elsif method == "POST" && (match = path.match(AGENT_STOP_ROUTE))
101
+ return respond_stop(match[:thread_id])
102
+ elsif method == "POST" && (match = path.match(AGENT_RUN_ROUTE))
103
+ return respond_run(env, match[:agent_id])
104
+ elsif method == "POST" && (match = path.match(AGENT_CONNECT_ROUTE))
105
+ return respond_connect(env)
106
+ end
107
+
108
+ not_found
109
+ end
110
+
111
+ private
112
+
113
+ def respond_info
114
+ [200, JSON_HEADERS.dup, [JSON.generate(info_payload)]]
115
+ end
116
+
117
+ # The capability information is built from ag-ui-protocol's
118
+ # capability and identity types — never hand-rolled.
119
+ def info_payload
120
+ agent = {
121
+ "name" => @agent_id,
122
+ "className" => @agent_class_name,
123
+ "capabilities" => @capabilities.as_json
124
+ }
125
+ agent["description"] = @description unless @description.nil?
126
+
127
+ {
128
+ "agents" => { @agent_id => agent },
129
+ "mode" => "sse",
130
+ "version" => VERSION
131
+ }
132
+ end
133
+
134
+ def default_capabilities
135
+ capabilities = AgUiProtocol::Core::Capabilities
136
+ capabilities::AgentCapabilities.new(
137
+ identity: capabilities::IdentityCapabilities.new(
138
+ name: @agent_id, description: @description, version: VERSION
139
+ ),
140
+ transport: capabilities::TransportCapabilities.new(streaming: true),
141
+ tools: capabilities::ToolsCapabilities.new(supported: true, client_provided: true),
142
+ reasoning: capabilities::ReasoningCapabilities.new(supported: true, streaming: true)
143
+ )
144
+ end
145
+
146
+ def respond_run(env, agent_id)
147
+ body = env["rack.input"]&.read.to_s
148
+ run = Run.parse(agent_id, body)
149
+ emitter = Emitter.new(thread_id: run.thread_id, run_id: run.run_id, messages: run.messages)
150
+ [200, SSE_HEADERS.dup, run_body(run, emitter)]
151
+ rescue Run::InvalidError => e
152
+ bad_request(e.message)
153
+ end
154
+
155
+ # The enumerable body pulls the host's events lazily, translates
156
+ # each through the emitter, and yields ready-to-write SSE frames —
157
+ # plain Rack 3 streaming with no async requirement.
158
+ def run_body(run, emitter)
159
+ server = self
160
+ Enumerator.new do |yielder|
161
+ server.send(:stream_run, run, emitter, yielder)
162
+ end
163
+ end
164
+
165
+ def stream_run(run, emitter, yielder)
166
+ @store&.begin_run(run.thread_id, run.run_id)
167
+ emit = lambda do |frames|
168
+ frames.each do |frame|
169
+ @store&.record(run.thread_id, frame)
170
+ yielder << frame
171
+ end
172
+ end
173
+
174
+ emit.call(emitter.start)
175
+
176
+ events = @handler.call(run)
177
+ events = [] if events.nil?
178
+ events = [events] unless events.respond_to?(:each)
179
+ events.each do |event|
180
+ break if @store&.stop_requested?(run.thread_id)
181
+
182
+ emit.call(emitter.handle(event))
183
+ end
184
+
185
+ emit.call(emitter.finish)
186
+ rescue StandardError => e
187
+ begin
188
+ emit.call(emitter.fail(e))
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ ensure
193
+ @store&.finish_run(run.thread_id)
194
+ end
195
+
196
+ # Resume/reattach: replay everything recorded for the thread, then
197
+ # close. Unknown thread (or no store) answers 200 SSE with zero
198
+ # events — the client's reconnect stays well behaved.
199
+ def respond_connect(env)
200
+ thread_id = extract_thread_id(env["rack.input"]&.read.to_s)
201
+ frames = (thread_id && @store) ? @store.replay(thread_id) : []
202
+ [200, SSE_HEADERS.dup, frames]
203
+ end
204
+
205
+ # Flag the thread's run for cooperative cancel and ack. The run loop
206
+ # checks the flag between events and ends the run early.
207
+ def respond_stop(thread_id)
208
+ stopped = @store ? @store.request_stop(thread_id.to_s) : false
209
+ [200, JSON_HEADERS.dup, [JSON.generate({ "stopped" => stopped })]]
210
+ end
211
+
212
+ # `/connect` bodies carry the run input plus an optional replay
213
+ # cursor — parsed opportunistically, never fatal.
214
+ def extract_thread_id(body)
215
+ return nil if body.empty?
216
+
217
+ raw = JSON.parse(body)
218
+ return nil unless raw.is_a?(Hash)
219
+
220
+ thread_id = raw["threadId"] || raw["thread_id"]
221
+ thread_id.is_a?(String) && !thread_id.empty? ? thread_id : nil
222
+ rescue JSON::ParserError
223
+ nil
224
+ end
225
+
226
+ def preflight
227
+ [204, {
228
+ "access-control-allow-origin" => "*",
229
+ "access-control-allow-methods" => "GET, POST, OPTIONS",
230
+ "access-control-allow-headers" => "*"
231
+ }, []]
232
+ end
233
+
234
+ def bad_request(details)
235
+ [400, JSON_HEADERS.dup,
236
+ [JSON.generate({ "error" => "Invalid request body", "details" => details })]]
237
+ end
238
+
239
+ def not_found
240
+ [404, JSON_HEADERS.dup, [JSON.generate({ "error" => "Not found" })]]
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module AGUI
5
+ # Gem version, following Semantic Versioning.
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
data/lib/ask/ag_ui.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ag_ui/version"
4
+ require_relative "ag_ui/emitter"
5
+ require_relative "ag_ui/run_store"
6
+ require_relative "ag_ui/run"
7
+ require_relative "ag_ui/server"
8
+
9
+ # Namespace for the AG-UI (Agent-User Interaction) protocol server.
10
+ #
11
+ # ask-ag-ui is the runtime surface that assistant-ui and CopilotKit
12
+ # frontends expect: SSE event streaming over the AG-UI protocol, backed by
13
+ # the ask-rb ecosystem.
14
+ #
15
+ # The public seam is Ask::AGUI::Emitter — see that class for the contract.
16
+ module Ask
17
+ # AG-UI protocol surface for ask-rb.
18
+ module AGUI
19
+ end
20
+ end
data/lib/ask-ag-ui.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main entry point for the ask-ag-ui gem.
4
+ #
5
+ # Requires the namespaced implementation directly. This family loads files
6
+ # with manual `require_relative` lists — no Zeitwerk.
7
+ require_relative "ask/ag_ui"
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-ag-ui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ag-ui-protocol
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: json_schemer
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.5'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.5'
82
+ description: The AG-UI (Agent-User Interaction) protocol server for the ask-rb ecosystem
83
+ — SSE event streaming and the runtime surface that assistant-ui and CopilotKit frontends
84
+ expect.
85
+ email:
86
+ - kaka@myrrlabs.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE
93
+ - README.md
94
+ - lib/ask-ag-ui.rb
95
+ - lib/ask/ag_ui.rb
96
+ - lib/ask/ag_ui/emitter.rb
97
+ - lib/ask/ag_ui/run.rb
98
+ - lib/ask/ag_ui/run_store.rb
99
+ - lib/ask/ag_ui/server.rb
100
+ - lib/ask/ag_ui/version.rb
101
+ homepage: https://github.com/ask-rb/ask-ag-ui
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ homepage_uri: https://github.com/ask-rb/ask-ag-ui
106
+ source_code_uri: https://github.com/ask-rb/ask-ag-ui
107
+ changelog_uri: https://github.com/ask-rb/ask-ag-ui/blob/master/CHANGELOG.md
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '3.2'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 4.0.18
123
+ specification_version: 4
124
+ summary: AG-UI protocol server for the ask-rb ecosystem
125
+ test_files: []