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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "async"
5
+ require "async/queue"
6
+ require "ag_ui"
7
+
8
+ module AgUi
9
+ # Per-thread run bookkeeping backing /connect (replay + live attach) and
10
+ # /stop (cancel the run's Async task) — the Ruby counterpart of the Node
11
+ # runtime's InMemoryAgentRunner thread store.
12
+ #
13
+ # The interface is duck-typed; hosts can supply anything implementing
14
+ # begin_run / record / finish_run / attach_task / stop /
15
+ # open_subscription (e.g. a redis-backed store) via
16
+ # `AgUi.agent(store: ...)`.
17
+ module RunStore
18
+ # Single-process store. Fiber/thread-safe: state mutations are locked,
19
+ # and live fanout uses Async::Queue so connect streams receive events
20
+ # as they are recorded.
21
+ class InMemory
22
+ FINISHED = :finished
23
+
24
+ def initialize
25
+ @threads = Hash.new do |hash, key|
26
+ hash[key] = { historic: [], live: nil, task: nil, subscribers: [] }
27
+ end
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ def begin_run(thread_id, run_id)
32
+ @mutex.synchronize do
33
+ @threads[thread_id][:live] = { run_id: run_id, events: [] }
34
+ end
35
+ end
36
+
37
+ def record(thread_id, payload)
38
+ subscribers = @mutex.synchronize do
39
+ thread = @threads[thread_id]
40
+ if thread[:live]
41
+ thread[:live][:events] << payload
42
+ thread[:subscribers].dup
43
+ else
44
+ []
45
+ end
46
+ end
47
+ subscribers.each { |queue| queue.enqueue(payload) }
48
+ end
49
+
50
+ def finish_run(thread_id)
51
+ subscribers = @mutex.synchronize do
52
+ thread = @threads[thread_id]
53
+ if thread[:live]
54
+ thread[:historic] << thread[:live]
55
+ thread[:live] = nil
56
+ end
57
+ thread[:task] = nil
58
+ drained = thread[:subscribers]
59
+ thread[:subscribers] = []
60
+ drained
61
+ end
62
+ subscribers.each { |queue| queue.enqueue(FINISHED) }
63
+ end
64
+
65
+ # Only meaningful while the run is live — a late attach (the builder
66
+ # calls on_task after Async returns, which for an already-completed
67
+ # run is after finish_run) must not resurrect a stopped handle.
68
+ def attach_task(thread_id, task)
69
+ @mutex.synchronize do
70
+ if @threads[thread_id][:live]
71
+ @threads[thread_id][:task] = task
72
+ end
73
+ end
74
+ end
75
+
76
+ # Cancel the thread's in-flight run. The stream still closes cleanly
77
+ # (StreamBuilder's ensure) — matching the Node runtime, which ends
78
+ # an aborted run with a plain RUN_FINISHED-then-close.
79
+ def stop(thread_id)
80
+ task = @mutex.synchronize { @threads[thread_id][:task] }
81
+ if task
82
+ task.stop
83
+ true
84
+ else
85
+ false
86
+ end
87
+ end
88
+
89
+ # Atomic snapshot + live subscription: the returned events are
90
+ # everything recorded so far (historic runs + the live run's events),
91
+ # and when a run is in flight, queue receives each subsequent event
92
+ # and finally FINISHED. Atomicity means no gap and no duplicates
93
+ # between snapshot and subscription.
94
+ def open_subscription(thread_id)
95
+ @mutex.synchronize do
96
+ thread = @threads[thread_id]
97
+ events = thread[:historic].flat_map { |run| run[:events] }
98
+ queue = nil
99
+ if thread[:live]
100
+ events += thread[:live][:events]
101
+ queue = Async::Queue.new
102
+ thread[:subscribers] << queue
103
+ end
104
+ { events: events, queue: queue }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ __END__
112
+
113
+ describe "AgUi::RunStore::InMemory" do
114
+ it "replays historic runs in order for a thread" do
115
+ store = AgUi::RunStore::InMemory.new
116
+ store.begin_run("t1", "r1")
117
+ store.record("t1", { "type" => "RUN_STARTED" })
118
+ store.record("t1", { "type" => "RUN_FINISHED" })
119
+ store.finish_run("t1")
120
+ store.begin_run("t1", "r2")
121
+ store.record("t1", { "type" => "RUN_STARTED" })
122
+ store.finish_run("t1")
123
+
124
+ subscription = store.open_subscription("t1")
125
+ subscription[:events].map { |e| e["type"] }.should == %w[RUN_STARTED RUN_FINISHED RUN_STARTED]
126
+ subscription[:queue].should.be.nil
127
+ end
128
+
129
+ it "returns an empty snapshot for unknown threads" do
130
+ subscription = AgUi::RunStore::InMemory.new.open_subscription("nope")
131
+ subscription[:events].should == []
132
+ subscription[:queue].should.be.nil
133
+ end
134
+
135
+ it "fans live events out to subscribers, with FINISHED at run end" do
136
+ store = AgUi::RunStore::InMemory.new
137
+ store.begin_run("t1", "r1")
138
+ store.record("t1", { "type" => "RUN_STARTED" })
139
+
140
+ received = []
141
+ Async do |task|
142
+ subscription = store.open_subscription("t1")
143
+ subscription[:events].length.should == 1
144
+ queue = subscription[:queue]
145
+
146
+ reader = task.async do
147
+ loop do
148
+ item = queue.dequeue
149
+ if item == AgUi::RunStore::InMemory::FINISHED
150
+ break
151
+ end
152
+ received << item
153
+ end
154
+ end
155
+
156
+ store.record("t1", { "type" => "TEXT_MESSAGE_START" })
157
+ store.finish_run("t1")
158
+ reader.wait
159
+ end
160
+
161
+ received.map { |e| e["type"] }.should == ["TEXT_MESSAGE_START"]
162
+ end
163
+
164
+ it "stops the attached task exactly while a run is live" do
165
+ store = AgUi::RunStore::InMemory.new
166
+ stopped = false
167
+ fake_task = Object.new
168
+ fake_task.define_singleton_method(:stop) { stopped = true }
169
+
170
+ store.stop("t1").should == false
171
+
172
+ store.begin_run("t1", "r1")
173
+ store.attach_task("t1", fake_task)
174
+ store.stop("t1").should == true
175
+ stopped.should == true
176
+
177
+ store.finish_run("t1")
178
+ store.stop("t1").should == false
179
+ end
180
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ class Server
8
+ # Builds the GET /info payload — the envelope the CopilotKit client
9
+ # fetches on mount to discover agents and feature-detect capabilities.
10
+ # Shape extracted verbatim from @copilotkit/runtime's
11
+ # get-runtime-info.mjs (docs/09-ground-truth-host-app.md §1).
12
+ #
13
+ # The client feature-detects A2UI from the TOP-LEVEL `a2uiEnabled`
14
+ # flag; when enabled, the optional `a2ui: {enabled: true}` detail
15
+ # object rides along.
16
+ module Info
17
+ # The runtime version the client was built against; advertise the
18
+ # same so version-gated client behaviour matches the Node sidecar.
19
+ VERSION_PARITY = "1.62.2"
20
+
21
+ class << self
22
+ def payload(agent_id: "default", description: nil, a2ui_enabled: false, overrides: {})
23
+ agent = { "name" => agent_id, "className" => "BuiltInAgent" }
24
+ unless description.nil?
25
+ agent["description"] = description
26
+ end
27
+
28
+ base = {
29
+ "version" => VERSION_PARITY,
30
+ "agents" => { agent_id => agent },
31
+ "audioFileTranscriptionEnabled" => false,
32
+ "mode" => "sse",
33
+ "threadEndpoints" => {
34
+ "list" => false,
35
+ "inspect" => false,
36
+ "mutations" => false,
37
+ "realtimeMetadata" => false,
38
+ },
39
+ "a2uiEnabled" => a2ui_enabled,
40
+ "openGenerativeUIEnabled" => false,
41
+ "telemetryDisabled" => true,
42
+ }
43
+
44
+ if a2ui_enabled
45
+ base["a2ui"] = { "enabled" => true }
46
+ end
47
+
48
+ base.merge(overrides)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ __END__
56
+
57
+ describe "AgUi::Server::Info" do
58
+ it "matches the Node runtime envelope for the default agent" do
59
+ payload = AgUi::Server::Info.payload
60
+
61
+ payload["version"].should == "1.62.2"
62
+ payload["agents"].should == { "default" => { "name" => "default", "className" => "BuiltInAgent" } }
63
+ payload["mode"].should == "sse"
64
+ payload["a2uiEnabled"].should == false
65
+ payload.key?("a2ui").should == false
66
+ payload["threadEndpoints"].should == {
67
+ "list" => false, "inspect" => false, "mutations" => false, "realtimeMetadata" => false,
68
+ }
69
+ end
70
+
71
+ it "advertises a2ui via the top-level flag plus detail object" do
72
+ payload = AgUi::Server::Info.payload(a2ui_enabled: true)
73
+ payload["a2uiEnabled"].should == true
74
+ payload["a2ui"].should == { "enabled" => true }
75
+ end
76
+
77
+ it "includes agent description when given and applies overrides" do
78
+ payload = AgUi::Server::Info.payload(
79
+ agent_id: "host-app", description: "Studio assistant",
80
+ overrides: { "telemetryDisabled" => false },
81
+ )
82
+ payload["agents"]["host-app"]["description"].should == "Studio assistant"
83
+ payload["telemetryDisabled"].should == false
84
+ end
85
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+ require "async"
6
+
7
+ module AgUi
8
+ class Server
9
+ module Middleware
10
+ # Sets up an SSE stream builder on `env["ag_ui.stream"]`, ported
11
+ # from a2a's Server::Middleware::SSEStream.
12
+ #
13
+ # The `open` block runs inside an Async fiber and the stream is
14
+ # automatically finished when the block exits (even on exception).
15
+ # If the handler never calls `open`, the builder is removed from env
16
+ # so the response layer doesn't mistake it for a real stream.
17
+ #
18
+ # Usage (inside the /run handler):
19
+ #
20
+ # env["ag_ui.stream"].open(thread_id:, run_id:) do |s|
21
+ # s.run_started
22
+ # s.text_message_content(message_id: "m1", delta: "Hi")
23
+ # s.run_finished
24
+ # end
25
+ #
26
+ class SSEStream
27
+ def initialize(app)
28
+ @app = app
29
+ end
30
+
31
+ def call(env)
32
+ builder = StreamBuilder.new(env)
33
+ env["ag_ui.stream"] = builder
34
+
35
+ result = @app.call(env)
36
+
37
+ # If open was never called, clear the builder so the response
38
+ # layer doesn't mistake it for a real stream.
39
+ if env["ag_ui.stream"].equal?(builder)
40
+ env.delete("ag_ui.stream")
41
+ end
42
+
43
+ result
44
+ end
45
+ end
46
+
47
+ # Factory that creates the SSE stream and runs the caller's block
48
+ # inside Async with automatic finish on exit.
49
+ #
50
+ # Created by SSEStream middleware — not intended for direct use.
51
+ class StreamBuilder
52
+ def initialize(env)
53
+ @env = env
54
+ end
55
+
56
+ # Create and open the SSE stream for the current run.
57
+ #
58
+ # The block runs inside an Async fiber; the stream is finished
59
+ # when the block exits, even if an exception is raised. The run
60
+ # handler owns terminal-event semantics (RUN_FINISHED / RUN_ERROR)
61
+ # — this layer only guarantees the connection closes.
62
+ #
63
+ # on_event / on_finish / on_task are the run-store taps (set by
64
+ # the Server's recording wrapper): every payload, end-of-run, and
65
+ # the Async task handle for /stop cancellation.
66
+ def open(thread_id:, run_id:, validate: true,
67
+ on_event: nil, on_finish: nil, on_task: nil, &block)
68
+ stream = SSE::Stream.new(
69
+ thread_id: thread_id,
70
+ run_id: run_id,
71
+ validate: validate,
72
+ on_event: on_event,
73
+ )
74
+
75
+ @env["ag_ui.stream"] = stream
76
+
77
+ task = Async do
78
+ block.call(stream)
79
+ ensure
80
+ stream.finish
81
+ on_finish&.call
82
+ end
83
+ on_task&.call(task)
84
+
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ __END__
93
+
94
+ describe "AgUi::Server::Middleware::SSEStream" do
95
+ it "sets a StreamBuilder on env and clears it when open is never called" do
96
+ seen = nil
97
+ mw = AgUi::Server::Middleware::SSEStream.new(->(env) { seen = env["ag_ui.stream"]; :ok })
98
+
99
+ env = {}
100
+ result = mw.call(env)
101
+
102
+ seen.should.be.kind_of(AgUi::Server::Middleware::StreamBuilder)
103
+ env.key?("ag_ui.stream").should == false
104
+ result.should == :ok
105
+ end
106
+
107
+ it "preserves the opened stream on env" do
108
+ mw = AgUi::Server::Middleware::SSEStream.new(->(env) do
109
+ env["ag_ui.stream"].open(thread_id: "t1", run_id: "r1") { |s| s.run_started }
110
+ end)
111
+
112
+ env = {}
113
+ mw.call(env)
114
+
115
+ env["ag_ui.stream"].should.be.kind_of(AgUi::Server::SSE::Stream)
116
+ end
117
+ end
118
+
119
+ describe "AgUi::Server::Middleware::StreamBuilder" do
120
+ it "passes thread_id and run_id through to the stream" do
121
+ env = {}
122
+ builder = AgUi::Server::Middleware::StreamBuilder.new(env)
123
+
124
+ builder.open(thread_id: "t1", run_id: "r1") do |s|
125
+ s.thread_id.should == "t1"
126
+ s.run_id.should == "r1"
127
+ end
128
+ end
129
+
130
+ it "auto-finishes the stream when the block completes" do
131
+ env = {}
132
+ builder = AgUi::Server::Middleware::StreamBuilder.new(env)
133
+
134
+ builder.open(thread_id: "t1", run_id: "r1") { |s| s.run_started }
135
+
136
+ stream = env["ag_ui.stream"]
137
+ stream.read.should.include?("RUN_STARTED")
138
+ stream.read.should.be.nil
139
+ end
140
+
141
+ it "auto-finishes even when the block raises" do
142
+ env = {}
143
+ builder = AgUi::Server::Middleware::StreamBuilder.new(env)
144
+
145
+ builder.open(thread_id: "t1", run_id: "r1") do |s|
146
+ s.run_started
147
+ raise "boom"
148
+ end
149
+
150
+ stream = env["ag_ui.stream"]
151
+ stream.read.should.include?("RUN_STARTED")
152
+ stream.read.should.be.nil
153
+ end
154
+
155
+ it "returns nil" do
156
+ env = {}
157
+ builder = AgUi::Server::Middleware::StreamBuilder.new(env)
158
+ builder.open(thread_id: "t1", run_id: "r1") { |_s| }.should.be.nil
159
+ end
160
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgUi
6
+ class Server
7
+ module SSE
8
+ # AG-UI SSE framing, ported from the reference encoder
9
+ # (ag-ui sdks/python/ag_ui/encoder/encoder.py, cross-checked against
10
+ # @ag-ui/encoder dist): one event per frame, `data: <json>\n\n`, no
11
+ # `event:`/`id:` lines, no heartbeats. Keys are camelCase and nil
12
+ # fields are omitted at every depth (pydantic exclude_none).
13
+ class EventEncoder
14
+ CONTENT_TYPE = "text/event-stream"
15
+
16
+ def content_type = CONTENT_TYPE
17
+
18
+ # event: a Hash with camelCase string/symbol keys, e.g.
19
+ # { type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "Hi" }
20
+ def encode(event)
21
+ "data: #{JSON.generate(strip_nils(event))}\n\n"
22
+ end
23
+
24
+ private
25
+
26
+ def strip_nils(value)
27
+ case value
28
+ when Hash
29
+ value.each_with_object({}) do |(k, v), out|
30
+ unless v.nil?
31
+ out[k] = strip_nils(v)
32
+ end
33
+ end
34
+ when Array
35
+ value.map { |v| strip_nils(v) }
36
+ else
37
+ value
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ __END__
46
+
47
+ describe "ag_ui/server/sse/event_encoder" do
48
+ encoder = AgUi::Server::SSE::EventEncoder.new
49
+
50
+ it "frames an event as a single data line with a blank-line terminator" do
51
+ frame = encoder.encode({ type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "Hi" })
52
+ frame.should == "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"Hi\"}\n\n"
53
+ end
54
+
55
+ it "omits nil fields at every depth" do
56
+ frame = encoder.encode({ type: "RUN_FINISHED", threadId: "t", runId: "r",
57
+ result: nil, nested: { keep: 1, drop: nil } })
58
+ frame.should == "data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t\",\"runId\":\"r\",\"nested\":{\"keep\":1}}\n\n"
59
+ end
60
+
61
+ it "preserves nils inside arrays and keeps empty strings/false" do
62
+ frame = encoder.encode({ type: "CUSTOM", name: "x", value: ["a", nil], flag: false, s: "" })
63
+ frame.should == "data: {\"type\":\"CUSTOM\",\"name\":\"x\",\"value\":[\"a\",null],\"flag\":false,\"s\":\"\"}\n\n"
64
+ end
65
+
66
+ it "advertises the SSE content type" do
67
+ encoder.content_type.should == "text/event-stream"
68
+ end
69
+ end