ag-ui 0.1.0 → 0.2.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: ba6811e09735cfc95262ba711a5e953a92d9af8d78ea8b13867f1d1c60fbeedc
4
+ data.tar.gz: 75e995ce5c17476566258e05e5b56de4481b3df8539554e91d687363e15ad456
5
5
  SHA512:
6
- metadata.gz: e4a40843efccd8f2a995e86816e7108f34e069aac3698f44c94c3d07873fb1f194630f133dae9521abb30803b5420a93742af89c8bc884997521369ba3d368fc
7
- data.tar.gz: 5fedf59e8db8548b66db608308c73733b863b09013640cf82139430f6b2b8ac4882baf638ae0fc240dcfd5cfe458c889edc957b9201e5196e30f07ad86847b94
6
+ metadata.gz: 7e1bfaaa5c833b60ab7ce9a99d069abd3603eaf8366abf355b73688b07c54fb1d76fd280fb46a00e3e0e01a5cbf9de8d7b4ce7e59b581679eff92f7f891842c2
7
+ data.tar.gz: 205c4a2f519bcbfcf1fcb975de4929da568633d1358a793c78b042424c867918cce430dd6c9289b965ead31bb1a218a4d706b14e7476093a2ac42d7a10a1e21b
@@ -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.2.0"
5
5
  end
data/lib/ag_ui.rb CHANGED
@@ -29,4 +29,3 @@ require "ag_ui/a2ui/catalog"
29
29
  require "ag_ui/a2ui/validate"
30
30
  require "ag_ui/a2ui/recovery"
31
31
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AgUi Contributors
@@ -206,7 +206,6 @@ files:
206
206
  - lib/ag_ui/protocol/json_schema/definition.rb
207
207
  - lib/ag_ui/protocol/json_schema/validation_error.rb
208
208
  - lib/ag_ui/run_input.rb
209
- - lib/ag_ui/run_loop.rb
210
209
  - lib/ag_ui/run_store.rb
211
210
  - lib/ag_ui/server.rb
212
211
  - 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