ag-ui 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff8366907501490461eb57be56e07c9054ac1632fea8ad6d47c6c3f5b1332cff
4
- data.tar.gz: a40b14e97b6d6b9a09daa6016b98a5e19f23ab0594acd37315a5eddc2bebc55c
3
+ metadata.gz: 6092a88471f821fdd20d43bd3dad28205f114bb5b2068faea39234b4f1bc87da
4
+ data.tar.gz: 93566155eed8ad3464386a74b8d5afb6db6ea124eccac4bf6382b264e02733ec
5
5
  SHA512:
6
- metadata.gz: e4a40843efccd8f2a995e86816e7108f34e069aac3698f44c94c3d07873fb1f194630f133dae9521abb30803b5420a93742af89c8bc884997521369ba3d368fc
7
- data.tar.gz: 5fedf59e8db8548b66db608308c73733b863b09013640cf82139430f6b2b8ac4882baf638ae0fc240dcfd5cfe458c889edc957b9201e5196e30f07ad86847b94
6
+ metadata.gz: cf108e6f97a63801cf5b3a936241d187a3492bf8c92209ff0353d5e98ae68bb3752c05aedeffcb8f7a1deee0d91378128dc9608b3a4f997e8a98b9712412fc89
7
+ data.tar.gz: 541b3da611c70fdd1ec02b97dd9c8fad76d30f20e124d38800896d57b431a099c148d3635193ebef28aaae6d70b303989e41380abeb6c4e489820cd2fc1d6d1c
@@ -24,12 +24,19 @@ module AgUi
24
24
  tool_call_args: :translate_tool_call_args,
25
25
  tool_call_end: :translate_tool_call_end,
26
26
  tool_call_result: :translate_tool_call_result,
27
+ state_snapshot: :translate_state_snapshot,
28
+ state_delta: :translate_state_delta,
29
+ messages_snapshot: :translate_messages_snapshot,
27
30
  activity_snapshot: :translate_activity_snapshot,
28
31
  reasoning_start: :translate_reasoning_start,
29
32
  reasoning_message_start: :translate_reasoning_message_start,
30
33
  reasoning_message_content: :translate_reasoning_message_content,
31
34
  reasoning_message_end: :translate_reasoning_message_end,
32
35
  reasoning_end: :translate_reasoning_end,
36
+ step_started: :translate_step_started,
37
+ step_finished: :translate_step_finished,
38
+ custom: :translate_custom,
39
+ raw: :translate_raw,
33
40
  }.freeze
34
41
 
35
42
  def initialize(stream)
@@ -88,6 +95,40 @@ module AgUi
88
95
  )
89
96
  end
90
97
 
98
+ # Shared state (CoAgents). snapshot replaces the whole state; delta is a
99
+ # JSON Patch (RFC 6902) op array the client applies to its own store.
100
+ def translate_state_snapshot(data)
101
+ @stream.state_snapshot(snapshot: data[:snapshot])
102
+ end
103
+
104
+ def translate_state_delta(data)
105
+ @stream.state_delta(delta: data[:delta])
106
+ end
107
+
108
+ def translate_messages_snapshot(data)
109
+ @stream.messages_snapshot(messages: data[:messages])
110
+ end
111
+
112
+ # Structured progress markers (paired start/finish by step name).
113
+ def translate_step_started(data)
114
+ @stream.step_started(step_name: data[:step_name])
115
+ end
116
+
117
+ def translate_step_finished(data)
118
+ @stream.step_finished(step_name: data[:step_name])
119
+ end
120
+
121
+ # Escape hatches: CUSTOM carries app/agent-defined events (e.g. the
122
+ # "PredictState" convention for predictive state updates); RAW passes a
123
+ # framework event straight through.
124
+ def translate_custom(data)
125
+ @stream.custom(name: data[:name], value: data[:value])
126
+ end
127
+
128
+ def translate_raw(data)
129
+ @stream.raw(event: data[:event], source: data[:source])
130
+ end
131
+
91
132
  def translate_activity_snapshot(data)
92
133
  @stream.activity_snapshot(
93
134
  message_id: data[:message_id],
@@ -171,4 +212,36 @@ describe "AgUi::EventBridge" do
171
212
  result.should.equal?(bridge)
172
213
  read_frames.(stream).should == []
173
214
  end
215
+
216
+ it "translates shared-state events (snapshot + JSON-Patch delta)" do
217
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
218
+ bridge = AgUi::EventBridge.new(stream)
219
+
220
+ bridge << { type: :state_snapshot, data: { snapshot: { "theme" => "dark" } } }
221
+ bridge << { type: :state_delta,
222
+ data: { delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] } }
223
+ stream.finish
224
+
225
+ frames = read_frames.(stream)
226
+ frames.map { |f| f["type"] }.should == %w[STATE_SNAPSHOT STATE_DELTA]
227
+ frames[0]["snapshot"].should == { "theme" => "dark" }
228
+ frames[1]["delta"].first["path"].should == "/theme"
229
+ end
230
+
231
+ it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
232
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
233
+ bridge = AgUi::EventBridge.new(stream)
234
+
235
+ bridge << { type: :step_started, data: { step_name: "plan" } }
236
+ bridge << { type: :custom,
237
+ data: { name: "PredictState",
238
+ value: [{ "state_key" => "document", "tool" => "write" }] } }
239
+ bridge << { type: :step_finished, data: { step_name: "plan" } }
240
+ stream.finish
241
+
242
+ frames = read_frames.(stream)
243
+ frames.map { |f| f["type"] }.should == %w[STEP_STARTED CUSTOM STEP_FINISHED]
244
+ frames[0]["stepName"].should == "plan"
245
+ frames[1]["name"].should == "PredictState"
246
+ end
174
247
  end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "securerandom"
5
+ require "hana"
6
+ require "ag_ui"
7
+
8
+ module AgUi
9
+ module Middleware
10
+ # Shared state (CoAgents) — the Ruby side of AG-UI's STATE_SNAPSHOT /
11
+ # STATE_DELTA channel (doc 05). Bidirectional state sync between the
12
+ # frontend's `agent.state` and the running agent.
13
+ #
14
+ # Way in:
15
+ # - seeds env[:state] from RunAgentInput.state (the state the frontend
16
+ # last held, pushed via agent.setState) so downstream middleware,
17
+ # tools, and the app can READ what the UI currently shows
18
+ # - injects two tools the model calls to WRITE state:
19
+ # AGUISendStateSnapshot({ snapshot }) — replace the whole state
20
+ # AGUISendStateDelta({ delta }) — RFC 6902 JSON Patch ops
21
+ #
22
+ # Way out, per state-tool call the model made:
23
+ # - snapshot: env[:state] = snapshot; emits STATE_SNAPSHOT
24
+ # - delta: patches env[:state] (Hana); emits STATE_DELTA
25
+ # - both append a {"status":"ok"} :tool message and emit a
26
+ # TOOL_CALL_RESULT — the same shape a server tool produces — then let
27
+ # the run CONTINUE (unlike a browser/client tool) so the model can act
28
+ # on the new state or confirm to the user. State tools are server-side.
29
+ #
30
+ # The TOOL_CALL_START/ARGS/END chrome is left to ToolRouter (it advertises
31
+ # nothing about these being "client" vs "server" — it just streams the
32
+ # call), exactly as A2ui leaves its render_a2ui call to ToolRouter. Compose
33
+ # State OUTSIDE ToolRouter:
34
+ #
35
+ # use Loop::ToolResult
36
+ # use State, state: input.state
37
+ # use ToolRouter, tools: input.tools
38
+ #
39
+ # The frontend applies the same snapshot/patch to its own store; env[:state]
40
+ # is kept coherent so the agent's own later reads (and further deltas) see
41
+ # the current value.
42
+ class State
43
+ SNAPSHOT_TOOL = "AGUISendStateSnapshot"
44
+ DELTA_TOOL = "AGUISendStateDelta"
45
+ TOOL_NAMES = [SNAPSHOT_TOOL, DELTA_TOOL].freeze
46
+
47
+ SNAPSHOT_DEFINITION = {
48
+ "name" => SNAPSHOT_TOOL,
49
+ "description" =>
50
+ "Replace the shared application state with a new snapshot; the " \
51
+ "frontend re-renders from it. Send the COMPLETE next state object, " \
52
+ "not a diff. Prefer AGUISendStateDelta for small targeted changes.",
53
+ "parameters" => {
54
+ "type" => "object",
55
+ "properties" => {
56
+ "snapshot" => {
57
+ "type" => "object",
58
+ "description" => "The complete new application state.",
59
+ },
60
+ },
61
+ "required" => %w[snapshot],
62
+ },
63
+ }.freeze
64
+
65
+ DELTA_DEFINITION = {
66
+ "name" => DELTA_TOOL,
67
+ "description" =>
68
+ "Apply a JSON Patch (RFC 6902) to the shared application state — an " \
69
+ "array of {op, path, value} operations. Use for small, targeted " \
70
+ "changes. Paths are JSON Pointers, e.g. " \
71
+ "\"/documentEditor/activeTabId\". ops: add, replace, remove, move, " \
72
+ "copy, test.",
73
+ "parameters" => {
74
+ "type" => "object",
75
+ "properties" => {
76
+ "delta" => {
77
+ "type" => "array",
78
+ "description" =>
79
+ "JSON Patch operations, e.g. " \
80
+ "[{\"op\":\"replace\",\"path\":\"/theme\",\"value\":\"dark\"}].",
81
+ "items" => { "type" => "object" },
82
+ },
83
+ },
84
+ "required" => %w[delta],
85
+ },
86
+ }.freeze
87
+
88
+ def initialize(app, state: nil)
89
+ @app = app
90
+ @initial_state = normalize(state)
91
+ end
92
+
93
+ def call(env)
94
+ seed_state(env)
95
+ advertise(env)
96
+
97
+ before = env[:messages].length
98
+ @app.call(env)
99
+
100
+ # Only this iteration's assistant message — seeded history can carry
101
+ # old state-tool turns that must not re-emit.
102
+ appended = env[:messages][before..] || []
103
+ assistant = appended.reverse.find { |m| m.respond_to?(:tool_call?) && m.tool_call? }
104
+ if assistant
105
+ apply_state_calls(env, assistant)
106
+ end
107
+
108
+ env
109
+ end
110
+
111
+ private
112
+
113
+ def apply_state_calls(env, assistant)
114
+ state_calls = assistant.tool_calls.select { |tc| TOOL_NAMES.include?(tc.name) }
115
+ unless state_calls.empty?
116
+ state_calls.each { |tool_call| handle(env, tool_call) }
117
+
118
+ # State tools don't end the run — they're server-side, not browser
119
+ # tools — so let Loop::ToolResult continue (last message is now
120
+ # :tool). Only override the exit for a PURE state turn; a real
121
+ # client tool mixed in still ends the run so the browser runs it.
122
+ if assistant.tool_calls.all? { |tc| TOOL_NAMES.include?(tc.name) }
123
+ env[:should_exit] = false
124
+ end
125
+ end
126
+ end
127
+
128
+ def seed_state(env)
129
+ unless env.key?(:state)
130
+ env[:state] = @initial_state
131
+ end
132
+ end
133
+
134
+ # Idempotent — the turn loop re-enters every iteration.
135
+ def advertise(env)
136
+ env[:tools] ||= []
137
+ names = env[:tools].map { |t| t.is_a?(Hash) ? t["name"] : nil }
138
+ [SNAPSHOT_DEFINITION, DELTA_DEFINITION].each do |definition|
139
+ unless names.include?(definition["name"])
140
+ env[:tools] << definition
141
+ end
142
+ end
143
+ end
144
+
145
+ def handle(env, tool_call)
146
+ case tool_call.name
147
+ when SNAPSHOT_TOOL then apply_snapshot(env, tool_call)
148
+ when DELTA_TOOL then apply_delta(env, tool_call)
149
+ end
150
+ end
151
+
152
+ def apply_snapshot(env, tool_call)
153
+ snapshot = tool_call.arguments["snapshot"]
154
+ if snapshot.is_a?(Hash)
155
+ env[:state] = snapshot
156
+ env[:events] << { type: :state_snapshot, data: { snapshot: snapshot } }
157
+ ack(env, tool_call)
158
+ else
159
+ ack(env, tool_call, error: "snapshot must be an object")
160
+ end
161
+ end
162
+
163
+ def apply_delta(env, tool_call)
164
+ delta = tool_call.arguments["delta"]
165
+ if delta.is_a?(Array)
166
+ patch_state(env, tool_call, delta)
167
+ else
168
+ ack(env, tool_call, error: "delta must be an array of JSON Patch operations")
169
+ end
170
+ end
171
+
172
+ # Keep our copy coherent for later reads/deltas; the frontend applies
173
+ # the same patch to its own store. A bad patch is reported back to the
174
+ # model as a tool error rather than raising the run.
175
+ def patch_state(env, tool_call, delta)
176
+ patched = Hana::Patch.new(delta).apply(deep_dup(env[:state] || {}))
177
+ rescue StandardError => e
178
+ ack(env, tool_call, error: "invalid JSON Patch: #{e.message}")
179
+ else
180
+ env[:state] = patched
181
+ env[:events] << { type: :state_delta, data: { delta: delta } }
182
+ ack(env, tool_call)
183
+ end
184
+
185
+ # Append the tool result (so the assistant tool-call has its matching
186
+ # :tool message and Loop::ToolResult continues) and emit TOOL_CALL_RESULT
187
+ # on the wire — the exact shape server tools produce.
188
+ def ack(env, tool_call, error: nil)
189
+ content = error ? { "status" => "error", "error" => error } : { "status" => "ok" }
190
+ json = JSON.generate(content)
191
+ env[:messages].tool(json, tool_call_id: tool_call.id)
192
+ env[:events] << {
193
+ type: :tool_call_result,
194
+ data: {
195
+ message_id: SecureRandom.uuid,
196
+ tool_call_id: tool_call.id,
197
+ content: json,
198
+ },
199
+ }
200
+ end
201
+
202
+ def normalize(state)
203
+ if state.nil?
204
+ nil
205
+ elsif state.respond_to?(:to_h)
206
+ state.to_h
207
+ else
208
+ state
209
+ end
210
+ end
211
+
212
+ def deep_dup(obj)
213
+ case obj
214
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
215
+ when Array then obj.map { |v| deep_dup(v) }
216
+ else obj
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ __END__
224
+
225
+ describe "AgUi::Middleware::State" do
226
+ snapshot_call = ->(args) do
227
+ Brute::Message.new(
228
+ role: :assistant, content: nil,
229
+ tool_calls: [{ id: "tc1", name: "AGUISendStateSnapshot", arguments: args }],
230
+ )
231
+ end
232
+
233
+ delta_call = ->(args) do
234
+ Brute::Message.new(
235
+ role: :assistant, content: nil,
236
+ tool_calls: [{ id: "tc1", name: "AGUISendStateDelta", arguments: args }],
237
+ )
238
+ end
239
+
240
+ it "advertises both state tools idempotently and seeds env[:state] from input" do
241
+ seen = nil
242
+ mw = AgUi::Middleware::State.new(->(env) { seen = env }, state: { "theme" => "light" })
243
+ env = { messages: Brute.log, events: [], tools: [{ "name" => "navigate" }] }
244
+ mw.call(env)
245
+ mw.call(env) # second loop iteration — no dupes
246
+
247
+ seen[:tools].map { |t| t["name"] }.should ==
248
+ %w[navigate AGUISendStateSnapshot AGUISendStateDelta]
249
+ seen[:state].should == { "theme" => "light" }
250
+ end
251
+
252
+ it "seeds from a Definition-like state via to_h" do
253
+ definition = Object.new
254
+ def definition.to_h = { "count" => 1 }
255
+ seen = nil
256
+ AgUi::Middleware::State.new(->(env) { seen = env }, state: definition)
257
+ .call({ messages: Brute.log, events: [], tools: [] })
258
+ seen[:state].should == { "count" => 1 }
259
+ end
260
+
261
+ it "AGUISendStateSnapshot: sets state, emits STATE_SNAPSHOT + result, continues the run" do
262
+ terminal = ->(env) { env[:messages] << snapshot_call.("snapshot" => { "count" => 3 }) }
263
+ env = { messages: Brute.log, events: [], tools: [], should_exit: true }
264
+ AgUi::Middleware::State.new(terminal).call(env)
265
+
266
+ env[:state].should == { "count" => 3 }
267
+ env[:events].map { |e| e[:type] }.should == %i[state_snapshot tool_call_result]
268
+ env[:events][0][:data][:snapshot].should == { "count" => 3 }
269
+ env[:messages].last.role.should == :tool
270
+ env[:messages].last.tool_call_id.should == "tc1"
271
+ env[:messages].last.content.should == "{\"status\":\"ok\"}"
272
+ env[:should_exit].should == false
273
+ end
274
+
275
+ it "AGUISendStateDelta: patches state (Hana), emits STATE_DELTA with the raw ops" do
276
+ delta = [{ "op" => "replace", "path" => "/theme", "value" => "dark" }]
277
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
278
+ env = { messages: Brute.log, events: [], tools: [], state: { "theme" => "light" }, should_exit: true }
279
+ AgUi::Middleware::State.new(terminal).call(env)
280
+
281
+ env[:state].should == { "theme" => "dark" }
282
+ env[:events].map { |e| e[:type] }.should == %i[state_delta tool_call_result]
283
+ env[:events][0][:data][:delta].should == delta
284
+ env[:should_exit].should == false
285
+ end
286
+
287
+ it "adds a nested key via delta against seeded state" do
288
+ delta = [{ "op" => "add", "path" => "/documentEditor", "value" => { "activeTabId" => "doc-2" } }]
289
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
290
+ env = { messages: Brute.log, events: [], tools: [], should_exit: true }
291
+ AgUi::Middleware::State.new(terminal, state: { "documentEditor" => { "activeTabId" => "doc-1" } })
292
+ .call(env)
293
+
294
+ env[:state].should == { "documentEditor" => { "activeTabId" => "doc-2" } }
295
+ env[:events][0][:type].should == :state_delta
296
+ end
297
+
298
+ it "reports a bad patch back to the model as a tool error, no STATE_DELTA emitted" do
299
+ delta = [{ "op" => "replace", "path" => "/missing/deep", "value" => 1 }]
300
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
301
+ env = { messages: Brute.log, events: [], tools: [], state: {}, should_exit: true }
302
+ AgUi::Middleware::State.new(terminal).call(env)
303
+
304
+ env[:events].map { |e| e[:type] }.should == %i[tool_call_result]
305
+ JSON.parse(env[:events][0][:data][:content])["status"].should == "error"
306
+ env[:messages].last.role.should == :tool
307
+ end
308
+
309
+ it "rejects a non-array delta and a non-object snapshot as tool errors" do
310
+ [delta_call.("delta" => "nope"), snapshot_call.("snapshot" => "nope")].each do |msg|
311
+ terminal = ->(env) { env[:messages] << msg }
312
+ env = { messages: Brute.log, events: [], tools: [] }
313
+ AgUi::Middleware::State.new(terminal).call(env)
314
+ JSON.parse(env[:events].last[:data][:content])["status"].should == "error"
315
+ end
316
+ end
317
+
318
+ it "leaves should_exit set when a real client tool is mixed into the turn" do
319
+ terminal = ->(env) do
320
+ env[:messages] << Brute::Message.new(
321
+ role: :assistant, content: nil,
322
+ tool_calls: [
323
+ { id: "tc1", name: "AGUISendStateSnapshot", arguments: { "snapshot" => { "a" => 1 } } },
324
+ { id: "tc2", name: "navigate", arguments: { "path" => "/x" } },
325
+ ],
326
+ )
327
+ end
328
+ env = { messages: Brute.log, events: [], tools: [], should_exit: true }
329
+ AgUi::Middleware::State.new(terminal).call(env)
330
+
331
+ env[:events].any? { |e| e[:type] == :state_snapshot }.should == true
332
+ env[:should_exit].should == true # browser still needs to run navigate
333
+ end
334
+
335
+ it "does nothing on a plain text turn" do
336
+ env = { messages: Brute.log, events: [], tools: [] }
337
+ AgUi::Middleware::State.new(->(e) { e[:messages].assistant("hi") }).call(env)
338
+ env[:events].should == []
339
+ env.key?(:should_exit).should == false
340
+ end
341
+ end
@@ -14,8 +14,8 @@ module AgUi
14
14
  # require "ag_ui/terminals/ruby_llm"
15
15
  #
16
16
  # RubyLLM.configure { |c| c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] }
17
- # run_loop = AgUi::RunLoop.new(system_prompt: PROMPT,
18
- # &AgUi::Terminals::RubyLLM.new)
17
+ # terminal = AgUi::Terminals::RubyLLM.new
18
+ # agent = Brute.agent.use(...).run(terminal) # standard Brute agent
19
19
  #
20
20
  # One assistant turn per call: seeds the chat from env[:messages]
21
21
  # (including prior tool-call turns), registers env[:tools] as
@@ -517,7 +517,7 @@ describe "AgUi::Terminals::RubyLLM" do
517
517
  captured.should == [[:anthropic, "claude-sonnet-4-5"], [:anthropic, "claude-sonnet-4-5"]]
518
518
  end
519
519
 
520
- it "drives the full client-tool run through RunLoop + ToolRouter" do
520
+ it "drives the full client-tool run through a standard Brute agent + ToolRouter" do
521
521
  tool_calls = {
522
522
  "tc1" => ::RubyLLM::ToolCall.new(id: "tc1", name: "navigate",
523
523
  arguments: { "path" => "/data" }),
@@ -525,8 +525,23 @@ describe "AgUi::Terminals::RubyLLM" do
525
525
  fake = fake_chat_class.new(final: nil, tool_calls: tool_calls)
526
526
  terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
527
527
 
528
- run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.", &terminal)
529
- app = AgUi.agent(agent_id: "default", &run_loop)
528
+ app = AgUi.agent(agent_id: "default") do |env|
529
+ input = env["ag_ui.input"]
530
+ agent = Brute.agent
531
+ .use(AgUi::Middleware::SystemPrompt, prompt: "Be terse.", context: input.context)
532
+ .use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
533
+ .use(Brute::Middleware::Loop::ToolResult)
534
+ .use(Brute::Middleware::MaxIterations, max_iterations: 10)
535
+ .use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: [])
536
+ .run(terminal)
537
+ env["ag_ui.stream"].open(thread_id: input.thread_id, run_id: input.run_id) do |stream|
538
+ stream.run_started
539
+ agent.start(AgUi::Messages.to_brute(input.messages), events: AgUi::EventBridge.new(stream))
540
+ stream.run_finished
541
+ rescue => e
542
+ stream.run_error(message: e.message, code: e.class.name)
543
+ end
544
+ end
530
545
 
531
546
  body = JSON.generate({
532
547
  "threadId" => "t1", "runId" => "r1", "state" => nil,
data/lib/ag_ui/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgUi
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ag_ui.rb CHANGED
@@ -25,8 +25,8 @@ require "ag_ui/event_bridge"
25
25
  require "ag_ui/middleware/system_prompt"
26
26
  require "ag_ui/middleware/forwarded_props"
27
27
  require "ag_ui/middleware/tool_router"
28
+ require "ag_ui/middleware/state"
28
29
  require "ag_ui/a2ui/catalog"
29
30
  require "ag_ui/a2ui/validate"
30
31
  require "ag_ui/a2ui/recovery"
31
32
  require "ag_ui/middleware/a2ui"
32
- require "ag_ui/run_loop"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ag-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AgUi Contributors
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '2.5'
68
+ - !ruby/object:Gem::Dependency
69
+ name: hana
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.3'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: brute
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -200,13 +214,13 @@ files:
200
214
  - lib/ag_ui/messages.rb
201
215
  - lib/ag_ui/middleware/a2ui.rb
202
216
  - lib/ag_ui/middleware/forwarded_props.rb
217
+ - lib/ag_ui/middleware/state.rb
203
218
  - lib/ag_ui/middleware/system_prompt.rb
204
219
  - lib/ag_ui/middleware/tool_router.rb
205
220
  - lib/ag_ui/protocol/json_schema.rb
206
221
  - lib/ag_ui/protocol/json_schema/definition.rb
207
222
  - lib/ag_ui/protocol/json_schema/validation_error.rb
208
223
  - lib/ag_ui/run_input.rb
209
- - lib/ag_ui/run_loop.rb
210
224
  - lib/ag_ui/run_store.rb
211
225
  - lib/ag_ui/server.rb
212
226
  - lib/ag_ui/server/info.rb
@@ -1,285 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "console"
5
- require "ag_ui"
6
-
7
- module AgUi
8
- # The run loop: drives one AG-UI run through a brute turn pipeline.
9
- #
10
- # The terminal block is the LLM call (ruby_llm or anything else) — it
11
- # receives brute's env ({messages:, events:, ...}), streams deltas into
12
- # env[:events] (translated live to SSE by EventBridge) and appends the
13
- # assistant message to env[:messages]. The gem stays LLM-agnostic.
14
- #
15
- # run_loop = AgUi::RunLoop.new(system_prompt: PROMPT) do |env|
16
- # env[:events] << { type: :text_message_start, data: { message_id: id } }
17
- # ... stream provider chunks ...
18
- # env[:events] << { type: :text_message_end, data: { message_id: id } }
19
- # env[:messages].assistant(full_text)
20
- # end
21
- #
22
- # app = AgUi.agent(agent_id: "default", &run_loop)
23
- #
24
- # Lifecycle per run (the AG-UI contract): RUN_STARTED first; then the
25
- # pipeline streams; then RUN_FINISHED — or RUN_ERROR if the pipeline
26
- # raised. Schema-validation failures are wire-contract bugs and re-raise
27
- # after RUN_ERROR so they fail loudly in dev.
28
- class RunLoop
29
- # a2ui: nil/false = off; an AgUi::A2ui::Catalog = on with that catalog;
30
- # true = on degraded (tool injected, no component schema).
31
- # server_tools: [{name:, description:, parameters:, handler:}] execute
32
- # inline and the turn loops (Loop::ToolResult) until the model answers
33
- # in text, a client tool defers to the browser, or max_iterations hits.
34
- def initialize(system_prompt: nil, validate: true, middleware: [], a2ui: nil,
35
- server_tools: [], max_iterations: 10, &terminal)
36
- unless terminal
37
- raise ArgumentError, "RunLoop requires a terminal block (the LLM call)"
38
- end
39
-
40
- @system_prompt = system_prompt
41
- @validate = validate
42
- @middleware = middleware
43
- @a2ui = a2ui
44
- @server_tools = server_tools
45
- @max_iterations = max_iterations
46
- @terminal = terminal
47
- end
48
-
49
- def a2ui_enabled?
50
- !(@a2ui.nil? || @a2ui == false)
51
- end
52
-
53
- # AgUi.agent takes a block; RunLoop quacks like one.
54
- def to_proc
55
- run_loop = self
56
- proc { |rack_env| run_loop.handle(rack_env) }
57
- end
58
-
59
- def handle(rack_env)
60
- input = rack_env["ag_ui.input"]
61
-
62
- rack_env["ag_ui.stream"].open(
63
- thread_id: input.thread_id,
64
- run_id: input.run_id,
65
- validate: @validate,
66
- ) do |stream|
67
- stream.run_started
68
- run(stream, input)
69
- end
70
- end
71
-
72
- private
73
-
74
- def run(stream, input)
75
- pipeline(input).start(
76
- Messages.to_brute(input.messages),
77
- events: EventBridge.new(stream),
78
- )
79
- stream.run_finished
80
- rescue Protocol::JsonSchema::ValidationError => e
81
- Console.error(self, "wire-contract bug: #{e.message}", e)
82
- stream.run_error(message: "Internal error", code: "validation")
83
- raise
84
- rescue => e
85
- Console.error(self, "run failed: #{e.class}: #{e.message}", e)
86
- stream.run_error(message: e.message, code: e.class.name)
87
- end
88
-
89
- def pipeline(input)
90
- agent = Brute.agent
91
- agent.use(
92
- AgUi::Middleware::SystemPrompt,
93
- prompt: @system_prompt,
94
- context: input.context,
95
- )
96
- agent.use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
97
- agent.use(Brute::Middleware::Loop::ToolResult)
98
- agent.use(Brute::Middleware::MaxIterations, max_iterations: @max_iterations)
99
- if a2ui_enabled?
100
- catalog = @a2ui.is_a?(AgUi::A2ui::Catalog) ? @a2ui : nil
101
- agent.use(AgUi::Middleware::A2ui, catalog: catalog)
102
- end
103
- agent.use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: @server_tools)
104
- @middleware.each do |(klass, options)|
105
- agent.use(klass, **(options || {}))
106
- end
107
- agent.run(@terminal)
108
- end
109
- end
110
- end
111
-
112
- __END__
113
-
114
- describe "AgUi::RunLoop" do
115
- minimal_input = JSON.generate({
116
- "threadId" => "t1", "runId" => "r1", "state" => nil,
117
- "messages" => [{ "id" => "u1", "role" => "user", "content" => "hi" }],
118
- "tools" => [], "context" => [{ "description" => "currentPath", "value" => "/data" }],
119
- "forwardedProps" => nil,
120
- })
121
-
122
- request = ->(app, body) do
123
- app.call({
124
- "REQUEST_METHOD" => "POST",
125
- "PATH_INFO" => "/agent/default/run",
126
- "rack.input" => StringIO.new(body),
127
- })
128
- end
129
-
130
- read_frames = ->(stream) do
131
- frames = []
132
- while (chunk = stream.read)
133
- frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
134
- end
135
- frames
136
- end
137
-
138
- it "streams a full run through a brute pipeline" do
139
- seen_env = nil
140
- run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.") do |env|
141
- seen_env = env
142
- env[:events] << { type: :text_message_start, data: { message_id: "m1" } }
143
- env[:events] << { type: :text_message_content, data: { message_id: "m1", delta: "Hello" } }
144
- env[:events] << { type: :text_message_end, data: { message_id: "m1" } }
145
- env[:messages].assistant("Hello")
146
- end
147
-
148
- app = AgUi.agent(agent_id: "default", &run_loop)
149
- _status, _headers, body = request.(app, minimal_input)
150
-
151
- frames = read_frames.(body)
152
- frames.map { |f| f["type"] }.should == %w[
153
- RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
154
- ]
155
-
156
- # The pipeline saw: system prompt (with context addendum) + history.
157
- seen_env[:messages].map(&:role).should == [:system, :user, :assistant]
158
- seen_env[:messages].first.content.should.include?("Be terse.")
159
- seen_env[:messages].first.content.should.include?("currentPath: /data")
160
- end
161
-
162
- it "ends the run with RUN_ERROR when the terminal raises" do
163
- run_loop = AgUi::RunLoop.new { |_env| raise "provider exploded" }
164
- app = AgUi.agent(agent_id: "default", &run_loop)
165
-
166
- _status, _headers, body = request.(app, minimal_input)
167
- frames = read_frames.(body)
168
-
169
- frames.map { |f| f["type"] }.should == %w[RUN_STARTED RUN_ERROR]
170
- frames.last["message"].should == "provider exploded"
171
- frames.last["code"].should == "RuntimeError"
172
- end
173
-
174
- it "supports extra brute middleware" do
175
- marker = Class.new do
176
- def initialize(app, note:)
177
- @app = app
178
- @note = note
179
- end
180
-
181
- def call(env)
182
- env[:metadata][:note] = @note
183
- @app.call(env)
184
- end
185
- end
186
-
187
- seen = nil
188
- run_loop = AgUi::RunLoop.new(middleware: [[marker, { note: "hi" }]]) do |env|
189
- seen = env[:metadata][:note]
190
- env[:messages].assistant("ok")
191
- end
192
-
193
- request.(AgUi.agent(&run_loop), minimal_input)
194
- seen.should == "hi"
195
- end
196
-
197
- it "requires a terminal block" do
198
- lambda { AgUi::RunLoop.new }.should.raise(ArgumentError)
199
- end
200
-
201
- it "executes server tools inline and loops the turn to completion" do
202
- weather_tool = {
203
- name: "get_weather",
204
- description: "Weather for a city",
205
- parameters: { "type" => "object" },
206
- handler: ->(args) { { "city" => args["city"], "temp" => 21 } },
207
- }
208
-
209
- iterations = 0
210
- terminal = ->(env) do
211
- iterations += 1
212
- if iterations == 1
213
- env[:messages] << Brute::Message.new(
214
- role: :assistant, content: nil,
215
- tool_calls: [{ id: "tc1", name: "get_weather", arguments: { "city" => "Lisbon" } }],
216
- )
217
- else
218
- # The model sees its own call + the executed result.
219
- env[:messages].last.role.should == :tool
220
- env[:messages].last.content.should == "{\"city\":\"Lisbon\",\"temp\":21}"
221
- env[:events] << { type: :text_message_start, data: { message_id: "m2" } }
222
- env[:events] << { type: :text_message_content, data: { message_id: "m2", delta: "21C in Lisbon" } }
223
- env[:events] << { type: :text_message_end, data: { message_id: "m2" } }
224
- env[:messages].assistant("21C in Lisbon")
225
- end
226
- end
227
-
228
- run_loop = AgUi::RunLoop.new(server_tools: [weather_tool], &terminal)
229
- app = AgUi.agent(agent_id: "default", &run_loop)
230
-
231
- _status, _headers, stream = request.(app, minimal_input)
232
- frames = read_frames.(stream)
233
-
234
- iterations.should == 2
235
- frames.map { |f| f["type"] }.should == %w[
236
- RUN_STARTED
237
- TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_RESULT
238
- TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END
239
- RUN_FINISHED
240
- ]
241
- frames[4]["content"].should == "{\"city\":\"Lisbon\",\"temp\":21}"
242
- end
243
-
244
- it "streams the full A2UI sequence when the model renders a surface" do
245
- catalog = AgUi::A2ui::Catalog.new(
246
- catalog_id: "host://ai-catalog",
247
- components: { "Card" => {} },
248
- )
249
-
250
- terminal = ->(env) do
251
- env[:tools].map { |t| t["name"] }.should.include?("render_a2ui")
252
- env[:messages].first.content.should.include?("A2UI Component Schema")
253
-
254
- env[:messages] << Brute::Message.new(
255
- role: :assistant, content: nil,
256
- tool_calls: [{ id: "tc9", name: "render_a2ui", arguments: {
257
- "surfaceId" => "s1",
258
- "components" => [{ "id" => "root", "component" => "Card" }],
259
- } }],
260
- )
261
- end
262
-
263
- run_loop = AgUi::RunLoop.new(a2ui: catalog, &terminal)
264
- app = AgUi.agent(agent_id: "default", &run_loop)
265
-
266
- _status, _headers, stream = request.(app, minimal_input)
267
- frames = read_frames.(stream)
268
-
269
- frames.map { |f| f["type"] }.should == %w[
270
- RUN_STARTED TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END
271
- ACTIVITY_SNAPSHOT TOOL_CALL_RESULT RUN_FINISHED
272
- ]
273
-
274
- snapshot = frames[4]
275
- snapshot["messageId"].should == "a2ui-surface-tc9"
276
- snapshot["activityType"].should == "a2ui-surface"
277
- snapshot["replace"].should == true
278
- snapshot["content"]["a2ui_operations"][0]["createSurface"]["catalogId"]
279
- .should == "host://ai-catalog"
280
-
281
- result = frames[5]
282
- result["toolCallId"].should == "tc9"
283
- result["content"].should == "{\"status\":\"rendered\"}"
284
- end
285
- end