ag-ui 0.2.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/version.rb +1 -1
- data/lib/ag_ui.rb +1 -0
- metadata +16 -1
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
|
data/lib/ag_ui/version.rb
CHANGED
data/lib/ag_ui.rb
CHANGED
|
@@ -25,6 +25,7 @@ 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"
|
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,6 +214,7 @@ 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
|