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
data/lib/ag_ui/event_bridge.rb
CHANGED
|
@@ -6,42 +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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
30
36
|
reasoning_message_content: :translate_reasoning_message_content,
|
|
31
|
-
reasoning_message_end:
|
|
32
|
-
reasoning_end:
|
|
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,
|
|
33
43
|
}.freeze
|
|
34
44
|
|
|
35
45
|
def initialize(stream)
|
|
36
46
|
@stream = stream
|
|
37
47
|
end
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 || {}) }
|
|
43
59
|
end
|
|
44
|
-
|
|
60
|
+
hooks
|
|
45
61
|
end
|
|
46
62
|
|
|
47
63
|
private
|
|
@@ -64,8 +80,8 @@ module AgUi
|
|
|
64
80
|
|
|
65
81
|
def translate_tool_call_start(data)
|
|
66
82
|
@stream.tool_call_start(
|
|
67
|
-
tool_call_id:
|
|
68
|
-
tool_call_name:
|
|
83
|
+
tool_call_id: data[:tool_call_id],
|
|
84
|
+
tool_call_name: data[:tool_call_name],
|
|
69
85
|
parent_message_id: data[:parent_message_id],
|
|
70
86
|
)
|
|
71
87
|
end
|
|
@@ -82,18 +98,52 @@ module AgUi
|
|
|
82
98
|
|
|
83
99
|
def translate_tool_call_result(data)
|
|
84
100
|
@stream.tool_call_result(
|
|
85
|
-
message_id:
|
|
101
|
+
message_id: data[:message_id],
|
|
86
102
|
tool_call_id: data[:tool_call_id],
|
|
87
|
-
content:
|
|
103
|
+
content: data[:content],
|
|
88
104
|
)
|
|
89
105
|
end
|
|
90
106
|
|
|
107
|
+
# Shared state (CoAgents). snapshot replaces the whole state; delta is a
|
|
108
|
+
# JSON Patch (RFC 6902) op array the client applies to its own store.
|
|
109
|
+
def translate_state_snapshot(data)
|
|
110
|
+
@stream.state_snapshot(snapshot: data[:snapshot])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def translate_state_delta(data)
|
|
114
|
+
@stream.state_delta(delta: data[:delta])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def translate_messages_snapshot(data)
|
|
118
|
+
@stream.messages_snapshot(messages: data[:messages])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Structured progress markers (paired start/finish by step name).
|
|
122
|
+
def translate_step_started(data)
|
|
123
|
+
@stream.step_started(step_name: data[:step_name])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def translate_step_finished(data)
|
|
127
|
+
@stream.step_finished(step_name: data[:step_name])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Escape hatches: CUSTOM carries app/agent-defined events (e.g. the
|
|
131
|
+
# "PredictState" convention for predictive state updates); RAW passes a
|
|
132
|
+
# framework event straight through.
|
|
133
|
+
def translate_custom(data)
|
|
134
|
+
@stream.custom(name: data[:name], value: data[:value])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def translate_raw(data)
|
|
138
|
+
@stream.raw(event: data[:event], source: data[:source])
|
|
139
|
+
end
|
|
140
|
+
|
|
91
141
|
def translate_activity_snapshot(data)
|
|
92
142
|
@stream.activity_snapshot(
|
|
93
|
-
message_id:
|
|
143
|
+
message_id: data[:message_id],
|
|
94
144
|
activity_type: data[:activity_type],
|
|
95
|
-
content:
|
|
96
|
-
replace:
|
|
145
|
+
content: data[:content],
|
|
146
|
+
replace: data.fetch(:replace, true),
|
|
97
147
|
)
|
|
98
148
|
end
|
|
99
149
|
|
|
@@ -124,6 +174,15 @@ end
|
|
|
124
174
|
__END__
|
|
125
175
|
|
|
126
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
|
+
|
|
127
186
|
read_frames = ->(stream) do
|
|
128
187
|
frames = []
|
|
129
188
|
while (chunk = stream.read)
|
|
@@ -134,12 +193,12 @@ describe "AgUi::EventBridge" do
|
|
|
134
193
|
|
|
135
194
|
it "translates text events into SSE frames as they arrive" do
|
|
136
195
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
137
|
-
|
|
196
|
+
env = bridged(stream)
|
|
138
197
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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" })
|
|
143
202
|
stream.finish
|
|
144
203
|
|
|
145
204
|
frames = read_frames.(stream)
|
|
@@ -151,24 +210,65 @@ describe "AgUi::EventBridge" do
|
|
|
151
210
|
|
|
152
211
|
it "drops empty deltas (protocol rule)" do
|
|
153
212
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
154
|
-
|
|
213
|
+
env = bridged(stream)
|
|
155
214
|
|
|
156
|
-
|
|
157
|
-
|
|
215
|
+
env.emit(:text_message_content, { message_id: "m1", delta: "" })
|
|
216
|
+
env.emit(:text_message_content, { message_id: "m1", delta: nil })
|
|
158
217
|
stream.finish
|
|
159
218
|
|
|
160
219
|
read_frames.(stream).should == []
|
|
161
220
|
end
|
|
162
221
|
|
|
163
|
-
it "
|
|
222
|
+
it "leaves brute's own lifecycle events and unknown types off the wire" do
|
|
164
223
|
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
165
|
-
|
|
224
|
+
env = bridged(stream)
|
|
166
225
|
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
env.emit(Brute::Hooks::LLM_START_EVENT)
|
|
227
|
+
env.emit(:whatever, { note: "internal" })
|
|
169
228
|
stream.finish
|
|
170
229
|
|
|
171
|
-
result.should.equal?(bridge)
|
|
172
230
|
read_frames.(stream).should == []
|
|
173
231
|
end
|
|
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
|
+
|
|
240
|
+
it "translates shared-state events (snapshot + JSON-Patch delta)" do
|
|
241
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
242
|
+
env = bridged(stream)
|
|
243
|
+
|
|
244
|
+
env.emit(:state_snapshot, { snapshot: { "theme" => "dark" } })
|
|
245
|
+
env.emit(
|
|
246
|
+
:state_delta,
|
|
247
|
+
{ delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] },
|
|
248
|
+
)
|
|
249
|
+
stream.finish
|
|
250
|
+
|
|
251
|
+
frames = read_frames.(stream)
|
|
252
|
+
frames.map { |f| f["type"] }.should == %w[STATE_SNAPSHOT STATE_DELTA]
|
|
253
|
+
frames[0]["snapshot"].should == { "theme" => "dark" }
|
|
254
|
+
frames[1]["delta"].first["path"].should == "/theme"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
|
|
258
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
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" })
|
|
267
|
+
stream.finish
|
|
268
|
+
|
|
269
|
+
frames = read_frames.(stream)
|
|
270
|
+
frames.map { |f| f["type"] }.should == %w[STEP_STARTED CUSTOM STEP_FINISHED]
|
|
271
|
+
frames[0]["stepName"].should == "plan"
|
|
272
|
+
frames[1]["name"].should == "PredictState"
|
|
273
|
+
end
|
|
174
274
|
end
|
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
|