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,558 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "securerandom"
5
+ require "ag_ui"
6
+ require "ruby_llm"
7
+
8
+ module AgUi
9
+ module Terminals
10
+ # The ruby_llm terminal — the LLM call at the bottom of the brute
11
+ # pipeline. NOT required by lib/ag_ui.rb (the gem core stays
12
+ # LLM-agnostic); opt in with:
13
+ #
14
+ # require "ag_ui/terminals/ruby_llm"
15
+ #
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)
19
+ #
20
+ # One assistant turn per call: seeds the chat from env[:messages]
21
+ # (including prior tool-call turns), registers env[:tools] as
22
+ # schema-only CLIENT tools (they halt — never execute server-side),
23
+ # streams text deltas into env[:events], then appends either the
24
+ # assistant text or the assistant tool-call message to env[:messages]
25
+ # for the ToolRouter to route.
26
+ #
27
+ # Accepts the host app's "anthropic/claude-sonnet-4-5" env form or a bare
28
+ # ruby_llm model id.
29
+ class RubyLLM
30
+ # thinking: {effort:} or {budget:} enables extended thinking
31
+ # (ruby_llm with_thinking); its deltas stream as REASONING_* events.
32
+ def initialize(model: "anthropic/claude-sonnet-4-5", thinking: nil, chat_factory: nil)
33
+ @provider, @model = split_model(model)
34
+ @thinking = thinking
35
+ @chat_factory = chat_factory || default_chat_factory
36
+ end
37
+
38
+ def call(env)
39
+ chat = @chat_factory.call(model: @model, provider: @provider)
40
+ if @thinking
41
+ chat.with_thinking(**@thinking)
42
+ end
43
+ register_client_tools(chat, env[:tools])
44
+ apply_tool_choice(chat, env)
45
+ seed(chat, env[:messages])
46
+
47
+ emitter = TurnEmitter.new(env[:events])
48
+ chat.complete do |chunk|
49
+ thinking = chunk.thinking
50
+ if thinking&.text
51
+ emitter.thinking_delta(thinking.text.to_s)
52
+ end
53
+ emitter.text_delta(chunk.content.to_s)
54
+ end
55
+ emitter.finish
56
+
57
+ conclude(chat, env)
58
+ env
59
+ end
60
+
61
+ def to_proc
62
+ method(:call).to_proc
63
+ end
64
+
65
+ # A client tool: full schema for the provider, no server execution.
66
+ # When the model calls it, execution "halts" ruby_llm's auto tool
67
+ # loop — the assistant tool-call message is already in chat.messages
68
+ # and the browser owns the actual execution (doc 03 multi-run model).
69
+ class ClientTool < ::RubyLLM::Tool
70
+ def initialize(name:, description:, parameters:)
71
+ @client_name = name
72
+ @client_description = description
73
+ @client_schema = parameters
74
+ super()
75
+ end
76
+
77
+ def name = @client_name
78
+ def description = @client_description
79
+ def params_schema = @client_schema
80
+
81
+ # Bypass arg validation entirely — the browser is the executor
82
+ # and the next run carries its result back in history.
83
+ def call(_args)
84
+ halt("(deferred to client)")
85
+ end
86
+ end
87
+
88
+ # Emits the turn's reasoning + text phases lazily: nothing opens
89
+ # until its first non-empty delta (tool-call-only turns produce no
90
+ # empty bubbles), and reasoning closes as soon as text begins —
91
+ # providers stream thinking strictly before the answer.
92
+ class TurnEmitter
93
+ def initialize(events)
94
+ @events = events
95
+ @text_id = SecureRandom.uuid
96
+ @reasoning_id = SecureRandom.uuid
97
+ @text_started = false
98
+ @reasoning_open = false
99
+ end
100
+
101
+ def thinking_delta(text)
102
+ unless text.empty?
103
+ open_reasoning
104
+ @events << {
105
+ type: :reasoning_message_content,
106
+ data: { message_id: @reasoning_id, delta: text },
107
+ }
108
+ end
109
+ end
110
+
111
+ def text_delta(text)
112
+ unless text.empty?
113
+ close_reasoning
114
+ unless @text_started
115
+ @events << { type: :text_message_start, data: { message_id: @text_id } }
116
+ @text_started = true
117
+ end
118
+ @events << {
119
+ type: :text_message_content,
120
+ data: { message_id: @text_id, delta: text },
121
+ }
122
+ end
123
+ end
124
+
125
+ def finish
126
+ close_reasoning
127
+ if @text_started
128
+ @events << { type: :text_message_end, data: { message_id: @text_id } }
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ def open_reasoning
135
+ unless @reasoning_open
136
+ @events << { type: :reasoning_start, data: { message_id: @reasoning_id } }
137
+ @events << { type: :reasoning_message_start, data: { message_id: @reasoning_id } }
138
+ @reasoning_open = true
139
+ end
140
+ end
141
+
142
+ def close_reasoning
143
+ if @reasoning_open
144
+ @events << { type: :reasoning_message_end, data: { message_id: @reasoning_id } }
145
+ @events << { type: :reasoning_end, data: { message_id: @reasoning_id } }
146
+ @reasoning_open = false
147
+ end
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def split_model(model)
154
+ if model.include?("/")
155
+ provider, id = model.split("/", 2)
156
+ [provider.to_sym, id]
157
+ else
158
+ [:anthropic, model]
159
+ end
160
+ end
161
+
162
+ def default_chat_factory
163
+ ->(model:, provider:) { ::RubyLLM.chat(model: model, provider: provider) }
164
+ end
165
+
166
+ # forwardedProps.toolChoice ({type: "function", function: {name}})
167
+ # forces the named tool — the suggestions engine relies on this to
168
+ # force copilotkitSuggest.
169
+ def apply_tool_choice(chat, env)
170
+ props = env[:forwarded_props]
171
+ choice = props.is_a?(Hash) ? props["toolChoice"] : nil
172
+ name = choice.is_a?(Hash) ? choice.dig("function", "name") : nil
173
+ if name
174
+ chat.with_tools(choice: name)
175
+ end
176
+ end
177
+
178
+ def register_client_tools(chat, tools)
179
+ (tools || []).each do |tool|
180
+ chat.with_tool(
181
+ ClientTool.new(
182
+ name: dig_tool(tool, "name"),
183
+ description: dig_tool(tool, "description"),
184
+ parameters: dig_tool(tool, "parameters"),
185
+ ),
186
+ )
187
+ end
188
+ end
189
+
190
+ # RunAgentInput.tools arrive as JsonSchema Definitions; accept
191
+ # plain wire hashes too.
192
+ def dig_tool(tool, key)
193
+ if tool.respond_to?(:to_h)
194
+ tool.to_h[key]
195
+ else
196
+ tool[key]
197
+ end
198
+ end
199
+
200
+ # Seed the chat from the brute log. System content goes through
201
+ # with_instructions (providers hoist it correctly); assistant
202
+ # tool-call turns and tool results are replayed so the model sees
203
+ # its prior calls and doesn't repeat them.
204
+ def seed(chat, messages)
205
+ messages.each do |message|
206
+ case message.role
207
+ when :system
208
+ chat.with_instructions(message.content.to_s, append: true)
209
+ when :user
210
+ chat.add_message(role: :user, content: user_content(message.content))
211
+ when :assistant
212
+ seed_assistant(chat, message)
213
+ when :tool
214
+ chat.add_message(
215
+ role: :tool,
216
+ content: message.content.to_s,
217
+ tool_call_id: message.tool_call_id,
218
+ )
219
+ end
220
+ end
221
+ end
222
+
223
+ # Multimodal user content (InputContent part arrays, preserved by
224
+ # Messages.to_brute) becomes a ruby_llm Content: text parts joined,
225
+ # media parts attached — url sources as-is, base64 data sources as
226
+ # io-like attachments named from their mime type.
227
+ def user_content(content)
228
+ if content.is_a?(Array)
229
+ text = content.filter_map do |part|
230
+ if part["type"] == "text"
231
+ part["text"]
232
+ end
233
+ end
234
+ rich = ::RubyLLM::Content.new(text.join("\n"))
235
+ content.each do |part|
236
+ attach_part(rich, part)
237
+ end
238
+ rich
239
+ else
240
+ content.to_s
241
+ end
242
+ end
243
+
244
+ def attach_part(rich, part)
245
+ source = part["source"]
246
+ if source.is_a?(Hash)
247
+ case source["type"]
248
+ when "url"
249
+ rich.add_attachment(source["value"].to_s)
250
+ when "data"
251
+ io = StringIO.new(source["value"].to_s.unpack1("m"))
252
+ rich.add_attachment(io, filename: data_filename(source))
253
+ end
254
+ end
255
+ end
256
+
257
+ def data_filename(source)
258
+ ext = source["mimeType"].to_s.split("/").last.to_s
259
+ if ext.empty?
260
+ "attachment.bin"
261
+ else
262
+ "attachment.#{ext}"
263
+ end
264
+ end
265
+
266
+ def seed_assistant(chat, message)
267
+ if message.tool_call?
268
+ tool_calls = message.tool_calls.to_h do |tc|
269
+ [tc.id, ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)]
270
+ end
271
+ chat.add_message(role: :assistant, content: message.content.to_s, tool_calls: tool_calls)
272
+ else
273
+ chat.add_message(role: :assistant, content: message.content.to_s)
274
+ end
275
+ end
276
+
277
+ # After the turn: did the model end on tool calls or text? The
278
+ # halting ClientTool leaves the assistant tool-call message in
279
+ # chat.messages (followed by the synthetic halt result) — surface
280
+ # it as a brute message for the ToolRouter.
281
+ def conclude(chat, env)
282
+ assistant = chat.messages.reverse.find { |m| m.role == :assistant }
283
+
284
+ if assistant&.tool_call?
285
+ env[:messages] << Brute::Message.new(
286
+ role: :assistant,
287
+ content: assistant.content.to_s,
288
+ tool_calls: assistant.tool_calls.each_value.map do |tc|
289
+ { id: tc.id, name: tc.name, arguments: tc.arguments }
290
+ end,
291
+ )
292
+ else
293
+ env[:messages].assistant(assistant ? assistant.content.to_s : "")
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ __END__
301
+
302
+ describe "AgUi::Terminals::RubyLLM" do
303
+ fake_message = Struct.new(:role, :content, :tool_calls, keyword_init: true) do
304
+ def tool_call?
305
+ !tool_calls.nil? && !tool_calls.empty?
306
+ end
307
+ end
308
+
309
+ fake_chat_class = Class.new do
310
+ attr_reader :instructions, :seeded, :tools, :messages, :thinking_config
311
+
312
+ FakeChunk = Struct.new(:content, :thinking)
313
+ FakeThinking = Struct.new(:text)
314
+
315
+ def initialize(chunks: [], final: "", tool_calls: nil, thinking_chunks: [])
316
+ @chunks = chunks
317
+ @thinking_chunks = thinking_chunks
318
+ @final = final
319
+ @tool_calls = nil
320
+ @final_tool_calls = tool_calls
321
+ @instructions = []
322
+ @seeded = []
323
+ @tools = []
324
+ @messages = []
325
+ @thinking_config = nil
326
+ end
327
+
328
+ def with_thinking(**config)
329
+ @thinking_config = config
330
+ self
331
+ end
332
+
333
+ def with_instructions(text, append: false)
334
+ @instructions << text
335
+ self
336
+ end
337
+
338
+ def with_tool(tool)
339
+ @tools << tool
340
+ self
341
+ end
342
+
343
+ attr_reader :tool_choice
344
+
345
+ def with_tools(*tools, choice: nil, **)
346
+ @tools.concat(tools)
347
+ @tool_choice = choice
348
+ self
349
+ end
350
+
351
+ def add_message(attributes)
352
+ @seeded << attributes
353
+ @messages << Struct.new(:role, :content, :tool_calls, keyword_init: true) do
354
+ def tool_call?
355
+ !tool_calls.nil? && !tool_calls.empty?
356
+ end
357
+ end.new(role: attributes[:role], content: attributes[:content], tool_calls: attributes[:tool_calls])
358
+ self
359
+ end
360
+
361
+ def complete(&block)
362
+ @thinking_chunks.each { |t| block.call(FakeChunk.new(nil, FakeThinking.new(t))) }
363
+ @chunks.each { |c| block.call(FakeChunk.new(c, nil)) }
364
+ add_message(role: :assistant, content: @final, tool_calls: @final_tool_calls)
365
+ @messages.last
366
+ end
367
+ end
368
+
369
+ build_env = -> do
370
+ { messages: Brute.log, events: [], metadata: {}, current_iteration: 1 }
371
+ end
372
+
373
+ it "streams deltas into env[:events] and appends the assistant message" do
374
+ fake = fake_chat_class.new(chunks: ["Hel", "", "lo"], final: "Hello")
375
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
376
+
377
+ env = build_env.()
378
+ env[:messages].user("hi")
379
+ terminal.call(env)
380
+
381
+ types = env[:events].map { |e| e[:type] }
382
+ types.should == [:text_message_start, :text_message_content, :text_message_content, :text_message_end]
383
+ env[:events][1][:data][:delta].should == "Hel"
384
+
385
+ env[:messages].last.role.should == :assistant
386
+ env[:messages].last.content.should == "Hello"
387
+ end
388
+
389
+ it "registers env[:tools] as schema-only halting client tools" do
390
+ fake = fake_chat_class.new(final: "ok")
391
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
392
+
393
+ env = build_env.()
394
+ env[:tools] = [{ "name" => "navigate", "description" => "Go somewhere",
395
+ "parameters" => { "type" => "object" } }]
396
+ terminal.call(env)
397
+
398
+ tool = fake.tools.first
399
+ tool.name.should == "navigate"
400
+ tool.description.should == "Go somewhere"
401
+ tool.params_schema.should == { "type" => "object" }
402
+ tool.call({ "path" => "/x" }).should.be.kind_of(::RubyLLM::Tool::Halt)
403
+ end
404
+
405
+ it "surfaces a tool-call turn as a brute assistant message with tool_calls" do
406
+ tool_calls = {
407
+ "tc1" => ::RubyLLM::ToolCall.new(id: "tc1", name: "navigate",
408
+ arguments: { "path" => "/data" }),
409
+ }
410
+ fake = fake_chat_class.new(final: nil, tool_calls: tool_calls)
411
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
412
+
413
+ env = build_env.()
414
+ env[:messages].user("go to data")
415
+ terminal.call(env)
416
+
417
+ # No text events for a tool-only turn (lazy start).
418
+ env[:events].should == []
419
+
420
+ last = env[:messages].last
421
+ last.tool_call?.should.be.true
422
+ last.tool_calls.first.id.should == "tc1"
423
+ last.tool_calls.first.arguments.should == { "path" => "/data" }
424
+ end
425
+
426
+ it "seeds prior tool-call turns and tool results back into the chat" do
427
+ fake = fake_chat_class.new(final: "done")
428
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
429
+
430
+ env = build_env.()
431
+ env[:messages] << Brute::Message.new(role: :user, content: "go")
432
+ env[:messages] << Brute::Message.new(
433
+ role: :assistant, content: nil,
434
+ tool_calls: [{ id: "tc1", name: "navigate", arguments: { "path" => "/data" } }],
435
+ )
436
+ env[:messages] << Brute::Message.new(role: :tool, content: "{\"ok\":true}", tool_call_id: "tc1")
437
+ terminal.call(env)
438
+
439
+ assistant_seed = fake.seeded[1]
440
+ assistant_seed[:role].should == :assistant
441
+ assistant_seed[:tool_calls]["tc1"].name.should == "navigate"
442
+
443
+ tool_seed = fake.seeded[2]
444
+ tool_seed[:role].should == :tool
445
+ tool_seed[:tool_call_id].should == "tc1"
446
+ end
447
+
448
+ it "forces the tool named by forwardedProps.toolChoice" do
449
+ fake = fake_chat_class.new(final: "ok")
450
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
451
+
452
+ env = build_env.()
453
+ env[:forwarded_props] = {
454
+ "toolChoice" => { "type" => "function", "function" => { "name" => "copilotkitSuggest" } },
455
+ }
456
+ terminal.call(env)
457
+
458
+ fake.tool_choice.should == "copilotkitSuggest"
459
+ end
460
+
461
+ it "streams thinking deltas as a reasoning phase closed before text" do
462
+ fake = fake_chat_class.new(
463
+ thinking_chunks: ["Let me ", "think..."],
464
+ chunks: ["The answer"],
465
+ final: "The answer",
466
+ )
467
+ terminal = AgUi::Terminals::RubyLLM.new(thinking: { budget: 1024 }, chat_factory: ->(**) { fake })
468
+
469
+ env = build_env.()
470
+ env[:messages].user("why?")
471
+ terminal.call(env)
472
+
473
+ fake.thinking_config.should == { budget: 1024 }
474
+ env[:events].map { |e| e[:type] }.should == %i[
475
+ reasoning_start reasoning_message_start
476
+ reasoning_message_content reasoning_message_content
477
+ reasoning_message_end reasoning_end
478
+ text_message_start text_message_content text_message_end
479
+ ]
480
+
481
+ reasoning_ids = env[:events].first(6).map { |e| e[:data][:message_id] }.uniq
482
+ reasoning_ids.length.should == 1
483
+ env[:events].last[:data][:message_id].should.not == reasoning_ids.first
484
+ end
485
+
486
+ it "seeds multimodal user content as ruby_llm Content with attachments" do
487
+ fake = fake_chat_class.new(final: "ok")
488
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
489
+
490
+ png = ["\x89PNG fake"].pack("m0")
491
+ env = build_env.()
492
+ env[:messages] << Brute::Message.new(role: :user, content: [
493
+ { "type" => "text", "text" => "what is this?" },
494
+ { "type" => "image", "source" => { "type" => "data", "value" => png, "mimeType" => "image/png" } },
495
+ { "type" => "document", "source" => { "type" => "url", "value" => "http://x/spec.pdf" } },
496
+ ])
497
+ terminal.call(env)
498
+
499
+ content = fake.seeded.first[:content]
500
+ content.should.be.kind_of(::RubyLLM::Content)
501
+ content.text.should == "what is this?"
502
+ content.attachments.length.should == 2
503
+ content.attachments.first.filename.should == "attachment.png"
504
+ end
505
+
506
+ it "splits host-app-style provider/model ids and defaults to anthropic" do
507
+ captured = []
508
+ factory = ->(model:, provider:) do
509
+ captured << [provider, model]
510
+ fake_chat_class.new(final: "ok")
511
+ end
512
+
513
+ AgUi::Terminals::RubyLLM.new(model: "anthropic/claude-sonnet-4-5", chat_factory: factory)
514
+ .call(build_env.())
515
+ AgUi::Terminals::RubyLLM.new(model: "claude-sonnet-4-5", chat_factory: factory)
516
+ .call(build_env.())
517
+ captured.should == [[:anthropic, "claude-sonnet-4-5"], [:anthropic, "claude-sonnet-4-5"]]
518
+ end
519
+
520
+ it "drives the full client-tool run through RunLoop + ToolRouter" do
521
+ tool_calls = {
522
+ "tc1" => ::RubyLLM::ToolCall.new(id: "tc1", name: "navigate",
523
+ arguments: { "path" => "/data" }),
524
+ }
525
+ fake = fake_chat_class.new(final: nil, tool_calls: tool_calls)
526
+ terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
527
+
528
+ run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.", &terminal)
529
+ app = AgUi.agent(agent_id: "default", &run_loop)
530
+
531
+ body = JSON.generate({
532
+ "threadId" => "t1", "runId" => "r1", "state" => nil,
533
+ "messages" => [{ "id" => "u1", "role" => "user", "content" => "go to data" }],
534
+ "tools" => [{ "name" => "navigate", "description" => "Go", "parameters" => { "type" => "object" } }],
535
+ "context" => [], "forwardedProps" => nil,
536
+ })
537
+ _status, _headers, stream = app.call({
538
+ "REQUEST_METHOD" => "POST",
539
+ "PATH_INFO" => "/agent/default/run",
540
+ "rack.input" => StringIO.new(body),
541
+ })
542
+
543
+ frames = []
544
+ while (chunk = stream.read)
545
+ frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
546
+ end
547
+
548
+ frames.map { |f| f["type"] }.should == %w[
549
+ RUN_STARTED TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END RUN_FINISHED
550
+ ]
551
+ frames[1]["toolCallId"].should == "tc1"
552
+ frames[1]["toolCallName"].should == "navigate"
553
+ frames[2]["delta"].should == "{\"path\":\"/data\"}"
554
+
555
+ # Plain RUN_FINISHED — no result, no outcome (doc 09 §4).
556
+ frames.last.should == { "type" => "RUN_FINISHED", "threadId" => "t1", "runId" => "r1" }
557
+ end
558
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgUi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ag_ui.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+ require "rack"
6
+ require "stringio"
7
+
8
+ module AgUi
9
+ end
10
+
11
+ require "ag_ui/version"
12
+ require "ag_ui/protocol/json_schema"
13
+ require "ag_ui/protocol/json_schema/definition"
14
+ require "ag_ui/protocol/json_schema/validation_error"
15
+ require "ag_ui/server/sse/event_encoder"
16
+ require "ag_ui/server/sse/stream"
17
+ require "ag_ui/server/middleware/sse_stream"
18
+ require "ag_ui/run_input"
19
+ require "ag_ui/run_store"
20
+ require "ag_ui/server"
21
+
22
+ require "brute"
23
+ require "ag_ui/messages"
24
+ require "ag_ui/event_bridge"
25
+ require "ag_ui/middleware/system_prompt"
26
+ require "ag_ui/middleware/forwarded_props"
27
+ require "ag_ui/middleware/tool_router"
28
+ require "ag_ui/a2ui/catalog"
29
+ require "ag_ui/a2ui/validate"
30
+ require "ag_ui/a2ui/recovery"
31
+ require "ag_ui/middleware/a2ui"
32
+ require "ag_ui/run_loop"