ag-ui 0.3.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 +89 -62
- 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 +77 -52
- 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
- metadata +30 -9
- data/data/generate-ag-ui-schema.py +0 -50
data/lib/ag_ui/event_bridge.rb
CHANGED
|
@@ -6,49 +6,58 @@ require "ag_ui"
|
|
|
6
6
|
module AgUi
|
|
7
7
|
# The seam between brute's turn pipeline and the AG-UI SSE stream.
|
|
8
8
|
#
|
|
9
|
-
# Brute middleware and terminal procs
|
|
10
|
-
#
|
|
11
|
-
# the matching typed SSE emitter as it arrives — the browser
|
|
12
|
-
# mid-turn, not after.
|
|
9
|
+
# Brute middleware and terminal procs emit AG-UI events through the env; a
|
|
10
|
+
# bridge subscribes to that vocabulary on the agent's hooks and translates
|
|
11
|
+
# each one into the matching typed SSE emitter as it arrives — the browser
|
|
12
|
+
# sees deltas mid-turn, not after.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
14
|
+
# agent = Brute.agent.use(...).run(terminal)
|
|
15
|
+
# AgUi::EventBridge.new(stream).subscribe(agent)
|
|
16
|
+
# agent.start(messages)
|
|
15
17
|
#
|
|
16
|
-
#
|
|
17
|
-
# are
|
|
18
|
+
# Brute's own lifecycle events (:llm_start, :tool_end, the middleware and
|
|
19
|
+
# trace sets) are simply never subscribed to here, so only the AG-UI
|
|
20
|
+
# vocabulary reaches the wire.
|
|
18
21
|
class EventBridge
|
|
19
22
|
TRANSLATIONS = {
|
|
20
|
-
text_message_start:
|
|
21
|
-
text_message_content:
|
|
22
|
-
text_message_end:
|
|
23
|
-
tool_call_start:
|
|
24
|
-
tool_call_args:
|
|
25
|
-
tool_call_end:
|
|
26
|
-
tool_call_result:
|
|
27
|
-
state_snapshot:
|
|
28
|
-
state_delta:
|
|
29
|
-
messages_snapshot:
|
|
30
|
-
activity_snapshot:
|
|
31
|
-
reasoning_start:
|
|
32
|
-
reasoning_message_start:
|
|
23
|
+
text_message_start: :translate_text_start,
|
|
24
|
+
text_message_content: :translate_text_content,
|
|
25
|
+
text_message_end: :translate_text_end,
|
|
26
|
+
tool_call_start: :translate_tool_call_start,
|
|
27
|
+
tool_call_args: :translate_tool_call_args,
|
|
28
|
+
tool_call_end: :translate_tool_call_end,
|
|
29
|
+
tool_call_result: :translate_tool_call_result,
|
|
30
|
+
state_snapshot: :translate_state_snapshot,
|
|
31
|
+
state_delta: :translate_state_delta,
|
|
32
|
+
messages_snapshot: :translate_messages_snapshot,
|
|
33
|
+
activity_snapshot: :translate_activity_snapshot,
|
|
34
|
+
reasoning_start: :translate_reasoning_start,
|
|
35
|
+
reasoning_message_start: :translate_reasoning_message_start,
|
|
33
36
|
reasoning_message_content: :translate_reasoning_message_content,
|
|
34
|
-
reasoning_message_end:
|
|
35
|
-
reasoning_end:
|
|
36
|
-
step_started:
|
|
37
|
-
step_finished:
|
|
38
|
-
custom:
|
|
39
|
-
raw:
|
|
37
|
+
reasoning_message_end: :translate_reasoning_message_end,
|
|
38
|
+
reasoning_end: :translate_reasoning_end,
|
|
39
|
+
step_started: :translate_step_started,
|
|
40
|
+
step_finished: :translate_step_finished,
|
|
41
|
+
custom: :translate_custom,
|
|
42
|
+
raw: :translate_raw,
|
|
40
43
|
}.freeze
|
|
41
44
|
|
|
42
45
|
def initialize(stream)
|
|
43
46
|
@stream = stream
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
# Registers the whole vocabulary above on a hooks registry — or on
|
|
50
|
+
# anything else answering `on`, which a Brute pipeline builder does.
|
|
51
|
+
# Answers what it was given, so it composes in one line.
|
|
52
|
+
#
|
|
53
|
+
# Every subscriber takes (env, data, trace): brute hands the env first and
|
|
54
|
+
# appends the trace last, and a translation wants neither — the data is
|
|
55
|
+
# the whole of an AG-UI event.
|
|
56
|
+
def subscribe(hooks)
|
|
57
|
+
TRANSLATIONS.each do |event, handler|
|
|
58
|
+
hooks.on(event) { |_env, data, _trace| send(handler, data || {}) }
|
|
50
59
|
end
|
|
51
|
-
|
|
60
|
+
hooks
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
private
|
|
@@ -71,8 +80,8 @@ module AgUi
|
|
|
71
80
|
|
|
72
81
|
def translate_tool_call_start(data)
|
|
73
82
|
@stream.tool_call_start(
|
|
74
|
-
tool_call_id:
|
|
75
|
-
tool_call_name:
|
|
83
|
+
tool_call_id: data[:tool_call_id],
|
|
84
|
+
tool_call_name: data[:tool_call_name],
|
|
76
85
|
parent_message_id: data[:parent_message_id],
|
|
77
86
|
)
|
|
78
87
|
end
|
|
@@ -89,9 +98,9 @@ module AgUi
|
|
|
89
98
|
|
|
90
99
|
def translate_tool_call_result(data)
|
|
91
100
|
@stream.tool_call_result(
|
|
92
|
-
message_id:
|
|
101
|
+
message_id: data[:message_id],
|
|
93
102
|
tool_call_id: data[:tool_call_id],
|
|
94
|
-
content:
|
|
103
|
+
content: data[:content],
|
|
95
104
|
)
|
|
96
105
|
end
|
|
97
106
|
|
|
@@ -131,10 +140,10 @@ module AgUi
|
|
|
131
140
|
|
|
132
141
|
def translate_activity_snapshot(data)
|
|
133
142
|
@stream.activity_snapshot(
|
|
134
|
-
message_id:
|
|
143
|
+
message_id: data[:message_id],
|
|
135
144
|
activity_type: data[:activity_type],
|
|
136
|
-
content:
|
|
137
|
-
replace:
|
|
145
|
+
content: data[:content],
|
|
146
|
+
replace: data.fetch(:replace, true),
|
|
138
147
|
)
|
|
139
148
|
end
|
|
140
149
|
|
|
@@ -165,6 +174,15 @@ end
|
|
|
165
174
|
__END__
|
|
166
175
|
|
|
167
176
|
describe "AgUi::EventBridge" do
|
|
177
|
+
# A bridge is driven by emitting on a registry it has subscribed to, so the
|
|
178
|
+
# tests below build the pair and push events through the same path a turn
|
|
179
|
+
# would.
|
|
180
|
+
def bridged(stream)
|
|
181
|
+
hooks = Brute::Hooks::Registry.new
|
|
182
|
+
AgUi::EventBridge.new(stream).subscribe(hooks)
|
|
183
|
+
Brute::Hooks::Trace.new({}, hooks: hooks)
|
|
184
|
+
end
|
|
185
|
+
|
|
168
186
|
read_frames = ->(stream) do
|
|
169
187
|
frames = []
|
|
170
188
|
while (chunk = stream.read)
|
|
@@ -175,12 +193,12 @@ describe "AgUi::EventBridge" do
|
|
|
175
193
|
|
|
176
194
|
it "translates text events into SSE frames as they arrive" do
|
|
177
195
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
178
|
-
|
|
196
|
+
env = bridged(stream)
|
|
179
197
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
198
|
+
env.emit(:text_message_start, { message_id: "m1" })
|
|
199
|
+
env.emit(:text_message_content, { message_id: "m1", delta: "Hel" })
|
|
200
|
+
env.emit(:text_message_content, { message_id: "m1", delta: "lo" })
|
|
201
|
+
env.emit(:text_message_end, { message_id: "m1" })
|
|
184
202
|
stream.finish
|
|
185
203
|
|
|
186
204
|
frames = read_frames.(stream)
|
|
@@ -192,34 +210,42 @@ describe "AgUi::EventBridge" do
|
|
|
192
210
|
|
|
193
211
|
it "drops empty deltas (protocol rule)" do
|
|
194
212
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
195
|
-
|
|
213
|
+
env = bridged(stream)
|
|
196
214
|
|
|
197
|
-
|
|
198
|
-
|
|
215
|
+
env.emit(:text_message_content, { message_id: "m1", delta: "" })
|
|
216
|
+
env.emit(:text_message_content, { message_id: "m1", delta: nil })
|
|
199
217
|
stream.finish
|
|
200
218
|
|
|
201
219
|
read_frames.(stream).should == []
|
|
202
220
|
end
|
|
203
221
|
|
|
204
|
-
it "
|
|
222
|
+
it "leaves brute's own lifecycle events and unknown types off the wire" do
|
|
205
223
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
206
|
-
|
|
224
|
+
env = bridged(stream)
|
|
207
225
|
|
|
208
|
-
|
|
209
|
-
|
|
226
|
+
env.emit(Brute::Hooks::LLM_START_EVENT)
|
|
227
|
+
env.emit(:whatever, { note: "internal" })
|
|
210
228
|
stream.finish
|
|
211
229
|
|
|
212
|
-
result.should.equal?(bridge)
|
|
213
230
|
read_frames.(stream).should == []
|
|
214
231
|
end
|
|
215
232
|
|
|
233
|
+
it "answers what it subscribed, so it composes in one line" do
|
|
234
|
+
hooks = Brute::Hooks::Registry.new
|
|
235
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
236
|
+
|
|
237
|
+
AgUi::EventBridge.new(stream).subscribe(hooks).should.equal?(hooks)
|
|
238
|
+
end
|
|
239
|
+
|
|
216
240
|
it "translates shared-state events (snapshot + JSON-Patch delta)" do
|
|
217
241
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
218
|
-
|
|
242
|
+
env = bridged(stream)
|
|
219
243
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
244
|
+
env.emit(:state_snapshot, { snapshot: { "theme" => "dark" } })
|
|
245
|
+
env.emit(
|
|
246
|
+
:state_delta,
|
|
247
|
+
{ delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] },
|
|
248
|
+
)
|
|
223
249
|
stream.finish
|
|
224
250
|
|
|
225
251
|
frames = read_frames.(stream)
|
|
@@ -230,13 +256,14 @@ describe "AgUi::EventBridge" do
|
|
|
230
256
|
|
|
231
257
|
it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
|
|
232
258
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
259
|
+
env = bridged(stream)
|
|
260
|
+
|
|
261
|
+
env.emit(:step_started, { step_name: "plan" })
|
|
262
|
+
env.emit(
|
|
263
|
+
:custom,
|
|
264
|
+
{ name: "PredictState", value: [{ "state_key" => "document", "tool" => "write" }] },
|
|
265
|
+
)
|
|
266
|
+
env.emit(:step_finished, { step_name: "plan" })
|
|
240
267
|
stream.finish
|
|
241
268
|
|
|
242
269
|
frames = read_frames.(stream)
|
data/lib/ag_ui/messages.rb
CHANGED
|
@@ -12,26 +12,24 @@ module AgUi
|
|
|
12
12
|
module Messages
|
|
13
13
|
class << self
|
|
14
14
|
def to_brute(wire_messages)
|
|
15
|
-
log
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
15
|
+
Brute.log.tap do |log|
|
|
16
|
+
wire_messages.each do |message|
|
|
17
|
+
case message["role"]
|
|
18
|
+
in "user"
|
|
19
|
+
log.user(text_content(message["content"]))
|
|
20
|
+
in "assistant"
|
|
21
|
+
log << assistant_message(message)
|
|
22
|
+
in "tool"
|
|
23
|
+
log.tool(message["content"].to_s, tool_call_id: message["toolCallId"])
|
|
24
|
+
in "system" | "developer"
|
|
25
|
+
log.system(message["content"].to_s)
|
|
26
|
+
in "activity" | "reasoning"
|
|
27
|
+
# Not part of the LLM conversation: activities render client-side
|
|
28
|
+
# (phase 4) and reasoning is provider-managed (phase 5).
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
|
-
|
|
34
|
-
log
|
|
35
33
|
end
|
|
36
34
|
|
|
37
35
|
private
|
|
@@ -39,15 +37,15 @@ module AgUi
|
|
|
39
37
|
def assistant_message(message)
|
|
40
38
|
tool_calls = message["toolCalls"]&.map do |tc|
|
|
41
39
|
Brute::ToolCall.new(
|
|
42
|
-
id:
|
|
43
|
-
name:
|
|
40
|
+
id: tc["id"],
|
|
41
|
+
name: tc.dig("function", "name"),
|
|
44
42
|
arguments: parse_arguments(tc.dig("function", "arguments")),
|
|
45
43
|
)
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
Brute::Message.new(
|
|
49
|
-
role:
|
|
50
|
-
content:
|
|
47
|
+
role: :assistant,
|
|
48
|
+
content: message["content"],
|
|
51
49
|
tool_calls: tool_calls,
|
|
52
50
|
)
|
|
53
51
|
end
|
|
@@ -29,37 +29,37 @@ module AgUi
|
|
|
29
29
|
#
|
|
30
30
|
# Sits OUTSIDE ToolRouter in the pipeline:
|
|
31
31
|
# use SystemPrompt; use A2ui, catalog: catalog; use ToolRouter
|
|
32
|
-
class A2ui
|
|
32
|
+
class A2ui < Brute::Middleware::Base
|
|
33
33
|
TOOL_NAME = "render_a2ui"
|
|
34
34
|
|
|
35
35
|
TOOL_DEFINITION = {
|
|
36
|
-
"name"
|
|
36
|
+
"name" => TOOL_NAME,
|
|
37
37
|
"description" =>
|
|
38
|
-
|
|
38
|
+
"Render a dynamic A2UI v0.9 surface with structured parameters. " \
|
|
39
39
|
"Follow the A2UI render tool usage guide provided in context.",
|
|
40
|
-
"parameters"
|
|
41
|
-
"type"
|
|
40
|
+
"parameters" => {
|
|
41
|
+
"type" => "object",
|
|
42
42
|
"properties" => {
|
|
43
|
-
"surfaceId"
|
|
44
|
-
"type"
|
|
43
|
+
"surfaceId" => {
|
|
44
|
+
"type" => "string",
|
|
45
45
|
"description" => "Unique surface identifier.",
|
|
46
46
|
},
|
|
47
47
|
"components" => {
|
|
48
|
-
"type"
|
|
48
|
+
"type" => "array",
|
|
49
49
|
"description" =>
|
|
50
|
-
|
|
50
|
+
"A2UI v0.9 component array (flat format). The root component " \
|
|
51
51
|
"must have id \"root\".",
|
|
52
|
-
"items"
|
|
52
|
+
"items" => { "type" => "object" },
|
|
53
53
|
},
|
|
54
|
-
"data"
|
|
55
|
-
"type"
|
|
54
|
+
"data" => {
|
|
55
|
+
"type" => "object",
|
|
56
56
|
"description" =>
|
|
57
|
-
|
|
57
|
+
"Initial data model for the surface. Written to the root path. " \
|
|
58
58
|
"Use for pre-filling form values (e.g. {\"form\": {\"name\": \"Alice\"}}) " \
|
|
59
59
|
"or providing data for components bound to data model paths.",
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
|
-
"required"
|
|
62
|
+
"required" => %w[surfaceId components],
|
|
63
63
|
},
|
|
64
64
|
}.freeze
|
|
65
65
|
|
|
@@ -123,7 +123,7 @@ module AgUi
|
|
|
123
123
|
if catalog_components? && !metadata(env)[:a2ui_schema_injected]
|
|
124
124
|
env[:messages].unshift(
|
|
125
125
|
Brute::Message.new(
|
|
126
|
-
role:
|
|
126
|
+
role: :system,
|
|
127
127
|
content: "#{SCHEMA_CONTEXT_PREAMBLE}\n#{JSON.generate(@catalog.components)}",
|
|
128
128
|
),
|
|
129
129
|
)
|
|
@@ -151,9 +151,9 @@ module AgUi
|
|
|
151
151
|
surface_id = args["surfaceId"].to_s
|
|
152
152
|
|
|
153
153
|
validation = ::AgUi::A2ui.validate_components(
|
|
154
|
-
components:
|
|
155
|
-
data:
|
|
156
|
-
catalog:
|
|
154
|
+
components: args["components"],
|
|
155
|
+
data: args["data"].is_a?(Hash) ? args["data"] : {},
|
|
156
|
+
catalog: catalog_components? ? { "components" => @catalog.components } : nil,
|
|
157
157
|
validate_bindings: false,
|
|
158
158
|
)
|
|
159
159
|
|
|
@@ -161,8 +161,8 @@ module AgUi
|
|
|
161
161
|
render_failure(env, tool_call, validation["errors"])
|
|
162
162
|
else
|
|
163
163
|
ops = operations(args, surface_id, emitted_surfaces(env))
|
|
164
|
-
env
|
|
165
|
-
env
|
|
164
|
+
emit_activity(env, tool_call, { "a2ui_operations" => ops })
|
|
165
|
+
emit_result(env, tool_call, { "status" => "rendered" })
|
|
166
166
|
end
|
|
167
167
|
end
|
|
168
168
|
|
|
@@ -171,18 +171,19 @@ module AgUi
|
|
|
171
171
|
metadata(env)[:a2ui_attempts] = attempt
|
|
172
172
|
final = attempt >= MAX_ATTEMPTS
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
emit_activity(
|
|
175
|
+
env,
|
|
175
176
|
tool_call,
|
|
176
177
|
{
|
|
177
|
-
"status"
|
|
178
|
-
"attempt"
|
|
178
|
+
"status" => final ? "failed" : "retrying",
|
|
179
|
+
"attempt" => attempt,
|
|
179
180
|
"maxAttempts" => MAX_ATTEMPTS,
|
|
180
|
-
"errors"
|
|
181
|
+
"errors" => errors,
|
|
181
182
|
},
|
|
182
183
|
)
|
|
183
184
|
|
|
184
185
|
content = { "status" => "failed", "errors" => errors }
|
|
185
|
-
env
|
|
186
|
+
emit_result(env, tool_call, content)
|
|
186
187
|
env[:messages].tool(JSON.generate(content), tool_call_id: tool_call.id)
|
|
187
188
|
|
|
188
189
|
unless final
|
|
@@ -197,55 +198,55 @@ module AgUi
|
|
|
197
198
|
# createSurface once per surface within the turn; updateComponents
|
|
198
199
|
# always; updateDataModel when initial data was given.
|
|
199
200
|
def operations(args, surface_id, emitted_surfaces)
|
|
200
|
-
|
|
201
|
+
[].tap do |ops|
|
|
202
|
+
if emitted_surfaces.add?(surface_id)
|
|
203
|
+
ops << {
|
|
204
|
+
"version" => "v0.9",
|
|
205
|
+
"createSurface" => { "surfaceId" => surface_id, "catalogId" => catalog_id },
|
|
206
|
+
}
|
|
207
|
+
end
|
|
201
208
|
|
|
202
|
-
if emitted_surfaces.add?(surface_id)
|
|
203
209
|
ops << {
|
|
204
|
-
"version"
|
|
205
|
-
"
|
|
210
|
+
"version" => "v0.9",
|
|
211
|
+
"updateComponents" => { "surfaceId" => surface_id, "components" => args["components"] },
|
|
206
212
|
}
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
ops << {
|
|
210
|
-
"version" => "v0.9",
|
|
211
|
-
"updateComponents" => { "surfaceId" => surface_id, "components" => args["components"] },
|
|
212
|
-
}
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
214
|
+
if args["data"].is_a?(Hash) && !args["data"].empty?
|
|
215
|
+
ops << {
|
|
216
|
+
"version" => "v0.9",
|
|
217
|
+
"updateDataModel" => { "surfaceId" => surface_id, "path" => "/", "value" => args["data"] },
|
|
218
|
+
}
|
|
219
|
+
end
|
|
219
220
|
end
|
|
220
|
-
|
|
221
|
-
ops
|
|
222
221
|
end
|
|
223
222
|
|
|
224
223
|
def catalog_id
|
|
225
224
|
@default_catalog_id || BASIC_CATALOG_ID
|
|
226
225
|
end
|
|
227
226
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
227
|
+
# One activity per tool call id, replacing rather than appending, so a
|
|
228
|
+
# surface re-rendered mid-turn updates in place on the client.
|
|
229
|
+
def emit_activity(env, tool_call, content)
|
|
230
|
+
env.emit(
|
|
231
|
+
:activity_snapshot,
|
|
232
|
+
{
|
|
233
|
+
message_id: "a2ui-surface-#{tool_call.id}",
|
|
233
234
|
activity_type: "a2ui-surface",
|
|
234
|
-
content:
|
|
235
|
-
replace:
|
|
235
|
+
content: content,
|
|
236
|
+
replace: true,
|
|
236
237
|
},
|
|
237
|
-
|
|
238
|
+
)
|
|
238
239
|
end
|
|
239
240
|
|
|
240
|
-
def
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
message_id:
|
|
241
|
+
def emit_result(env, tool_call, content)
|
|
242
|
+
env.emit(
|
|
243
|
+
:tool_call_result,
|
|
244
|
+
{
|
|
245
|
+
message_id: SecureRandom.uuid,
|
|
245
246
|
tool_call_id: tool_call.id,
|
|
246
|
-
content:
|
|
247
|
+
content: JSON.generate(content),
|
|
247
248
|
},
|
|
248
|
-
|
|
249
|
+
)
|
|
249
250
|
end
|
|
250
251
|
end
|
|
251
252
|
end
|
|
@@ -254,6 +255,20 @@ end
|
|
|
254
255
|
__END__
|
|
255
256
|
|
|
256
257
|
describe "AgUi::Middleware::A2ui" do
|
|
258
|
+
|
|
259
|
+
# Brute 6 replaced the events sink with the hooks registry, so a test
|
|
260
|
+
# that wants to see what a middleware emitted subscribes instead of reading
|
|
261
|
+
# an array off the env. This records the vocabulary A2ui speaks, in emission
|
|
262
|
+
# order, and accumulates across calls the way the old array did.
|
|
263
|
+
A2UI_EVENTS = %i[activity_snapshot tool_call_result].freeze
|
|
264
|
+
|
|
265
|
+
def recording(env, into)
|
|
266
|
+
hooks = Brute::Hooks::Registry.new
|
|
267
|
+
A2UI_EVENTS.each do |event|
|
|
268
|
+
hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
|
|
269
|
+
end
|
|
270
|
+
Brute::Hooks::Trace.new(env, hooks: hooks)
|
|
271
|
+
end
|
|
257
272
|
catalog = AgUi::A2ui::Catalog.new(
|
|
258
273
|
catalog_id: "host://ai-catalog",
|
|
259
274
|
components: { "Card" => { "description" => "A card", "props" => {} } },
|
|
@@ -292,12 +307,14 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
292
307
|
)
|
|
293
308
|
end
|
|
294
309
|
|
|
295
|
-
env = { messages: Brute.log,
|
|
296
|
-
|
|
310
|
+
env = { messages: Brute.log, tools: [] }
|
|
311
|
+
|
|
312
|
+
events = []
|
|
313
|
+
AgUi::Middleware::A2ui.new(terminal, catalog: id_only).call(recording(env, events))
|
|
297
314
|
|
|
298
315
|
seen[:messages].first.role.should == :assistant # no schema system message
|
|
299
|
-
|
|
300
|
-
|
|
316
|
+
events[0][:data][:content].key?("a2ui_operations").should == true
|
|
317
|
+
events[0][:data][:content]["a2ui_operations"][0]["createSurface"]["catalogId"]
|
|
301
318
|
.should == "app://cat"
|
|
302
319
|
end
|
|
303
320
|
|
|
@@ -319,10 +336,12 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
319
336
|
)
|
|
320
337
|
end
|
|
321
338
|
|
|
322
|
-
env = { messages: Brute.log,
|
|
323
|
-
AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
|
|
339
|
+
env = { messages: Brute.log, tools: [] }
|
|
324
340
|
|
|
325
|
-
|
|
341
|
+
events = []
|
|
342
|
+
AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
|
|
343
|
+
|
|
344
|
+
activity = events[0]
|
|
326
345
|
activity[:type].should == :activity_snapshot
|
|
327
346
|
activity[:data][:message_id].should == "a2ui-surface-tc1"
|
|
328
347
|
activity[:data][:activity_type].should == "a2ui-surface"
|
|
@@ -333,7 +352,7 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
333
352
|
ops[1]["updateComponents"]["components"].first["id"].should == "root"
|
|
334
353
|
ops[2]["updateDataModel"].should == { "surfaceId" => "s1", "path" => "/", "value" => { "title" => "hi" } }
|
|
335
354
|
|
|
336
|
-
result =
|
|
355
|
+
result = events[1]
|
|
337
356
|
result[:type].should == :tool_call_result
|
|
338
357
|
result[:data][:tool_call_id].should == "tc1"
|
|
339
358
|
result[:data][:content].should == "{\"status\":\"rendered\"}"
|
|
@@ -346,10 +365,12 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
346
365
|
env[:messages] << render_call.("surfaceId" => "s1", "components" => minimal_components)
|
|
347
366
|
end
|
|
348
367
|
|
|
349
|
-
env = { messages: Brute.log,
|
|
350
|
-
|
|
368
|
+
env = { messages: Brute.log, tools: [] }
|
|
369
|
+
|
|
370
|
+
events = []
|
|
371
|
+
AgUi::Middleware::A2ui.new(terminal).call(recording(env, events))
|
|
351
372
|
|
|
352
|
-
ops =
|
|
373
|
+
ops = events[0][:data][:content]["a2ui_operations"]
|
|
353
374
|
ops[0]["createSurface"]["catalogId"].should ==
|
|
354
375
|
"https://a2ui.org/specification/v0_9/basic_catalog.json"
|
|
355
376
|
end
|
|
@@ -367,10 +388,12 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
367
388
|
)
|
|
368
389
|
end
|
|
369
390
|
|
|
370
|
-
env = { messages: Brute.log,
|
|
371
|
-
|
|
391
|
+
env = { messages: Brute.log, tools: [] }
|
|
392
|
+
|
|
393
|
+
events = []
|
|
394
|
+
AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
|
|
372
395
|
|
|
373
|
-
activities =
|
|
396
|
+
activities = events.select { |e| e[:type] == :activity_snapshot }
|
|
374
397
|
first_ops = activities[0][:data][:content]["a2ui_operations"]
|
|
375
398
|
second_ops = activities[1][:data][:content]["a2ui_operations"]
|
|
376
399
|
|
|
@@ -389,12 +412,14 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
389
412
|
)
|
|
390
413
|
end
|
|
391
414
|
|
|
392
|
-
env = { messages: Brute.log,
|
|
393
|
-
AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
|
|
415
|
+
env = { messages: Brute.log, tools: [], should_exit: true }
|
|
394
416
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
417
|
+
events = []
|
|
418
|
+
AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
|
|
419
|
+
|
|
420
|
+
events.length.should == 2
|
|
421
|
+
events[0][:data][:content]["status"].should == "retrying"
|
|
422
|
+
JSON.parse(events[1][:data][:content])["status"].should == "failed"
|
|
398
423
|
env[:messages].last.role.should == :tool
|
|
399
424
|
env[:messages].last.tool_call_id.should == "tc2"
|
|
400
425
|
env[:should_exit].should == false
|
|
@@ -417,11 +442,13 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
417
442
|
env[:messages] << invalid.("tc#{calls}")
|
|
418
443
|
end
|
|
419
444
|
|
|
420
|
-
env = { messages: Brute.log,
|
|
445
|
+
env = { messages: Brute.log, tools: [], metadata: {} }
|
|
446
|
+
|
|
447
|
+
events = []
|
|
421
448
|
mw = AgUi::Middleware::A2ui.new(terminal, catalog: catalog)
|
|
422
449
|
|
|
423
|
-
mw.call(env)
|
|
424
|
-
first =
|
|
450
|
+
mw.call(recording(env, events))
|
|
451
|
+
first = events[0][:data][:content]
|
|
425
452
|
first["status"].should == "retrying"
|
|
426
453
|
first["attempt"].should == 1
|
|
427
454
|
codes = first["errors"].map { |e| e["code"] }
|
|
@@ -430,11 +457,11 @@ describe "AgUi::Middleware::A2ui" do
|
|
|
430
457
|
env[:should_exit].should == false
|
|
431
458
|
|
|
432
459
|
env[:should_exit] = true
|
|
433
|
-
mw.call(env)
|
|
460
|
+
mw.call(recording(env, events))
|
|
434
461
|
env[:should_exit] = true
|
|
435
|
-
mw.call(env)
|
|
462
|
+
mw.call(recording(env, events))
|
|
436
463
|
|
|
437
|
-
statuses =
|
|
464
|
+
statuses = events.select { |e| e[:type] == :activity_snapshot }
|
|
438
465
|
.map { |e| e[:data][:content]["status"] }
|
|
439
466
|
statuses.should == %w[retrying retrying failed]
|
|
440
467
|
env[:should_exit].should == true
|
|
@@ -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
|