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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba6811e09735cfc95262ba711a5e953a92d9af8d78ea8b13867f1d1c60fbeedc
4
- data.tar.gz: 75e995ce5c17476566258e05e5b56de4481b3df8539554e91d687363e15ad456
3
+ metadata.gz: 6092a88471f821fdd20d43bd3dad28205f114bb5b2068faea39234b4f1bc87da
4
+ data.tar.gz: 93566155eed8ad3464386a74b8d5afb6db6ea124eccac4bf6382b264e02733ec
5
5
  SHA512:
6
- metadata.gz: 7e1bfaaa5c833b60ab7ce9a99d069abd3603eaf8366abf355b73688b07c54fb1d76fd280fb46a00e3e0e01a5cbf9de8d7b4ce7e59b581679eff92f7f891842c2
7
- data.tar.gz: 205c4a2f519bcbfcf1fcb975de4929da568633d1358a793c78b042424c867918cce430dd6c9289b965ead31bb1a218a4d706b14e7476093a2ac42d7a10a1e21b
6
+ metadata.gz: cf108e6f97a63801cf5b3a936241d187a3492bf8c92209ff0353d5e98ae68bb3752c05aedeffcb8f7a1deee0d91378128dc9608b3a4f997e8a98b9712412fc89
7
+ data.tar.gz: 541b3da611c70fdd1ec02b97dd9c8fad76d30f20e124d38800896d57b431a099c148d3635193ebef28aaae6d70b303989e41380abeb6c4e489820cd2fc1d6d1c
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgUi
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.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