ag-ui 0.1.0 → 0.3.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/lib/ag_ui/event_bridge.rb +73 -0
- data/lib/ag_ui/middleware/state.rb +341 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +20 -5
- data/lib/ag_ui/version.rb +1 -1
- data/lib/ag_ui.rb +1 -1
- metadata +16 -2
- data/lib/ag_ui/run_loop.rb +0 -285
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6092a88471f821fdd20d43bd3dad28205f114bb5b2068faea39234b4f1bc87da
|
|
4
|
+
data.tar.gz: 93566155eed8ad3464386a74b8d5afb6db6ea124eccac4bf6382b264e02733ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf108e6f97a63801cf5b3a936241d187a3492bf8c92209ff0353d5e98ae68bb3752c05aedeffcb8f7a1deee0d91378128dc9608b3a4f997e8a98b9712412fc89
|
|
7
|
+
data.tar.gz: 541b3da611c70fdd1ec02b97dd9c8fad76d30f20e124d38800896d57b431a099c148d3635193ebef28aaae6d70b303989e41380abeb6c4e489820cd2fc1d6d1c
|
data/lib/ag_ui/event_bridge.rb
CHANGED
|
@@ -24,12 +24,19 @@ module AgUi
|
|
|
24
24
|
tool_call_args: :translate_tool_call_args,
|
|
25
25
|
tool_call_end: :translate_tool_call_end,
|
|
26
26
|
tool_call_result: :translate_tool_call_result,
|
|
27
|
+
state_snapshot: :translate_state_snapshot,
|
|
28
|
+
state_delta: :translate_state_delta,
|
|
29
|
+
messages_snapshot: :translate_messages_snapshot,
|
|
27
30
|
activity_snapshot: :translate_activity_snapshot,
|
|
28
31
|
reasoning_start: :translate_reasoning_start,
|
|
29
32
|
reasoning_message_start: :translate_reasoning_message_start,
|
|
30
33
|
reasoning_message_content: :translate_reasoning_message_content,
|
|
31
34
|
reasoning_message_end: :translate_reasoning_message_end,
|
|
32
35
|
reasoning_end: :translate_reasoning_end,
|
|
36
|
+
step_started: :translate_step_started,
|
|
37
|
+
step_finished: :translate_step_finished,
|
|
38
|
+
custom: :translate_custom,
|
|
39
|
+
raw: :translate_raw,
|
|
33
40
|
}.freeze
|
|
34
41
|
|
|
35
42
|
def initialize(stream)
|
|
@@ -88,6 +95,40 @@ module AgUi
|
|
|
88
95
|
)
|
|
89
96
|
end
|
|
90
97
|
|
|
98
|
+
# Shared state (CoAgents). snapshot replaces the whole state; delta is a
|
|
99
|
+
# JSON Patch (RFC 6902) op array the client applies to its own store.
|
|
100
|
+
def translate_state_snapshot(data)
|
|
101
|
+
@stream.state_snapshot(snapshot: data[:snapshot])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def translate_state_delta(data)
|
|
105
|
+
@stream.state_delta(delta: data[:delta])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def translate_messages_snapshot(data)
|
|
109
|
+
@stream.messages_snapshot(messages: data[:messages])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Structured progress markers (paired start/finish by step name).
|
|
113
|
+
def translate_step_started(data)
|
|
114
|
+
@stream.step_started(step_name: data[:step_name])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def translate_step_finished(data)
|
|
118
|
+
@stream.step_finished(step_name: data[:step_name])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Escape hatches: CUSTOM carries app/agent-defined events (e.g. the
|
|
122
|
+
# "PredictState" convention for predictive state updates); RAW passes a
|
|
123
|
+
# framework event straight through.
|
|
124
|
+
def translate_custom(data)
|
|
125
|
+
@stream.custom(name: data[:name], value: data[:value])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def translate_raw(data)
|
|
129
|
+
@stream.raw(event: data[:event], source: data[:source])
|
|
130
|
+
end
|
|
131
|
+
|
|
91
132
|
def translate_activity_snapshot(data)
|
|
92
133
|
@stream.activity_snapshot(
|
|
93
134
|
message_id: data[:message_id],
|
|
@@ -171,4 +212,36 @@ describe "AgUi::EventBridge" do
|
|
|
171
212
|
result.should.equal?(bridge)
|
|
172
213
|
read_frames.(stream).should == []
|
|
173
214
|
end
|
|
215
|
+
|
|
216
|
+
it "translates shared-state events (snapshot + JSON-Patch delta)" do
|
|
217
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
218
|
+
bridge = AgUi::EventBridge.new(stream)
|
|
219
|
+
|
|
220
|
+
bridge << { type: :state_snapshot, data: { snapshot: { "theme" => "dark" } } }
|
|
221
|
+
bridge << { type: :state_delta,
|
|
222
|
+
data: { delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] } }
|
|
223
|
+
stream.finish
|
|
224
|
+
|
|
225
|
+
frames = read_frames.(stream)
|
|
226
|
+
frames.map { |f| f["type"] }.should == %w[STATE_SNAPSHOT STATE_DELTA]
|
|
227
|
+
frames[0]["snapshot"].should == { "theme" => "dark" }
|
|
228
|
+
frames[1]["delta"].first["path"].should == "/theme"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
|
|
232
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
233
|
+
bridge = AgUi::EventBridge.new(stream)
|
|
234
|
+
|
|
235
|
+
bridge << { type: :step_started, data: { step_name: "plan" } }
|
|
236
|
+
bridge << { type: :custom,
|
|
237
|
+
data: { name: "PredictState",
|
|
238
|
+
value: [{ "state_key" => "document", "tool" => "write" }] } }
|
|
239
|
+
bridge << { type: :step_finished, data: { step_name: "plan" } }
|
|
240
|
+
stream.finish
|
|
241
|
+
|
|
242
|
+
frames = read_frames.(stream)
|
|
243
|
+
frames.map { |f| f["type"] }.should == %w[STEP_STARTED CUSTOM STEP_FINISHED]
|
|
244
|
+
frames[0]["stepName"].should == "plan"
|
|
245
|
+
frames[1]["name"].should == "PredictState"
|
|
246
|
+
end
|
|
174
247
|
end
|
|
@@ -0,0 +1,341 @@
|
|
|
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
|
|
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[:events] << { type: :state_snapshot, data: { 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[:events] << { type: :state_delta, data: { 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
|
+
content = error ? { "status" => "error", "error" => error } : { "status" => "ok" }
|
|
190
|
+
json = JSON.generate(content)
|
|
191
|
+
env[:messages].tool(json, tool_call_id: tool_call.id)
|
|
192
|
+
env[:events] << {
|
|
193
|
+
type: :tool_call_result,
|
|
194
|
+
data: {
|
|
195
|
+
message_id: SecureRandom.uuid,
|
|
196
|
+
tool_call_id: tool_call.id,
|
|
197
|
+
content: json,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize(state)
|
|
203
|
+
if state.nil?
|
|
204
|
+
nil
|
|
205
|
+
elsif state.respond_to?(:to_h)
|
|
206
|
+
state.to_h
|
|
207
|
+
else
|
|
208
|
+
state
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def deep_dup(obj)
|
|
213
|
+
case obj
|
|
214
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
215
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
216
|
+
else obj
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
__END__
|
|
224
|
+
|
|
225
|
+
describe "AgUi::Middleware::State" do
|
|
226
|
+
snapshot_call = ->(args) do
|
|
227
|
+
Brute::Message.new(
|
|
228
|
+
role: :assistant, content: nil,
|
|
229
|
+
tool_calls: [{ id: "tc1", name: "AGUISendStateSnapshot", arguments: args }],
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
delta_call = ->(args) do
|
|
234
|
+
Brute::Message.new(
|
|
235
|
+
role: :assistant, content: nil,
|
|
236
|
+
tool_calls: [{ id: "tc1", name: "AGUISendStateDelta", arguments: args }],
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it "advertises both state tools idempotently and seeds env[:state] from input" do
|
|
241
|
+
seen = nil
|
|
242
|
+
mw = AgUi::Middleware::State.new(->(env) { seen = env }, state: { "theme" => "light" })
|
|
243
|
+
env = { messages: Brute.log, events: [], tools: [{ "name" => "navigate" }] }
|
|
244
|
+
mw.call(env)
|
|
245
|
+
mw.call(env) # second loop iteration — no dupes
|
|
246
|
+
|
|
247
|
+
seen[:tools].map { |t| t["name"] }.should ==
|
|
248
|
+
%w[navigate AGUISendStateSnapshot AGUISendStateDelta]
|
|
249
|
+
seen[:state].should == { "theme" => "light" }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "seeds from a Definition-like state via to_h" do
|
|
253
|
+
definition = Object.new
|
|
254
|
+
def definition.to_h = { "count" => 1 }
|
|
255
|
+
seen = nil
|
|
256
|
+
AgUi::Middleware::State.new(->(env) { seen = env }, state: definition)
|
|
257
|
+
.call({ messages: Brute.log, events: [], tools: [] })
|
|
258
|
+
seen[:state].should == { "count" => 1 }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "AGUISendStateSnapshot: sets state, emits STATE_SNAPSHOT + result, continues the run" do
|
|
262
|
+
terminal = ->(env) { env[:messages] << snapshot_call.("snapshot" => { "count" => 3 }) }
|
|
263
|
+
env = { messages: Brute.log, events: [], tools: [], should_exit: true }
|
|
264
|
+
AgUi::Middleware::State.new(terminal).call(env)
|
|
265
|
+
|
|
266
|
+
env[:state].should == { "count" => 3 }
|
|
267
|
+
env[:events].map { |e| e[:type] }.should == %i[state_snapshot tool_call_result]
|
|
268
|
+
env[:events][0][:data][:snapshot].should == { "count" => 3 }
|
|
269
|
+
env[:messages].last.role.should == :tool
|
|
270
|
+
env[:messages].last.tool_call_id.should == "tc1"
|
|
271
|
+
env[:messages].last.content.should == "{\"status\":\"ok\"}"
|
|
272
|
+
env[:should_exit].should == false
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "AGUISendStateDelta: patches state (Hana), emits STATE_DELTA with the raw ops" do
|
|
276
|
+
delta = [{ "op" => "replace", "path" => "/theme", "value" => "dark" }]
|
|
277
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
278
|
+
env = { messages: Brute.log, events: [], tools: [], state: { "theme" => "light" }, should_exit: true }
|
|
279
|
+
AgUi::Middleware::State.new(terminal).call(env)
|
|
280
|
+
|
|
281
|
+
env[:state].should == { "theme" => "dark" }
|
|
282
|
+
env[:events].map { |e| e[:type] }.should == %i[state_delta tool_call_result]
|
|
283
|
+
env[:events][0][:data][:delta].should == delta
|
|
284
|
+
env[:should_exit].should == false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it "adds a nested key via delta against seeded state" do
|
|
288
|
+
delta = [{ "op" => "add", "path" => "/documentEditor", "value" => { "activeTabId" => "doc-2" } }]
|
|
289
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
290
|
+
env = { messages: Brute.log, events: [], tools: [], should_exit: true }
|
|
291
|
+
AgUi::Middleware::State.new(terminal, state: { "documentEditor" => { "activeTabId" => "doc-1" } })
|
|
292
|
+
.call(env)
|
|
293
|
+
|
|
294
|
+
env[:state].should == { "documentEditor" => { "activeTabId" => "doc-2" } }
|
|
295
|
+
env[:events][0][:type].should == :state_delta
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "reports a bad patch back to the model as a tool error, no STATE_DELTA emitted" do
|
|
299
|
+
delta = [{ "op" => "replace", "path" => "/missing/deep", "value" => 1 }]
|
|
300
|
+
terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
|
|
301
|
+
env = { messages: Brute.log, events: [], tools: [], state: {}, should_exit: true }
|
|
302
|
+
AgUi::Middleware::State.new(terminal).call(env)
|
|
303
|
+
|
|
304
|
+
env[:events].map { |e| e[:type] }.should == %i[tool_call_result]
|
|
305
|
+
JSON.parse(env[:events][0][:data][:content])["status"].should == "error"
|
|
306
|
+
env[:messages].last.role.should == :tool
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "rejects a non-array delta and a non-object snapshot as tool errors" do
|
|
310
|
+
[delta_call.("delta" => "nope"), snapshot_call.("snapshot" => "nope")].each do |msg|
|
|
311
|
+
terminal = ->(env) { env[:messages] << msg }
|
|
312
|
+
env = { messages: Brute.log, events: [], tools: [] }
|
|
313
|
+
AgUi::Middleware::State.new(terminal).call(env)
|
|
314
|
+
JSON.parse(env[:events].last[:data][:content])["status"].should == "error"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it "leaves should_exit set when a real client tool is mixed into the turn" do
|
|
319
|
+
terminal = ->(env) do
|
|
320
|
+
env[:messages] << Brute::Message.new(
|
|
321
|
+
role: :assistant, content: nil,
|
|
322
|
+
tool_calls: [
|
|
323
|
+
{ id: "tc1", name: "AGUISendStateSnapshot", arguments: { "snapshot" => { "a" => 1 } } },
|
|
324
|
+
{ id: "tc2", name: "navigate", arguments: { "path" => "/x" } },
|
|
325
|
+
],
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
env = { messages: Brute.log, events: [], tools: [], should_exit: true }
|
|
329
|
+
AgUi::Middleware::State.new(terminal).call(env)
|
|
330
|
+
|
|
331
|
+
env[:events].any? { |e| e[:type] == :state_snapshot }.should == true
|
|
332
|
+
env[:should_exit].should == true # browser still needs to run navigate
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "does nothing on a plain text turn" do
|
|
336
|
+
env = { messages: Brute.log, events: [], tools: [] }
|
|
337
|
+
AgUi::Middleware::State.new(->(e) { e[:messages].assistant("hi") }).call(env)
|
|
338
|
+
env[:events].should == []
|
|
339
|
+
env.key?(:should_exit).should == false
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -14,8 +14,8 @@ module AgUi
|
|
|
14
14
|
# require "ag_ui/terminals/ruby_llm"
|
|
15
15
|
#
|
|
16
16
|
# RubyLLM.configure { |c| c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] }
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# terminal = AgUi::Terminals::RubyLLM.new
|
|
18
|
+
# agent = Brute.agent.use(...).run(terminal) # standard Brute agent
|
|
19
19
|
#
|
|
20
20
|
# One assistant turn per call: seeds the chat from env[:messages]
|
|
21
21
|
# (including prior tool-call turns), registers env[:tools] as
|
|
@@ -517,7 +517,7 @@ describe "AgUi::Terminals::RubyLLM" do
|
|
|
517
517
|
captured.should == [[:anthropic, "claude-sonnet-4-5"], [:anthropic, "claude-sonnet-4-5"]]
|
|
518
518
|
end
|
|
519
519
|
|
|
520
|
-
it "drives the full client-tool run through
|
|
520
|
+
it "drives the full client-tool run through a standard Brute agent + ToolRouter" do
|
|
521
521
|
tool_calls = {
|
|
522
522
|
"tc1" => ::RubyLLM::ToolCall.new(id: "tc1", name: "navigate",
|
|
523
523
|
arguments: { "path" => "/data" }),
|
|
@@ -525,8 +525,23 @@ describe "AgUi::Terminals::RubyLLM" do
|
|
|
525
525
|
fake = fake_chat_class.new(final: nil, tool_calls: tool_calls)
|
|
526
526
|
terminal = AgUi::Terminals::RubyLLM.new(chat_factory: ->(**) { fake })
|
|
527
527
|
|
|
528
|
-
|
|
529
|
-
|
|
528
|
+
app = AgUi.agent(agent_id: "default") do |env|
|
|
529
|
+
input = env["ag_ui.input"]
|
|
530
|
+
agent = Brute.agent
|
|
531
|
+
.use(AgUi::Middleware::SystemPrompt, prompt: "Be terse.", context: input.context)
|
|
532
|
+
.use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
|
|
533
|
+
.use(Brute::Middleware::Loop::ToolResult)
|
|
534
|
+
.use(Brute::Middleware::MaxIterations, max_iterations: 10)
|
|
535
|
+
.use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: [])
|
|
536
|
+
.run(terminal)
|
|
537
|
+
env["ag_ui.stream"].open(thread_id: input.thread_id, run_id: input.run_id) do |stream|
|
|
538
|
+
stream.run_started
|
|
539
|
+
agent.start(AgUi::Messages.to_brute(input.messages), events: AgUi::EventBridge.new(stream))
|
|
540
|
+
stream.run_finished
|
|
541
|
+
rescue => e
|
|
542
|
+
stream.run_error(message: e.message, code: e.class.name)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
530
545
|
|
|
531
546
|
body = JSON.generate({
|
|
532
547
|
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
data/lib/ag_ui/version.rb
CHANGED
data/lib/ag_ui.rb
CHANGED
|
@@ -25,8 +25,8 @@ require "ag_ui/event_bridge"
|
|
|
25
25
|
require "ag_ui/middleware/system_prompt"
|
|
26
26
|
require "ag_ui/middleware/forwarded_props"
|
|
27
27
|
require "ag_ui/middleware/tool_router"
|
|
28
|
+
require "ag_ui/middleware/state"
|
|
28
29
|
require "ag_ui/a2ui/catalog"
|
|
29
30
|
require "ag_ui/a2ui/validate"
|
|
30
31
|
require "ag_ui/a2ui/recovery"
|
|
31
32
|
require "ag_ui/middleware/a2ui"
|
|
32
|
-
require "ag_ui/run_loop"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ag-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- AgUi Contributors
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '2.5'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: hana
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.3'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.3'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: brute
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -200,13 +214,13 @@ files:
|
|
|
200
214
|
- lib/ag_ui/messages.rb
|
|
201
215
|
- lib/ag_ui/middleware/a2ui.rb
|
|
202
216
|
- lib/ag_ui/middleware/forwarded_props.rb
|
|
217
|
+
- lib/ag_ui/middleware/state.rb
|
|
203
218
|
- lib/ag_ui/middleware/system_prompt.rb
|
|
204
219
|
- lib/ag_ui/middleware/tool_router.rb
|
|
205
220
|
- lib/ag_ui/protocol/json_schema.rb
|
|
206
221
|
- lib/ag_ui/protocol/json_schema/definition.rb
|
|
207
222
|
- lib/ag_ui/protocol/json_schema/validation_error.rb
|
|
208
223
|
- lib/ag_ui/run_input.rb
|
|
209
|
-
- lib/ag_ui/run_loop.rb
|
|
210
224
|
- lib/ag_ui/run_store.rb
|
|
211
225
|
- lib/ag_ui/server.rb
|
|
212
226
|
- lib/ag_ui/server/info.rb
|
data/lib/ag_ui/run_loop.rb
DELETED
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "console"
|
|
5
|
-
require "ag_ui"
|
|
6
|
-
|
|
7
|
-
module AgUi
|
|
8
|
-
# The run loop: drives one AG-UI run through a brute turn pipeline.
|
|
9
|
-
#
|
|
10
|
-
# The terminal block is the LLM call (ruby_llm or anything else) — it
|
|
11
|
-
# receives brute's env ({messages:, events:, ...}), streams deltas into
|
|
12
|
-
# env[:events] (translated live to SSE by EventBridge) and appends the
|
|
13
|
-
# assistant message to env[:messages]. The gem stays LLM-agnostic.
|
|
14
|
-
#
|
|
15
|
-
# run_loop = AgUi::RunLoop.new(system_prompt: PROMPT) do |env|
|
|
16
|
-
# env[:events] << { type: :text_message_start, data: { message_id: id } }
|
|
17
|
-
# ... stream provider chunks ...
|
|
18
|
-
# env[:events] << { type: :text_message_end, data: { message_id: id } }
|
|
19
|
-
# env[:messages].assistant(full_text)
|
|
20
|
-
# end
|
|
21
|
-
#
|
|
22
|
-
# app = AgUi.agent(agent_id: "default", &run_loop)
|
|
23
|
-
#
|
|
24
|
-
# Lifecycle per run (the AG-UI contract): RUN_STARTED first; then the
|
|
25
|
-
# pipeline streams; then RUN_FINISHED — or RUN_ERROR if the pipeline
|
|
26
|
-
# raised. Schema-validation failures are wire-contract bugs and re-raise
|
|
27
|
-
# after RUN_ERROR so they fail loudly in dev.
|
|
28
|
-
class RunLoop
|
|
29
|
-
# a2ui: nil/false = off; an AgUi::A2ui::Catalog = on with that catalog;
|
|
30
|
-
# true = on degraded (tool injected, no component schema).
|
|
31
|
-
# server_tools: [{name:, description:, parameters:, handler:}] execute
|
|
32
|
-
# inline and the turn loops (Loop::ToolResult) until the model answers
|
|
33
|
-
# in text, a client tool defers to the browser, or max_iterations hits.
|
|
34
|
-
def initialize(system_prompt: nil, validate: true, middleware: [], a2ui: nil,
|
|
35
|
-
server_tools: [], max_iterations: 10, &terminal)
|
|
36
|
-
unless terminal
|
|
37
|
-
raise ArgumentError, "RunLoop requires a terminal block (the LLM call)"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
@system_prompt = system_prompt
|
|
41
|
-
@validate = validate
|
|
42
|
-
@middleware = middleware
|
|
43
|
-
@a2ui = a2ui
|
|
44
|
-
@server_tools = server_tools
|
|
45
|
-
@max_iterations = max_iterations
|
|
46
|
-
@terminal = terminal
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def a2ui_enabled?
|
|
50
|
-
!(@a2ui.nil? || @a2ui == false)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# AgUi.agent takes a block; RunLoop quacks like one.
|
|
54
|
-
def to_proc
|
|
55
|
-
run_loop = self
|
|
56
|
-
proc { |rack_env| run_loop.handle(rack_env) }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def handle(rack_env)
|
|
60
|
-
input = rack_env["ag_ui.input"]
|
|
61
|
-
|
|
62
|
-
rack_env["ag_ui.stream"].open(
|
|
63
|
-
thread_id: input.thread_id,
|
|
64
|
-
run_id: input.run_id,
|
|
65
|
-
validate: @validate,
|
|
66
|
-
) do |stream|
|
|
67
|
-
stream.run_started
|
|
68
|
-
run(stream, input)
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
def run(stream, input)
|
|
75
|
-
pipeline(input).start(
|
|
76
|
-
Messages.to_brute(input.messages),
|
|
77
|
-
events: EventBridge.new(stream),
|
|
78
|
-
)
|
|
79
|
-
stream.run_finished
|
|
80
|
-
rescue Protocol::JsonSchema::ValidationError => e
|
|
81
|
-
Console.error(self, "wire-contract bug: #{e.message}", e)
|
|
82
|
-
stream.run_error(message: "Internal error", code: "validation")
|
|
83
|
-
raise
|
|
84
|
-
rescue => e
|
|
85
|
-
Console.error(self, "run failed: #{e.class}: #{e.message}", e)
|
|
86
|
-
stream.run_error(message: e.message, code: e.class.name)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def pipeline(input)
|
|
90
|
-
agent = Brute.agent
|
|
91
|
-
agent.use(
|
|
92
|
-
AgUi::Middleware::SystemPrompt,
|
|
93
|
-
prompt: @system_prompt,
|
|
94
|
-
context: input.context,
|
|
95
|
-
)
|
|
96
|
-
agent.use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
|
|
97
|
-
agent.use(Brute::Middleware::Loop::ToolResult)
|
|
98
|
-
agent.use(Brute::Middleware::MaxIterations, max_iterations: @max_iterations)
|
|
99
|
-
if a2ui_enabled?
|
|
100
|
-
catalog = @a2ui.is_a?(AgUi::A2ui::Catalog) ? @a2ui : nil
|
|
101
|
-
agent.use(AgUi::Middleware::A2ui, catalog: catalog)
|
|
102
|
-
end
|
|
103
|
-
agent.use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: @server_tools)
|
|
104
|
-
@middleware.each do |(klass, options)|
|
|
105
|
-
agent.use(klass, **(options || {}))
|
|
106
|
-
end
|
|
107
|
-
agent.run(@terminal)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
__END__
|
|
113
|
-
|
|
114
|
-
describe "AgUi::RunLoop" do
|
|
115
|
-
minimal_input = JSON.generate({
|
|
116
|
-
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
|
117
|
-
"messages" => [{ "id" => "u1", "role" => "user", "content" => "hi" }],
|
|
118
|
-
"tools" => [], "context" => [{ "description" => "currentPath", "value" => "/data" }],
|
|
119
|
-
"forwardedProps" => nil,
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
request = ->(app, body) do
|
|
123
|
-
app.call({
|
|
124
|
-
"REQUEST_METHOD" => "POST",
|
|
125
|
-
"PATH_INFO" => "/agent/default/run",
|
|
126
|
-
"rack.input" => StringIO.new(body),
|
|
127
|
-
})
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
read_frames = ->(stream) do
|
|
131
|
-
frames = []
|
|
132
|
-
while (chunk = stream.read)
|
|
133
|
-
frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
134
|
-
end
|
|
135
|
-
frames
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
it "streams a full run through a brute pipeline" do
|
|
139
|
-
seen_env = nil
|
|
140
|
-
run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.") do |env|
|
|
141
|
-
seen_env = env
|
|
142
|
-
env[:events] << { type: :text_message_start, data: { message_id: "m1" } }
|
|
143
|
-
env[:events] << { type: :text_message_content, data: { message_id: "m1", delta: "Hello" } }
|
|
144
|
-
env[:events] << { type: :text_message_end, data: { message_id: "m1" } }
|
|
145
|
-
env[:messages].assistant("Hello")
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
149
|
-
_status, _headers, body = request.(app, minimal_input)
|
|
150
|
-
|
|
151
|
-
frames = read_frames.(body)
|
|
152
|
-
frames.map { |f| f["type"] }.should == %w[
|
|
153
|
-
RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
|
|
154
|
-
]
|
|
155
|
-
|
|
156
|
-
# The pipeline saw: system prompt (with context addendum) + history.
|
|
157
|
-
seen_env[:messages].map(&:role).should == [:system, :user, :assistant]
|
|
158
|
-
seen_env[:messages].first.content.should.include?("Be terse.")
|
|
159
|
-
seen_env[:messages].first.content.should.include?("currentPath: /data")
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
it "ends the run with RUN_ERROR when the terminal raises" do
|
|
163
|
-
run_loop = AgUi::RunLoop.new { |_env| raise "provider exploded" }
|
|
164
|
-
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
165
|
-
|
|
166
|
-
_status, _headers, body = request.(app, minimal_input)
|
|
167
|
-
frames = read_frames.(body)
|
|
168
|
-
|
|
169
|
-
frames.map { |f| f["type"] }.should == %w[RUN_STARTED RUN_ERROR]
|
|
170
|
-
frames.last["message"].should == "provider exploded"
|
|
171
|
-
frames.last["code"].should == "RuntimeError"
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
it "supports extra brute middleware" do
|
|
175
|
-
marker = Class.new do
|
|
176
|
-
def initialize(app, note:)
|
|
177
|
-
@app = app
|
|
178
|
-
@note = note
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def call(env)
|
|
182
|
-
env[:metadata][:note] = @note
|
|
183
|
-
@app.call(env)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
seen = nil
|
|
188
|
-
run_loop = AgUi::RunLoop.new(middleware: [[marker, { note: "hi" }]]) do |env|
|
|
189
|
-
seen = env[:metadata][:note]
|
|
190
|
-
env[:messages].assistant("ok")
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
request.(AgUi.agent(&run_loop), minimal_input)
|
|
194
|
-
seen.should == "hi"
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
it "requires a terminal block" do
|
|
198
|
-
lambda { AgUi::RunLoop.new }.should.raise(ArgumentError)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
it "executes server tools inline and loops the turn to completion" do
|
|
202
|
-
weather_tool = {
|
|
203
|
-
name: "get_weather",
|
|
204
|
-
description: "Weather for a city",
|
|
205
|
-
parameters: { "type" => "object" },
|
|
206
|
-
handler: ->(args) { { "city" => args["city"], "temp" => 21 } },
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
iterations = 0
|
|
210
|
-
terminal = ->(env) do
|
|
211
|
-
iterations += 1
|
|
212
|
-
if iterations == 1
|
|
213
|
-
env[:messages] << Brute::Message.new(
|
|
214
|
-
role: :assistant, content: nil,
|
|
215
|
-
tool_calls: [{ id: "tc1", name: "get_weather", arguments: { "city" => "Lisbon" } }],
|
|
216
|
-
)
|
|
217
|
-
else
|
|
218
|
-
# The model sees its own call + the executed result.
|
|
219
|
-
env[:messages].last.role.should == :tool
|
|
220
|
-
env[:messages].last.content.should == "{\"city\":\"Lisbon\",\"temp\":21}"
|
|
221
|
-
env[:events] << { type: :text_message_start, data: { message_id: "m2" } }
|
|
222
|
-
env[:events] << { type: :text_message_content, data: { message_id: "m2", delta: "21C in Lisbon" } }
|
|
223
|
-
env[:events] << { type: :text_message_end, data: { message_id: "m2" } }
|
|
224
|
-
env[:messages].assistant("21C in Lisbon")
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
run_loop = AgUi::RunLoop.new(server_tools: [weather_tool], &terminal)
|
|
229
|
-
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
230
|
-
|
|
231
|
-
_status, _headers, stream = request.(app, minimal_input)
|
|
232
|
-
frames = read_frames.(stream)
|
|
233
|
-
|
|
234
|
-
iterations.should == 2
|
|
235
|
-
frames.map { |f| f["type"] }.should == %w[
|
|
236
|
-
RUN_STARTED
|
|
237
|
-
TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_RESULT
|
|
238
|
-
TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END
|
|
239
|
-
RUN_FINISHED
|
|
240
|
-
]
|
|
241
|
-
frames[4]["content"].should == "{\"city\":\"Lisbon\",\"temp\":21}"
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
it "streams the full A2UI sequence when the model renders a surface" do
|
|
245
|
-
catalog = AgUi::A2ui::Catalog.new(
|
|
246
|
-
catalog_id: "host://ai-catalog",
|
|
247
|
-
components: { "Card" => {} },
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
terminal = ->(env) do
|
|
251
|
-
env[:tools].map { |t| t["name"] }.should.include?("render_a2ui")
|
|
252
|
-
env[:messages].first.content.should.include?("A2UI Component Schema")
|
|
253
|
-
|
|
254
|
-
env[:messages] << Brute::Message.new(
|
|
255
|
-
role: :assistant, content: nil,
|
|
256
|
-
tool_calls: [{ id: "tc9", name: "render_a2ui", arguments: {
|
|
257
|
-
"surfaceId" => "s1",
|
|
258
|
-
"components" => [{ "id" => "root", "component" => "Card" }],
|
|
259
|
-
} }],
|
|
260
|
-
)
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
run_loop = AgUi::RunLoop.new(a2ui: catalog, &terminal)
|
|
264
|
-
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
265
|
-
|
|
266
|
-
_status, _headers, stream = request.(app, minimal_input)
|
|
267
|
-
frames = read_frames.(stream)
|
|
268
|
-
|
|
269
|
-
frames.map { |f| f["type"] }.should == %w[
|
|
270
|
-
RUN_STARTED TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END
|
|
271
|
-
ACTIVITY_SNAPSHOT TOOL_CALL_RESULT RUN_FINISHED
|
|
272
|
-
]
|
|
273
|
-
|
|
274
|
-
snapshot = frames[4]
|
|
275
|
-
snapshot["messageId"].should == "a2ui-surface-tc9"
|
|
276
|
-
snapshot["activityType"].should == "a2ui-surface"
|
|
277
|
-
snapshot["replace"].should == true
|
|
278
|
-
snapshot["content"]["a2ui_operations"][0]["createSurface"]["catalogId"]
|
|
279
|
-
.should == "host://ai-catalog"
|
|
280
|
-
|
|
281
|
-
result = frames[5]
|
|
282
|
-
result["toolCallId"].should == "tc9"
|
|
283
|
-
result["content"].should == "{\"status\":\"rendered\"}"
|
|
284
|
-
end
|
|
285
|
-
end
|