ag-ui 0.2.0 → 1.0.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 +4 -4
- data/data/ag_ui.json +1 -1
- data/lib/ag_ui/a2ui/recovery.rb +19 -12
- data/lib/ag_ui/a2ui/validate.rb +150 -89
- data/lib/ag_ui/event_bridge.rb +144 -44
- data/lib/ag_ui/messages.rb +20 -22
- data/lib/ag_ui/middleware/a2ui.rb +109 -82
- data/lib/ag_ui/middleware/forwarded_props.rb +1 -1
- data/lib/ag_ui/middleware/state.rb +366 -0
- data/lib/ag_ui/middleware/system_prompt.rb +1 -1
- data/lib/ag_ui/middleware/tool_router.rb +63 -44
- data/lib/ag_ui/protocol/json_schema/definition.rb +5 -5
- data/lib/ag_ui/protocol/json_schema.rb +18 -14
- data/lib/ag_ui/run_input.rb +1 -1
- data/lib/ag_ui/server/info.rb +10 -10
- data/lib/ag_ui/server/middleware/sse_stream.rb +3 -3
- data/lib/ag_ui/server/sse/event_encoder.rb +11 -11
- data/lib/ag_ui/server/sse/stream.rb +5 -1
- data/lib/ag_ui/server/triage.rb +11 -8
- data/lib/ag_ui/server.rb +14 -10
- data/lib/ag_ui/terminals/ruby_llm.rb +68 -39
- data/lib/ag_ui/version.rb +1 -1
- data/lib/ag_ui.rb +1 -0
- metadata +45 -9
- data/data/generate-ag-ui-schema.py +0 -50
|
@@ -9,7 +9,7 @@ module AgUi
|
|
|
9
9
|
# middleware and the terminal can honor client-driven knobs — most
|
|
10
10
|
# importantly `toolChoice` ({type: "function", function: {name}}),
|
|
11
11
|
# which the suggestions engine uses to force `copilotkitSuggest`.
|
|
12
|
-
class ForwardedProps
|
|
12
|
+
class ForwardedProps < Brute::Middleware::Base
|
|
13
13
|
def initialize(app, props: nil)
|
|
14
14
|
@app = app
|
|
15
15
|
@props = props
|
|
@@ -0,0 +1,366 @@
|
|
|
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 < Brute::Middleware::Base
|
|
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.emit(:state_snapshot, { 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.emit(:state_delta, { 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
|
+
if error
|
|
190
|
+
content = { "status" => "error", "error" => error }
|
|
191
|
+
else
|
|
192
|
+
content = { "status" => "ok" }
|
|
193
|
+
end
|
|
194
|
+
json = JSON.generate(content)
|
|
195
|
+
env[:messages].tool(json, tool_call_id: tool_call.id)
|
|
196
|
+
env.emit(
|
|
197
|
+
:tool_call_result,
|
|
198
|
+
{
|
|
199
|
+
message_id: SecureRandom.uuid,
|
|
200
|
+
tool_call_id: tool_call.id,
|
|
201
|
+
content: json,
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize(state)
|
|
207
|
+
if state.nil?
|
|
208
|
+
nil
|
|
209
|
+
elsif state.respond_to?(:to_h)
|
|
210
|
+
state.to_h
|
|
211
|
+
else
|
|
212
|
+
state
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def deep_dup(obj)
|
|
217
|
+
case obj
|
|
218
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
219
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
220
|
+
else obj
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
__END__
|
|
228
|
+
|
|
229
|
+
describe "AgUi::Middleware::State" do
|
|
230
|
+
|
|
231
|
+
# Brute 6 replaced the env[:events] sink with the hooks registry, so a test
|
|
232
|
+
# that wants to see what a middleware emitted subscribes instead of reading
|
|
233
|
+
# an array off the env. This records the vocabulary State speaks, in
|
|
234
|
+
# emission order.
|
|
235
|
+
STATE_EVENTS = %i[state_snapshot state_delta tool_call_result].freeze
|
|
236
|
+
|
|
237
|
+
def recording(env, into)
|
|
238
|
+
hooks = Brute::Hooks::Registry.new
|
|
239
|
+
STATE_EVENTS.each do |event|
|
|
240
|
+
hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
|
|
241
|
+
end
|
|
242
|
+
Brute::Hooks::Trace.new(env, hooks: hooks)
|
|
243
|
+
end
|
|
244
|
+
snapshot_call = ->(args) do
|
|
245
|
+
Brute::Message.new(
|
|
246
|
+
role: :assistant, content: nil,
|
|
247
|
+
tool_calls: [{ id: "tc1", name: "AGUISendStateSnapshot", arguments: args }],
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
delta_call = ->(args) do
|
|
252
|
+
Brute::Message.new(
|
|
253
|
+
role: :assistant, content: nil,
|
|
254
|
+
tool_calls: [{ id: "tc1", name: "AGUISendStateDelta", arguments: args }],
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "advertises both state tools idempotently and seeds env[:state] from input" do
|
|
259
|
+
seen = nil
|
|
260
|
+
mw = AgUi::Middleware::State.new(->(env) { seen = env }, state: { "theme" => "light" })
|
|
261
|
+
env = { messages: Brute.log, tools: [{ "name" => "navigate" }] }
|
|
262
|
+
mw.call(recording(env, []))
|
|
263
|
+
mw.call(recording(env, [])) # second loop iteration — no dupes
|
|
264
|
+
|
|
265
|
+
seen[:tools].map { |t| t["name"] }.should ==
|
|
266
|
+
%w[navigate AGUISendStateSnapshot AGUISendStateDelta]
|
|
267
|
+
seen[:state].should == { "theme" => "light" }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "seeds from a Definition-like state via to_h" do
|
|
271
|
+
definition = Object.new
|
|
272
|
+
def definition.to_h = { "count" => 1 }
|
|
273
|
+
seen = nil
|
|
274
|
+
AgUi::Middleware::State.new(->(env) { seen = env }, state: definition)
|
|
275
|
+
.call(recording({ messages: Brute.log, tools: [] }, []))
|
|
276
|
+
seen[:state].should == { "count" => 1 }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "AGUISendStateSnapshot: sets state, emits STATE_SNAPSHOT + result, continues the run" do
|
|
280
|
+
terminal = ->(env) { env[:messages] << snapshot_call.("snapshot" => { "count" => 3 }) }
|
|
281
|
+
env = { messages: Brute.log, tools: [], should_exit: true }
|
|
282
|
+
events = []
|
|
283
|
+
AgUi::Middleware::State.new(terminal).call(recording(env, events))
|
|
284
|
+
|
|
285
|
+
env[:state].should == { "count" => 3 }
|
|
286
|
+
events.map { |e| e[:type] }.should == %i[state_snapshot tool_call_result]
|
|
287
|
+
events[0][:data][:snapshot].should == { "count" => 3 }
|
|
288
|
+
env[:messages].last.role.should == :tool
|
|
289
|
+
env[:messages].last.tool_call_id.should == "tc1"
|
|
290
|
+
env[:messages].last.content.should == "{\"status\":\"ok\"}"
|
|
291
|
+
env[:should_exit].should == false
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it "AGUISendStateDelta: patches state (Hana), emits STATE_DELTA with the raw ops" do
|
|
295
|
+
delta = [{ "op" => "replace", "path" => "/theme", "value" => "dark" }]
|
|
296
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
297
|
+
env = { messages: Brute.log, tools: [], state: { "theme" => "light" }, should_exit: true }
|
|
298
|
+
events = []
|
|
299
|
+
AgUi::Middleware::State.new(terminal).call(recording(env, events))
|
|
300
|
+
|
|
301
|
+
env[:state].should == { "theme" => "dark" }
|
|
302
|
+
events.map { |e| e[:type] }.should == %i[state_delta tool_call_result]
|
|
303
|
+
events[0][:data][:delta].should == delta
|
|
304
|
+
env[:should_exit].should == false
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it "adds a nested key via delta against seeded state" do
|
|
308
|
+
delta = [{ "op" => "add", "path" => "/documentEditor", "value" => { "activeTabId" => "doc-2" } }]
|
|
309
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
310
|
+
env = { messages: Brute.log, tools: [], should_exit: true }
|
|
311
|
+
events = []
|
|
312
|
+
AgUi::Middleware::State.new(terminal, state: { "documentEditor" => { "activeTabId" => "doc-1" } })
|
|
313
|
+
.call(recording(env, events))
|
|
314
|
+
|
|
315
|
+
env[:state].should == { "documentEditor" => { "activeTabId" => "doc-2" } }
|
|
316
|
+
events[0][:type].should == :state_delta
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "reports a bad patch back to the model as a tool error, no STATE_DELTA emitted" do
|
|
320
|
+
delta = [{ "op" => "replace", "path" => "/missing/deep", "value" => 1 }]
|
|
321
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
322
|
+
env = { messages: Brute.log, tools: [], state: {}, should_exit: true }
|
|
323
|
+
events = []
|
|
324
|
+
AgUi::Middleware::State.new(terminal).call(recording(env, events))
|
|
325
|
+
|
|
326
|
+
events.map { |e| e[:type] }.should == %i[tool_call_result]
|
|
327
|
+
JSON.parse(events[0][:data][:content])["status"].should == "error"
|
|
328
|
+
env[:messages].last.role.should == :tool
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it "rejects a non-array delta and a non-object snapshot as tool errors" do
|
|
332
|
+
[delta_call.("delta" => "nope"), snapshot_call.("snapshot" => "nope")].each do |msg|
|
|
333
|
+
terminal = ->(env) { env[:messages] << msg }
|
|
334
|
+
env = { messages: Brute.log, tools: [] }
|
|
335
|
+
events = []
|
|
336
|
+
AgUi::Middleware::State.new(terminal).call(recording(env, events))
|
|
337
|
+
JSON.parse(events.last[:data][:content])["status"].should == "error"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "leaves should_exit set when a real client tool is mixed into the turn" do
|
|
342
|
+
terminal = ->(env) do
|
|
343
|
+
env[:messages] << Brute::Message.new(
|
|
344
|
+
role: :assistant, content: nil,
|
|
345
|
+
tool_calls: [
|
|
346
|
+
{ id: "tc1", name: "AGUISendStateSnapshot", arguments: { "snapshot" => { "a" => 1 } } },
|
|
347
|
+
{ id: "tc2", name: "navigate", arguments: { "path" => "/x" } },
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
env = { messages: Brute.log, tools: [], should_exit: true }
|
|
352
|
+
events = []
|
|
353
|
+
AgUi::Middleware::State.new(terminal).call(recording(env, events))
|
|
354
|
+
|
|
355
|
+
events.any? { |e| e[:type] == :state_snapshot }.should == true
|
|
356
|
+
env[:should_exit].should == true # browser still needs to run navigate
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it "does nothing on a plain text turn" do
|
|
360
|
+
env = { messages: Brute.log, tools: [] }
|
|
361
|
+
events = []
|
|
362
|
+
AgUi::Middleware::State.new(->(e) { e[:messages].assistant("hi") }).call(recording(env, events))
|
|
363
|
+
events.should == []
|
|
364
|
+
env.key?(:should_exit).should == false
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -11,7 +11,7 @@ module AgUi
|
|
|
11
11
|
#
|
|
12
12
|
# Skips entirely when the history already carries a system message
|
|
13
13
|
# (the client can send its own via system/developer roles).
|
|
14
|
-
class SystemPrompt
|
|
14
|
+
class SystemPrompt < Brute::Middleware::Base
|
|
15
15
|
def initialize(app, prompt: nil, context: nil)
|
|
16
16
|
@app = app
|
|
17
17
|
@prompt = prompt
|
|
@@ -26,7 +26,7 @@ module AgUi
|
|
|
26
26
|
# RUN_FINISHED and the browser executes it (multi-run model)
|
|
27
27
|
# - mixed turns: server tools still execute, but any client call
|
|
28
28
|
# ends the run
|
|
29
|
-
class ToolRouter
|
|
29
|
+
class ToolRouter < Brute::Middleware::Base
|
|
30
30
|
def initialize(app, tools: nil, server_tools: nil)
|
|
31
31
|
@app = app
|
|
32
32
|
@client_tools = tools || []
|
|
@@ -72,9 +72,9 @@ module AgUi
|
|
|
72
72
|
|
|
73
73
|
def server_definition(tool)
|
|
74
74
|
{
|
|
75
|
-
"name"
|
|
75
|
+
"name" => tool[:name].to_s,
|
|
76
76
|
"description" => tool[:description].to_s,
|
|
77
|
-
"parameters"
|
|
77
|
+
"parameters" => tool[:parameters] || { "type" => "object" },
|
|
78
78
|
}
|
|
79
79
|
end
|
|
80
80
|
|
|
@@ -82,7 +82,7 @@ module AgUi
|
|
|
82
82
|
client_called = false
|
|
83
83
|
|
|
84
84
|
tool_calls.each do |tool_call|
|
|
85
|
-
emit_call(env
|
|
85
|
+
emit_call(env, tool_call)
|
|
86
86
|
|
|
87
87
|
server = @server_tools[tool_call.name]
|
|
88
88
|
if server
|
|
@@ -97,19 +97,16 @@ module AgUi
|
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
def emit_call(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
type: :tool_call_end,
|
|
111
|
-
data: { tool_call_id: tool_call.id },
|
|
112
|
-
}
|
|
100
|
+
def emit_call(env, tool_call)
|
|
101
|
+
env.emit(
|
|
102
|
+
:tool_call_start,
|
|
103
|
+
{ tool_call_id: tool_call.id, tool_call_name: tool_call.name },
|
|
104
|
+
)
|
|
105
|
+
env.emit(
|
|
106
|
+
:tool_call_args,
|
|
107
|
+
{ tool_call_id: tool_call.id, delta: JSON.generate(tool_call.arguments) },
|
|
108
|
+
)
|
|
109
|
+
env.emit(:tool_call_end, { tool_call_id: tool_call.id })
|
|
113
110
|
end
|
|
114
111
|
|
|
115
112
|
def execute_server_tool(env, tool_call, tool)
|
|
@@ -120,16 +117,20 @@ module AgUi
|
|
|
120
117
|
result = { "error" => e.message }
|
|
121
118
|
end
|
|
122
119
|
|
|
123
|
-
|
|
120
|
+
if result.is_a?(String)
|
|
121
|
+
content = result
|
|
122
|
+
else
|
|
123
|
+
content = JSON.generate(result)
|
|
124
|
+
end
|
|
124
125
|
env[:messages].tool(content, tool_call_id: tool_call.id)
|
|
125
|
-
env
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
message_id:
|
|
126
|
+
env.emit(
|
|
127
|
+
:tool_call_result,
|
|
128
|
+
{
|
|
129
|
+
message_id: SecureRandom.uuid,
|
|
129
130
|
tool_call_id: tool_call.id,
|
|
130
|
-
content:
|
|
131
|
+
content: content,
|
|
131
132
|
},
|
|
132
|
-
|
|
133
|
+
)
|
|
133
134
|
end
|
|
134
135
|
end
|
|
135
136
|
end
|
|
@@ -138,6 +139,20 @@ end
|
|
|
138
139
|
__END__
|
|
139
140
|
|
|
140
141
|
describe "AgUi::Middleware::ToolRouter" do
|
|
142
|
+
# Brute 6 replaced the env[:events] sink with the hooks registry, so a test
|
|
143
|
+
# that wants to see what a middleware emitted subscribes instead of reading
|
|
144
|
+
# an array off the env. This records the AG-UI tool vocabulary in emission
|
|
145
|
+
# order, which keeps the assertions below the shape they always had.
|
|
146
|
+
TOOL_EVENTS = %i[tool_call_start tool_call_args tool_call_end tool_call_result].freeze
|
|
147
|
+
|
|
148
|
+
def recording(env, into)
|
|
149
|
+
hooks = Brute::Hooks::Registry.new
|
|
150
|
+
TOOL_EVENTS.each do |event|
|
|
151
|
+
hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
|
|
152
|
+
end
|
|
153
|
+
Brute::Hooks::Trace.new(env, hooks: hooks)
|
|
154
|
+
end
|
|
155
|
+
|
|
141
156
|
it "advertises client and server tools idempotently across iterations" do
|
|
142
157
|
seen = nil
|
|
143
158
|
server_tool = { name: "get_time", description: "Now", handler: -> (_args) { "12:00" } }
|
|
@@ -147,9 +162,9 @@ describe "AgUi::Middleware::ToolRouter" do
|
|
|
147
162
|
server_tools: [server_tool],
|
|
148
163
|
)
|
|
149
164
|
|
|
150
|
-
env = { messages: Brute.log
|
|
151
|
-
mw.call(env)
|
|
152
|
-
mw.call(env) # second loop iteration
|
|
165
|
+
env = { messages: Brute.log }
|
|
166
|
+
mw.call(recording(env, []))
|
|
167
|
+
mw.call(recording(env, [])) # second loop iteration
|
|
153
168
|
|
|
154
169
|
seen.map { |t| t["name"] }.should == %w[navigate get_time]
|
|
155
170
|
seen.last["parameters"].should == { "type" => "object" }
|
|
@@ -169,17 +184,18 @@ describe "AgUi::Middleware::ToolRouter" do
|
|
|
169
184
|
)
|
|
170
185
|
end
|
|
171
186
|
|
|
172
|
-
env = { messages: Brute.log
|
|
173
|
-
|
|
187
|
+
env = { messages: Brute.log }
|
|
188
|
+
events = []
|
|
189
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, events))
|
|
174
190
|
|
|
175
191
|
env[:messages].last.role.should == :tool
|
|
176
192
|
env[:messages].last.tool_call_id.should == "tc1"
|
|
177
193
|
env[:messages].last.content.should == "{\"found\":42}"
|
|
178
194
|
|
|
179
|
-
|
|
195
|
+
events.map { |e| e[:type] }.should == %i[
|
|
180
196
|
tool_call_start tool_call_args tool_call_end tool_call_result
|
|
181
197
|
]
|
|
182
|
-
|
|
198
|
+
events.last[:data][:content].should == "{\"found\":42}"
|
|
183
199
|
env.key?(:should_exit).should == false
|
|
184
200
|
end
|
|
185
201
|
|
|
@@ -192,8 +208,8 @@ describe "AgUi::Middleware::ToolRouter" do
|
|
|
192
208
|
)
|
|
193
209
|
end
|
|
194
210
|
|
|
195
|
-
env = { messages: Brute.log
|
|
196
|
-
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
|
|
211
|
+
env = { messages: Brute.log }
|
|
212
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, []))
|
|
197
213
|
|
|
198
214
|
env[:messages].last.content.should == "{\"error\":\"kaput\"}"
|
|
199
215
|
env.key?(:should_exit).should == false
|
|
@@ -211,11 +227,12 @@ describe "AgUi::Middleware::ToolRouter" do
|
|
|
211
227
|
)
|
|
212
228
|
end
|
|
213
229
|
|
|
214
|
-
env = { messages: Brute.log
|
|
215
|
-
|
|
230
|
+
env = { messages: Brute.log }
|
|
231
|
+
events = []
|
|
232
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, events))
|
|
216
233
|
|
|
217
234
|
env[:should_exit].should == true
|
|
218
|
-
|
|
235
|
+
events.count { |e| e[:type] == :tool_call_result }.should == 1
|
|
219
236
|
end
|
|
220
237
|
|
|
221
238
|
it "emits TOOL_CALL events and exits when the turn ends on client tool calls" do
|
|
@@ -229,23 +246,25 @@ describe "AgUi::Middleware::ToolRouter" do
|
|
|
229
246
|
)
|
|
230
247
|
end
|
|
231
248
|
|
|
232
|
-
env = { messages: Brute.log
|
|
233
|
-
|
|
249
|
+
env = { messages: Brute.log }
|
|
250
|
+
events = []
|
|
251
|
+
AgUi::Middleware::ToolRouter.new(terminal).call(recording(env, events))
|
|
234
252
|
|
|
235
|
-
|
|
253
|
+
events.map { |e| e[:type] }.should == %i[
|
|
236
254
|
tool_call_start tool_call_args tool_call_end
|
|
237
255
|
tool_call_start tool_call_args tool_call_end
|
|
238
256
|
]
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
events[0][:data].should == { tool_call_id: "tc1", tool_call_name: "navigate" }
|
|
258
|
+
events[1][:data][:delta].should == "{\"path\":\"/data\"}"
|
|
241
259
|
env[:should_exit].should == true
|
|
242
260
|
end
|
|
243
261
|
|
|
244
262
|
it "does nothing on the way out for plain text turns" do
|
|
245
|
-
env = { messages: Brute.log
|
|
246
|
-
|
|
263
|
+
env = { messages: Brute.log }
|
|
264
|
+
events = []
|
|
265
|
+
AgUi::Middleware::ToolRouter.new(->(e) { e[:messages].assistant("hi") }).call(recording(env, events))
|
|
247
266
|
|
|
248
|
-
|
|
267
|
+
events.should == []
|
|
249
268
|
env.key?(:should_exit).should == false
|
|
250
269
|
end
|
|
251
270
|
end
|
|
@@ -33,12 +33,12 @@ module AgUi
|
|
|
33
33
|
camel = snake[k] || k
|
|
34
34
|
|
|
35
35
|
if props.include?(camel)
|
|
36
|
-
|
|
37
|
-
value.to_h
|
|
36
|
+
if value.is_a?(Definition)
|
|
37
|
+
@data[camel] = value.to_h
|
|
38
38
|
elsif (ref_info = refs[camel])
|
|
39
|
-
wrap_ref(value, ref_info)
|
|
39
|
+
@data[camel] = wrap_ref(value, ref_info)
|
|
40
40
|
else
|
|
41
|
-
value
|
|
41
|
+
@data[camel] = value
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
end
|
|
@@ -91,7 +91,7 @@ module AgUi
|
|
|
91
91
|
raise ValidationError.new(
|
|
92
92
|
errors,
|
|
93
93
|
definition_name: self.class.definition_name,
|
|
94
|
-
data:
|
|
94
|
+
data: to_h,
|
|
95
95
|
)
|
|
96
96
|
end
|
|
97
97
|
end
|
|
@@ -129,14 +129,18 @@ module AgUi
|
|
|
129
129
|
properties.each do |camel_key, prop_schema|
|
|
130
130
|
schema = unwrap_optional(prop_schema)
|
|
131
131
|
|
|
132
|
-
kind
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
kind = nil
|
|
133
|
+
ref = nil
|
|
134
|
+
if (r = schema["$ref"])
|
|
135
|
+
kind = :object
|
|
136
|
+
ref = r
|
|
137
|
+
elsif schema["type"] == "array" && (r = schema.dig("items", "$ref"))
|
|
138
|
+
kind = :array
|
|
139
|
+
ref = r
|
|
140
|
+
elsif schema["type"] == "object" && (r = schema.dig("additionalProperties", "$ref"))
|
|
141
|
+
kind = :map
|
|
142
|
+
ref = r
|
|
143
|
+
end
|
|
140
144
|
|
|
141
145
|
if ref
|
|
142
146
|
name = ref_name_for(ref)
|
|
@@ -168,13 +172,13 @@ module AgUi
|
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
def build_snake_to_camel(camel_keys)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
{}.tap do |map|
|
|
176
|
+
camel_keys.each do |camel|
|
|
177
|
+
snake = camel_to_snake(camel)
|
|
178
|
+
map[snake] = camel
|
|
179
|
+
map[camel] = camel
|
|
180
|
+
end
|
|
176
181
|
end
|
|
177
|
-
map
|
|
178
182
|
end
|
|
179
183
|
|
|
180
184
|
def camel_to_snake(str)
|