ag-ui 0.1.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.
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "securerandom"
5
+ require "ag_ui"
6
+
7
+ module AgUi
8
+ module Middleware
9
+ # A2UI (Tier-3 generative UI) — the Ruby port of CopilotKit's
10
+ # @ag-ui/a2ui-middleware transform (doc 09 §6).
11
+ #
12
+ # Way in:
13
+ # - injects the `render_a2ui` tool into env[:tools] (STATIC schema —
14
+ # the catalog does NOT shape it)
15
+ # - appends the catalog's component schema to the conversation as a
16
+ # system message (that's how the model learns the vocabulary)
17
+ #
18
+ # Way out, for each `render_a2ui` call the model made:
19
+ # - converts the call args into an ACTIVITY_SNAPSHOT carrying
20
+ # `a2ui_operations` (createSurface once per surface, then
21
+ # updateComponents / updateDataModel), flat wire shape,
22
+ # activityType "a2ui-surface", replace: true
23
+ # - emits a synthetic TOOL_CALL_RESULT ({"status":"rendered"}) so the
24
+ # next run's history shows the model its call completed
25
+ #
26
+ # The TOOL_CALL_* events themselves pass through (ToolRouter emits
27
+ # them) and the run still ends — same multi-run model as client tools;
28
+ # the activity events are what the canvas renders.
29
+ #
30
+ # Sits OUTSIDE ToolRouter in the pipeline:
31
+ # use SystemPrompt; use A2ui, catalog: catalog; use ToolRouter
32
+ class A2ui
33
+ TOOL_NAME = "render_a2ui"
34
+
35
+ TOOL_DEFINITION = {
36
+ "name" => TOOL_NAME,
37
+ "description" =>
38
+ "Render a dynamic A2UI v0.9 surface with structured parameters. " \
39
+ "Follow the A2UI render tool usage guide provided in context.",
40
+ "parameters" => {
41
+ "type" => "object",
42
+ "properties" => {
43
+ "surfaceId" => {
44
+ "type" => "string",
45
+ "description" => "Unique surface identifier.",
46
+ },
47
+ "components" => {
48
+ "type" => "array",
49
+ "description" =>
50
+ "A2UI v0.9 component array (flat format). The root component " \
51
+ "must have id \"root\".",
52
+ "items" => { "type" => "object" },
53
+ },
54
+ "data" => {
55
+ "type" => "object",
56
+ "description" =>
57
+ "Initial data model for the surface. Written to the root path. " \
58
+ "Use for pre-filling form values (e.g. {\"form\": {\"name\": \"Alice\"}}) " \
59
+ "or providing data for components bound to data model paths.",
60
+ },
61
+ },
62
+ "required" => %w[surfaceId components],
63
+ },
64
+ }.freeze
65
+
66
+ SCHEMA_CONTEXT_PREAMBLE =
67
+ "A2UI Component Schema — available components for generating UI " \
68
+ "surfaces. Use these component names and properties when creating " \
69
+ "A2UI operations."
70
+
71
+ BASIC_CATALOG_ID = "https://a2ui.org/specification/v0_9/basic_catalog.json"
72
+
73
+ def initialize(app, catalog: nil, default_catalog_id: nil)
74
+ @app = app
75
+ @catalog = catalog
76
+ @default_catalog_id = default_catalog_id || catalog&.catalog_id
77
+ end
78
+
79
+ # Attempt cap for in-run regeneration (initial try + retries),
80
+ # matching the toolkit recovery default.
81
+ MAX_ATTEMPTS = ::AgUi::A2ui::Recovery::MAX_A2UI_ATTEMPTS
82
+
83
+ def call(env)
84
+ advertise(env)
85
+ inject_catalog_schema(env)
86
+
87
+ before = env[:messages].length
88
+ @app.call(env)
89
+
90
+ # Only this iteration's assistant message — seeded history can
91
+ # carry old tool-call turns that must not re-render.
92
+ appended = env[:messages][before..] || []
93
+ assistant = appended.reverse.find { |m| m.respond_to?(:tool_call?) && m.tool_call? }
94
+ if assistant
95
+ assistant.tool_calls.each do |tool_call|
96
+ if tool_call.name == TOOL_NAME
97
+ render(env, tool_call)
98
+ end
99
+ end
100
+ end
101
+
102
+ env
103
+ end
104
+
105
+ private
106
+
107
+ # Idempotent — the turn loop re-enters every iteration.
108
+ def advertise(env)
109
+ env[:tools] ||= []
110
+ unless env[:tools].any? { |t| t.is_a?(Hash) && t["name"] == TOOL_NAME }
111
+ env[:tools] << TOOL_DEFINITION
112
+ end
113
+ end
114
+
115
+ # A catalog can carry an id without component schemas (the app may
116
+ # teach the model its vocabulary through the system prompt instead)
117
+ # — only inject and validate against components when they exist.
118
+ def catalog_components?
119
+ @catalog && !@catalog.components.to_h.empty?
120
+ end
121
+
122
+ def inject_catalog_schema(env)
123
+ if catalog_components? && !metadata(env)[:a2ui_schema_injected]
124
+ env[:messages].unshift(
125
+ Brute::Message.new(
126
+ role: :system,
127
+ content: "#{SCHEMA_CONTEXT_PREAMBLE}\n#{JSON.generate(@catalog.components)}",
128
+ ),
129
+ )
130
+ metadata(env)[:a2ui_schema_injected] = true
131
+ end
132
+ end
133
+
134
+ def metadata(env)
135
+ env[:metadata] ||= {}
136
+ end
137
+
138
+ # Components are validated with the ported a2ui_toolkit semantics
139
+ # (catalog membership, required props, child refs, cycles).
140
+ # Bindings are NOT validated here — same as the Node middleware
141
+ # (validateBindings: false): relative template paths resolve
142
+ # per-item at render time and would false-positive.
143
+ #
144
+ # Failures regenerate IN-RUN: the structured errors are appended
145
+ # as the tool result and env[:should_exit] is cleared so
146
+ # Loop::ToolResult re-invokes the stack — status "retrying" on the
147
+ # wire, "failed" once MAX_ATTEMPTS is exhausted (Node-middleware
148
+ # status progression).
149
+ def render(env, tool_call)
150
+ args = tool_call.arguments
151
+ surface_id = args["surfaceId"].to_s
152
+
153
+ validation = ::AgUi::A2ui.validate_components(
154
+ components: args["components"],
155
+ data: args["data"].is_a?(Hash) ? args["data"] : {},
156
+ catalog: catalog_components? ? { "components" => @catalog.components } : nil,
157
+ validate_bindings: false,
158
+ )
159
+
160
+ if surface_id.empty? || !validation["valid"]
161
+ render_failure(env, tool_call, validation["errors"])
162
+ else
163
+ ops = operations(args, surface_id, emitted_surfaces(env))
164
+ env[:events] << activity(tool_call, { "a2ui_operations" => ops })
165
+ env[:events] << result(tool_call, { "status" => "rendered" })
166
+ end
167
+ end
168
+
169
+ def render_failure(env, tool_call, errors)
170
+ attempt = (metadata(env)[:a2ui_attempts] || 0) + 1
171
+ metadata(env)[:a2ui_attempts] = attempt
172
+ final = attempt >= MAX_ATTEMPTS
173
+
174
+ env[:events] << activity(
175
+ tool_call,
176
+ {
177
+ "status" => final ? "failed" : "retrying",
178
+ "attempt" => attempt,
179
+ "maxAttempts" => MAX_ATTEMPTS,
180
+ "errors" => errors,
181
+ },
182
+ )
183
+
184
+ content = { "status" => "failed", "errors" => errors }
185
+ env[:events] << result(tool_call, content)
186
+ env[:messages].tool(JSON.generate(content), tool_call_id: tool_call.id)
187
+
188
+ unless final
189
+ env[:should_exit] = false
190
+ end
191
+ end
192
+
193
+ def emitted_surfaces(env)
194
+ metadata(env)[:a2ui_surfaces] ||= Set.new
195
+ end
196
+
197
+ # createSurface once per surface within the turn; updateComponents
198
+ # always; updateDataModel when initial data was given.
199
+ def operations(args, surface_id, emitted_surfaces)
200
+ ops = []
201
+
202
+ if emitted_surfaces.add?(surface_id)
203
+ ops << {
204
+ "version" => "v0.9",
205
+ "createSurface" => { "surfaceId" => surface_id, "catalogId" => catalog_id },
206
+ }
207
+ end
208
+
209
+ ops << {
210
+ "version" => "v0.9",
211
+ "updateComponents" => { "surfaceId" => surface_id, "components" => args["components"] },
212
+ }
213
+
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
220
+
221
+ ops
222
+ end
223
+
224
+ def catalog_id
225
+ @default_catalog_id || BASIC_CATALOG_ID
226
+ end
227
+
228
+ def activity(tool_call, content)
229
+ {
230
+ type: :activity_snapshot,
231
+ data: {
232
+ message_id: "a2ui-surface-#{tool_call.id}",
233
+ activity_type: "a2ui-surface",
234
+ content: content,
235
+ replace: true,
236
+ },
237
+ }
238
+ end
239
+
240
+ def result(tool_call, content)
241
+ {
242
+ type: :tool_call_result,
243
+ data: {
244
+ message_id: SecureRandom.uuid,
245
+ tool_call_id: tool_call.id,
246
+ content: JSON.generate(content),
247
+ },
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ __END__
255
+
256
+ describe "AgUi::Middleware::A2ui" do
257
+ catalog = AgUi::A2ui::Catalog.new(
258
+ catalog_id: "host://ai-catalog",
259
+ components: { "Card" => { "description" => "A card", "props" => {} } },
260
+ )
261
+
262
+ render_call = ->(args) do
263
+ Brute::Message.new(
264
+ role: :assistant, content: nil,
265
+ tool_calls: [{ id: "tc1", name: "render_a2ui", arguments: args }],
266
+ )
267
+ end
268
+
269
+ it "injects the render_a2ui tool and the catalog schema system message" do
270
+ seen = nil
271
+ mw = AgUi::Middleware::A2ui.new(->(env) { seen = env }, catalog: catalog)
272
+ mw.call({ messages: Brute.log, events: [], tools: [{ "name" => "navigate" }] })
273
+
274
+ seen[:tools].map { |t| t["name"] }.should == %w[navigate render_a2ui]
275
+ tool = seen[:tools].last
276
+ tool["parameters"]["required"].should == %w[surfaceId components]
277
+
278
+ schema_msg = seen[:messages].first
279
+ schema_msg.role.should == :system
280
+ schema_msg.content.should.include?("A2UI Component Schema")
281
+ schema_msg.content.should.include?("Card")
282
+ end
283
+
284
+ it "keeps an id-only catalog prompt-driven: no schema message, structural validation" do
285
+ id_only = AgUi::A2ui::Catalog.new(catalog_id: "app://cat", components: {})
286
+ seen = nil
287
+ terminal = ->(env) do
288
+ seen = env
289
+ env[:messages] << render_call.(
290
+ "surfaceId" => "s1",
291
+ "components" => [{ "id" => "root", "component" => "AnythingGoes" }],
292
+ )
293
+ end
294
+
295
+ env = { messages: Brute.log, events: [], tools: [] }
296
+ AgUi::Middleware::A2ui.new(terminal, catalog: id_only).call(env)
297
+
298
+ seen[:messages].first.role.should == :assistant # no schema system message
299
+ env[:events][0][:data][:content].key?("a2ui_operations").should == true
300
+ env[:events][0][:data][:content]["a2ui_operations"][0]["createSurface"]["catalogId"]
301
+ .should == "app://cat"
302
+ end
303
+
304
+ it "injects the tool without a schema message when degraded (no catalog)" do
305
+ seen = nil
306
+ AgUi::Middleware::A2ui.new(->(env) { seen = env })
307
+ .call({ messages: Brute.log, events: [], tools: [] })
308
+
309
+ seen[:tools].map { |t| t["name"] }.should == ["render_a2ui"]
310
+ seen[:messages].should == []
311
+ end
312
+
313
+ it "converts a render_a2ui call into activity + synthetic rendered result" do
314
+ terminal = ->(env) do
315
+ env[:messages] << render_call.(
316
+ "surfaceId" => "s1",
317
+ "components" => [{ "id" => "root", "component" => "Card" }],
318
+ "data" => { "title" => "hi" },
319
+ )
320
+ end
321
+
322
+ env = { messages: Brute.log, events: [], tools: [] }
323
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
324
+
325
+ activity = env[:events][0]
326
+ activity[:type].should == :activity_snapshot
327
+ activity[:data][:message_id].should == "a2ui-surface-tc1"
328
+ activity[:data][:activity_type].should == "a2ui-surface"
329
+ activity[:data][:replace].should == true
330
+
331
+ ops = activity[:data][:content]["a2ui_operations"]
332
+ ops[0]["createSurface"].should == { "surfaceId" => "s1", "catalogId" => "host://ai-catalog" }
333
+ ops[1]["updateComponents"]["components"].first["id"].should == "root"
334
+ ops[2]["updateDataModel"].should == { "surfaceId" => "s1", "path" => "/", "value" => { "title" => "hi" } }
335
+
336
+ result = env[:events][1]
337
+ result[:type].should == :tool_call_result
338
+ result[:data][:tool_call_id].should == "tc1"
339
+ result[:data][:content].should == "{\"status\":\"rendered\"}"
340
+ end
341
+
342
+ minimal_components = [{ "id" => "root", "component" => "Card" }]
343
+
344
+ it "falls back to the basic catalog id when degraded" do
345
+ terminal = ->(env) do
346
+ env[:messages] << render_call.("surfaceId" => "s1", "components" => minimal_components)
347
+ end
348
+
349
+ env = { messages: Brute.log, events: [], tools: [] }
350
+ AgUi::Middleware::A2ui.new(terminal).call(env)
351
+
352
+ ops = env[:events][0][:data][:content]["a2ui_operations"]
353
+ ops[0]["createSurface"]["catalogId"].should ==
354
+ "https://a2ui.org/specification/v0_9/basic_catalog.json"
355
+ end
356
+
357
+ it "creates each surface once but updates on every call" do
358
+ terminal = ->(env) do
359
+ env[:messages] << Brute::Message.new(
360
+ role: :assistant, content: nil,
361
+ tool_calls: [
362
+ { id: "tc1", name: "render_a2ui",
363
+ arguments: { "surfaceId" => "s1", "components" => minimal_components } },
364
+ { id: "tc2", name: "render_a2ui",
365
+ arguments: { "surfaceId" => "s1", "components" => minimal_components } },
366
+ ],
367
+ )
368
+ end
369
+
370
+ env = { messages: Brute.log, events: [], tools: [] }
371
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
372
+
373
+ activities = env[:events].select { |e| e[:type] == :activity_snapshot }
374
+ first_ops = activities[0][:data][:content]["a2ui_operations"]
375
+ second_ops = activities[1][:data][:content]["a2ui_operations"]
376
+
377
+ first_ops.map(&:keys).flatten.should.include?("createSurface")
378
+ second_ops.map(&:keys).flatten.should.not.include?("createSurface")
379
+ end
380
+
381
+ it "ignores non-a2ui tool calls; malformed render args enter the retry path" do
382
+ terminal = ->(env) do
383
+ env[:messages] << Brute::Message.new(
384
+ role: :assistant, content: nil,
385
+ tool_calls: [
386
+ { id: "tc1", name: "navigate", arguments: { "path" => "/x" } },
387
+ { id: "tc2", name: "render_a2ui", arguments: { "surfaceId" => "s1" } },
388
+ ],
389
+ )
390
+ end
391
+
392
+ env = { messages: Brute.log, events: [], tools: [], should_exit: true }
393
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
394
+
395
+ env[:events].length.should == 2
396
+ env[:events][0][:data][:content]["status"].should == "retrying"
397
+ JSON.parse(env[:events][1][:data][:content])["status"].should == "failed"
398
+ env[:messages].last.role.should == :tool
399
+ env[:messages].last.tool_call_id.should == "tc2"
400
+ env[:should_exit].should == false
401
+ end
402
+
403
+ it "rejects invalid trees with structured errors, failing hard at the attempt cap" do
404
+ invalid = ->(id) do
405
+ Brute::Message.new(
406
+ role: :assistant, content: nil,
407
+ tool_calls: [{ id: id, name: "render_a2ui", arguments: {
408
+ "surfaceId" => "s1",
409
+ "components" => [{ "id" => "root", "component" => "Mystery", "children" => ["ghost"] }],
410
+ } }],
411
+ )
412
+ end
413
+
414
+ calls = 0
415
+ terminal = ->(env) do
416
+ calls += 1
417
+ env[:messages] << invalid.("tc#{calls}")
418
+ end
419
+
420
+ env = { messages: Brute.log, events: [], tools: [], metadata: {} }
421
+ mw = AgUi::Middleware::A2ui.new(terminal, catalog: catalog)
422
+
423
+ mw.call(env)
424
+ first = env[:events][0][:data][:content]
425
+ first["status"].should == "retrying"
426
+ first["attempt"].should == 1
427
+ codes = first["errors"].map { |e| e["code"] }
428
+ codes.should.include?("unknown_component")
429
+ codes.should.include?("unresolved_child")
430
+ env[:should_exit].should == false
431
+
432
+ env[:should_exit] = true
433
+ mw.call(env)
434
+ env[:should_exit] = true
435
+ mw.call(env)
436
+
437
+ statuses = env[:events].select { |e| e[:type] == :activity_snapshot }
438
+ .map { |e| e[:data][:content]["status"] }
439
+ statuses.should == %w[retrying retrying failed]
440
+ env[:should_exit].should == true
441
+ end
442
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ module Middleware
8
+ # Exposes RunAgentInput.forwardedProps on the turn env so downstream
9
+ # middleware and the terminal can honor client-driven knobs — most
10
+ # importantly `toolChoice` ({type: "function", function: {name}}),
11
+ # which the suggestions engine uses to force `copilotkitSuggest`.
12
+ class ForwardedProps
13
+ def initialize(app, props: nil)
14
+ @app = app
15
+ @props = props
16
+ end
17
+
18
+ def call(env)
19
+ env[:forwarded_props] = @props
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ __END__
27
+
28
+ describe "AgUi::Middleware::ForwardedProps" do
29
+ it "exposes the props on env" do
30
+ seen = nil
31
+ mw = AgUi::Middleware::ForwardedProps.new(
32
+ ->(env) { seen = env[:forwarded_props] },
33
+ props: { "toolChoice" => { "function" => { "name" => "copilotkitSuggest" } } },
34
+ )
35
+ mw.call({})
36
+ seen.dig("toolChoice", "function", "name").should == "copilotkitSuggest"
37
+ end
38
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ module Middleware
8
+ # Brute-style turn middleware: prepends the agent's system prompt,
9
+ # with the run's RunAgentInput.context entries appended as a context
10
+ # addendum (useAgentContext — how the agent "sees" the current page).
11
+ #
12
+ # Skips entirely when the history already carries a system message
13
+ # (the client can send its own via system/developer roles).
14
+ class SystemPrompt
15
+ def initialize(app, prompt: nil, context: nil)
16
+ @app = app
17
+ @prompt = prompt
18
+ @context = context
19
+ end
20
+
21
+ def call(env)
22
+ unless env[:messages].any? { |m| m.role == :system }
23
+ content = [@prompt, context_addendum].compact.join("\n\n")
24
+ unless content.empty?
25
+ env[:messages].unshift(Brute::Message.new(role: :system, content: content))
26
+ end
27
+ end
28
+
29
+ @app.call(env)
30
+ end
31
+
32
+ private
33
+
34
+ def context_addendum(context = @context)
35
+ if context.nil? || context.empty?
36
+ nil
37
+ else
38
+ lines = context.map { |entry| "- #{entry.description}: #{entry.value}" }
39
+ "Context shared by the application:\n#{lines.join("\n")}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ __END__
47
+
48
+ describe "AgUi::Middleware::SystemPrompt" do
49
+ terminal = ->(env) { env }
50
+
51
+ it "prepends the prompt as a system message" do
52
+ mw = AgUi::Middleware::SystemPrompt.new(terminal, prompt: "Be terse.")
53
+ env = { messages: Brute.log.tap { |l| l.user("hi") } }
54
+ mw.call(env)
55
+
56
+ env[:messages].first.role.should == :system
57
+ env[:messages].first.content.should == "Be terse."
58
+ end
59
+
60
+ it "appends RunAgentInput.context entries as an addendum" do
61
+ context = [
62
+ AgUi::Protocol::JsonSchema["Context"].new(description: "currentPath", value: "/data"),
63
+ AgUi::Protocol::JsonSchema["Context"].new(description: "user", value: "nathan"),
64
+ ]
65
+ mw = AgUi::Middleware::SystemPrompt.new(terminal, prompt: "Be terse.", context: context)
66
+ env = { messages: Brute.log }
67
+ mw.call(env)
68
+
69
+ env[:messages].first.content.should.include?("Be terse.")
70
+ env[:messages].first.content.should.include?("- currentPath: /data")
71
+ env[:messages].first.content.should.include?("- user: nathan")
72
+ end
73
+
74
+ it "leaves history alone when a system message already exists" do
75
+ mw = AgUi::Middleware::SystemPrompt.new(terminal, prompt: "Be terse.")
76
+ env = { messages: Brute.log.tap { |l| l.system("existing") } }
77
+ mw.call(env)
78
+
79
+ env[:messages].length.should == 1
80
+ env[:messages].first.content.should == "existing"
81
+ end
82
+
83
+ it "adds nothing when there is no prompt and no context" do
84
+ mw = AgUi::Middleware::SystemPrompt.new(terminal)
85
+ env = { messages: Brute.log }
86
+ mw.call(env)
87
+
88
+ env[:messages].should == []
89
+ end
90
+ end