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 +7 -0
- data/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
|
@@ -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
|
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"
|