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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
|
@@ -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
|