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,50 @@
|
|
|
1
|
+
# Generates data/ag_ui.json from the reference Python SDK's pydantic models
|
|
2
|
+
# (clone github.com/ag-ui-protocol/ag-ui and point AG_UI_SDK at sdks/python).
|
|
3
|
+
#
|
|
4
|
+
# Run: uv run --with pydantic data/generate-ag-ui-schema.py [/path/to/ag-ui/sdks/python]
|
|
5
|
+
#
|
|
6
|
+
# Output shape mirrors a2a's data/a2a.json: a single top-level "definitions"
|
|
7
|
+
# map keyed by model class name, refs rewritten to #/definitions/<Model>.
|
|
8
|
+
# Wire keys are camelCase (by_alias), matching @ag-ui/core exactly.
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sdk = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("AG_UI_SDK", "/tmp/ag-ui/sdks/python")
|
|
16
|
+
sys.path.insert(0, sdk)
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel # noqa: E402
|
|
19
|
+
from pydantic.json_schema import models_json_schema # noqa: E402
|
|
20
|
+
|
|
21
|
+
from ag_ui.core import capabilities, events, types # noqa: E402
|
|
22
|
+
|
|
23
|
+
models = []
|
|
24
|
+
for mod in (types, events, capabilities):
|
|
25
|
+
for _name, obj in sorted(vars(mod).items()):
|
|
26
|
+
if (
|
|
27
|
+
inspect.isclass(obj)
|
|
28
|
+
and issubclass(obj, BaseModel)
|
|
29
|
+
and obj.__module__ == mod.__name__
|
|
30
|
+
and obj.__name__ != "ConfiguredBaseModel"
|
|
31
|
+
):
|
|
32
|
+
models.append(obj)
|
|
33
|
+
|
|
34
|
+
_, top = models_json_schema(
|
|
35
|
+
[(m, "validation") for m in models],
|
|
36
|
+
by_alias=True,
|
|
37
|
+
ref_template="#/definitions/{model}",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
out = {
|
|
41
|
+
"$comment": f"Generated by data/generate-ag-ui-schema.py from ag-ui sdks/python ({len(models)} models). Do not edit.",
|
|
42
|
+
"definitions": top["$defs"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ag_ui.json")
|
|
46
|
+
with open(dest, "w") as f:
|
|
47
|
+
json.dump(out, f, indent=2, sort_keys=True)
|
|
48
|
+
f.write("\n")
|
|
49
|
+
|
|
50
|
+
print(f"wrote {dest}: {len(out['definitions'])} definitions")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "ag_ui"
|
|
7
|
+
|
|
8
|
+
module AgUi
|
|
9
|
+
module A2ui
|
|
10
|
+
# The A2UI component catalog — the app's own UI vocabulary, served by
|
|
11
|
+
# the frontend (e.g. GET /api/copilotkit/catalog) and fetched once at
|
|
12
|
+
# boot, mirroring the Node sidecar (doc 09 §5): 20 retries x 3s, and
|
|
13
|
+
# on total failure A2UI DEGRADES (tool still injected, no schema)
|
|
14
|
+
# rather than failing the boot.
|
|
15
|
+
#
|
|
16
|
+
# catalog = AgUi::A2ui::Catalog.fetch(url: ENV["AI_CATALOG_URL"])
|
|
17
|
+
# catalog&.catalog_id #=> "host://ai-catalog"
|
|
18
|
+
#
|
|
19
|
+
Catalog = Data.define(:catalog_id, :components) do
|
|
20
|
+
# Wire shape: { "catalogId" => "...", "components" => {...} }
|
|
21
|
+
def self.from_wire(data)
|
|
22
|
+
unless data.is_a?(Hash) && data["catalogId"] && data["components"]
|
|
23
|
+
raise ArgumentError, "malformed catalog: expected catalogId + components"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new(catalog_id: data["catalogId"], components: data["components"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.fetch(url:, retries: 20, interval: 3, http: nil, logger: Console)
|
|
30
|
+
http ||= ->(u) { Net::HTTP.get_response(URI(u)) }
|
|
31
|
+
|
|
32
|
+
attempt = 0
|
|
33
|
+
loop do
|
|
34
|
+
attempt += 1
|
|
35
|
+
begin
|
|
36
|
+
response = http.call(url)
|
|
37
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
raise "HTTP #{response.code}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
catalog = from_wire(JSON.parse(response.body))
|
|
42
|
+
logger.info(
|
|
43
|
+
self,
|
|
44
|
+
"loaded A2UI catalog #{catalog.catalog_id} " \
|
|
45
|
+
"(#{catalog.components.length} components) from #{url}",
|
|
46
|
+
)
|
|
47
|
+
break catalog
|
|
48
|
+
rescue => e
|
|
49
|
+
if attempt >= retries
|
|
50
|
+
logger.warn(
|
|
51
|
+
self,
|
|
52
|
+
"A2UI catalog unreachable after #{attempt} attempts " \
|
|
53
|
+
"(#{e.message}) — degrading (tool without schema)",
|
|
54
|
+
)
|
|
55
|
+
break nil
|
|
56
|
+
end
|
|
57
|
+
sleep interval
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
__END__
|
|
66
|
+
|
|
67
|
+
describe "AgUi::A2ui::Catalog" do
|
|
68
|
+
ok_body = JSON.generate({
|
|
69
|
+
"catalogId" => "host://ai-catalog",
|
|
70
|
+
"components" => { "Card" => { "description" => "A card", "props" => {} } },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
fake_response = Struct.new(:body, :code) do
|
|
74
|
+
def is_a?(klass) = klass == Net::HTTPSuccess || super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "parses the wire shape" do
|
|
78
|
+
catalog = AgUi::A2ui::Catalog.from_wire(JSON.parse(ok_body))
|
|
79
|
+
catalog.catalog_id.should == "host://ai-catalog"
|
|
80
|
+
catalog.components.keys.should == ["Card"]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "rejects malformed catalogs" do
|
|
84
|
+
lambda { AgUi::A2ui::Catalog.from_wire({ "nope" => 1 }) }.should.raise(ArgumentError)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "fetches with retries and returns the catalog" do
|
|
88
|
+
calls = 0
|
|
89
|
+
http = ->(_url) do
|
|
90
|
+
calls += 1
|
|
91
|
+
raise "conn refused" if calls < 3
|
|
92
|
+
|
|
93
|
+
fake_response.new(ok_body, "200")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
catalog = AgUi::A2ui::Catalog.fetch(url: "http://x/catalog", retries: 5, interval: 0, http: http)
|
|
97
|
+
catalog.catalog_id.should == "host://ai-catalog"
|
|
98
|
+
calls.should == 3
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "degrades to nil after exhausting retries" do
|
|
102
|
+
http = ->(_url) { raise "conn refused" }
|
|
103
|
+
AgUi::A2ui::Catalog.fetch(url: "http://x/catalog", retries: 2, interval: 0, http: http)
|
|
104
|
+
.should.be.nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
module A2ui
|
|
8
|
+
# A2UI error-recovery loop — Ruby port of the toolkit's recovery.py.
|
|
9
|
+
#
|
|
10
|
+
# The toolkit cannot bind/invoke a model, so the adapter supplies
|
|
11
|
+
# `invoke_subagent` (its model call, receives (prompt, attempt) and
|
|
12
|
+
# returns the render_a2ui args hash or nil) and `build_envelope` (its
|
|
13
|
+
# prepared operations envelope). This module owns the validate→retry
|
|
14
|
+
# loop using the SAME validate_components the middleware uses, so the
|
|
15
|
+
# retry decision and the paint decision agree.
|
|
16
|
+
module Recovery
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Default attempt cap (initial try + retries). Configurable per call.
|
|
20
|
+
MAX_A2UI_ATTEMPTS = 3
|
|
21
|
+
|
|
22
|
+
# Activity type the middleware/client use for the recovery status channel.
|
|
23
|
+
A2UI_RECOVERY_ACTIVITY_TYPE = "a2ui_recovery"
|
|
24
|
+
|
|
25
|
+
NO_TOOL_CALL_ERROR = {
|
|
26
|
+
"code" => "empty_components",
|
|
27
|
+
"path" => "components",
|
|
28
|
+
"message" => "Sub-agent did not call render_a2ui",
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Render structured errors as a compact, model-readable list.
|
|
32
|
+
def format_validation_errors(errors)
|
|
33
|
+
errors.map { |e| "- [#{e["code"]}] #{e["path"]}: #{e["message"]}" }.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Append a fix-it block describing the prior attempt's errors.
|
|
37
|
+
def augment_prompt_with_validation_errors(prompt, errors)
|
|
38
|
+
if errors.empty?
|
|
39
|
+
prompt
|
|
40
|
+
else
|
|
41
|
+
"#{prompt}\n\n## Previous attempt was invalid — fix these and regenerate:\n" \
|
|
42
|
+
"#{format_validation_errors(errors)}\n"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Drive the validate→retry loop. Returns {"envelope", "attempts",
|
|
47
|
+
# "ok"}: the validated envelope on success, or a structured
|
|
48
|
+
# a2ui_recovery_exhausted envelope once the cap is hit. Never retries
|
|
49
|
+
# an attempt whose components validated.
|
|
50
|
+
def run_generation_with_recovery(base_prompt:, invoke_subagent:, build_envelope:,
|
|
51
|
+
catalog: nil, config: nil, on_attempt: nil)
|
|
52
|
+
max_attempts = (config || {})["maxAttempts"] || MAX_A2UI_ATTEMPTS
|
|
53
|
+
attempts = []
|
|
54
|
+
last_errors = []
|
|
55
|
+
|
|
56
|
+
(1..max_attempts).each do |attempt|
|
|
57
|
+
prompt = augment_prompt_with_validation_errors(base_prompt, last_errors)
|
|
58
|
+
args = invoke_subagent.call(prompt, attempt)
|
|
59
|
+
|
|
60
|
+
record =
|
|
61
|
+
if args.nil? || args.empty?
|
|
62
|
+
{ "attempt" => attempt, "ok" => false, "errors" => [NO_TOOL_CALL_ERROR] }
|
|
63
|
+
else
|
|
64
|
+
components = args["components"].is_a?(Array) ? args["components"] : []
|
|
65
|
+
data = args["data"].is_a?(Hash) ? args["data"] : {}
|
|
66
|
+
result = Validate.validate_components(components: components, data: data, catalog: catalog)
|
|
67
|
+
{ "attempt" => attempt, "ok" => result["valid"], "errors" => result["errors"] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
attempts << record
|
|
71
|
+
on_attempt&.call(record)
|
|
72
|
+
|
|
73
|
+
if record["ok"]
|
|
74
|
+
break { "envelope" => build_envelope.call(args), "attempts" => attempts, "ok" => true }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
last_errors = record["errors"]
|
|
78
|
+
end.then do |result|
|
|
79
|
+
if result.is_a?(Hash)
|
|
80
|
+
result
|
|
81
|
+
else
|
|
82
|
+
{
|
|
83
|
+
"envelope" => exhausted_envelope(max_attempts, attempts),
|
|
84
|
+
"attempts" => attempts,
|
|
85
|
+
"ok" => false,
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def exhausted_envelope(max_attempts, attempts)
|
|
92
|
+
JSON.generate(
|
|
93
|
+
{
|
|
94
|
+
"error" => "Failed to generate valid A2UI after #{max_attempts} attempt(s)",
|
|
95
|
+
"code" => "a2ui_recovery_exhausted",
|
|
96
|
+
"attempts" => attempts,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
__END__
|
|
105
|
+
|
|
106
|
+
describe "AgUi::A2ui::Recovery" do
|
|
107
|
+
# Fixtures ported verbatim from the toolkit's test_recovery.py.
|
|
108
|
+
recovery = AgUi::A2ui::Recovery
|
|
109
|
+
|
|
110
|
+
catalog = { "components" => {
|
|
111
|
+
"Row" => { "required" => ["children"] },
|
|
112
|
+
"HotelCard" => { "required" => %w[name rating] },
|
|
113
|
+
} }
|
|
114
|
+
|
|
115
|
+
root = { "id" => "root", "component" => "Row",
|
|
116
|
+
"children" => { "componentId" => "card", "path" => "/items" } }
|
|
117
|
+
good_card = { "id" => "card", "component" => "HotelCard",
|
|
118
|
+
"name" => { "path" => "name" }, "rating" => { "path" => "rating" } }
|
|
119
|
+
bad_card = { "id" => "card", "component" => "HotelCard", "name" => { "path" => "name" } }
|
|
120
|
+
|
|
121
|
+
good_args = { "surfaceId" => "s1", "components" => [root, good_card],
|
|
122
|
+
"data" => { "items" => [{ "name" => "Ritz", "rating" => 4.8 }] } }
|
|
123
|
+
bad_args = { "surfaceId" => "s1", "components" => [root, bad_card],
|
|
124
|
+
"data" => { "items" => [{ "name" => "Ritz", "rating" => 4.8 }] } }
|
|
125
|
+
|
|
126
|
+
build_envelope = ->(args) { JSON.generate({ "a2ui_operations" => args["components"] }) }
|
|
127
|
+
|
|
128
|
+
it "exposes the upstream defaults" do
|
|
129
|
+
recovery::MAX_A2UI_ATTEMPTS.should == 3
|
|
130
|
+
recovery::A2UI_RECOVERY_ACTIVITY_TYPE.should == "a2ui_recovery"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "augments prompts with a fix-it block only when there are errors" do
|
|
134
|
+
errors = [{ "code" => "missing_required_prop", "path" => "components[1].rating",
|
|
135
|
+
"message" => "missing required prop 'rating'" }]
|
|
136
|
+
|
|
137
|
+
recovery.augment_prompt_with_validation_errors("BASE", []).should == "BASE"
|
|
138
|
+
|
|
139
|
+
out = recovery.augment_prompt_with_validation_errors("BASE", errors)
|
|
140
|
+
out.should.include?("BASE")
|
|
141
|
+
out.should.include?("rating")
|
|
142
|
+
out.should.include?(recovery.format_validation_errors(errors))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "returns the envelope on a valid first attempt" do
|
|
146
|
+
calls = []
|
|
147
|
+
result = recovery.run_generation_with_recovery(
|
|
148
|
+
base_prompt: "P", catalog: catalog,
|
|
149
|
+
invoke_subagent: ->(_prompt, attempt) { calls << attempt; good_args },
|
|
150
|
+
build_envelope: build_envelope,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
result["ok"].should == true
|
|
154
|
+
result["attempts"].length.should == 1
|
|
155
|
+
calls.should == [1]
|
|
156
|
+
JSON.parse(result["envelope"]).key?("a2ui_operations").should == true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "recovers on the second attempt with error feedback in the prompt" do
|
|
160
|
+
prompts = []
|
|
161
|
+
result = recovery.run_generation_with_recovery(
|
|
162
|
+
base_prompt: "P", catalog: catalog,
|
|
163
|
+
invoke_subagent: ->(prompt, attempt) { prompts << prompt; attempt == 1 ? bad_args : good_args },
|
|
164
|
+
build_envelope: build_envelope,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
result["ok"].should == true
|
|
168
|
+
result["attempts"].length.should == 2
|
|
169
|
+
result["attempts"][0]["ok"].should == false
|
|
170
|
+
result["attempts"][1]["ok"].should == true
|
|
171
|
+
prompts[1].should.include?("rating")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "returns the exhausted envelope after the attempt cap" do
|
|
175
|
+
seen = []
|
|
176
|
+
result = recovery.run_generation_with_recovery(
|
|
177
|
+
base_prompt: "P", catalog: catalog,
|
|
178
|
+
invoke_subagent: ->(_p, _a) { bad_args },
|
|
179
|
+
build_envelope: build_envelope,
|
|
180
|
+
on_attempt: ->(record) { seen << record },
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
result["ok"].should == false
|
|
184
|
+
result["attempts"].length.should == recovery::MAX_A2UI_ATTEMPTS
|
|
185
|
+
seen.length.should == recovery::MAX_A2UI_ATTEMPTS
|
|
186
|
+
|
|
187
|
+
parsed = JSON.parse(result["envelope"])
|
|
188
|
+
parsed["code"].should == "a2ui_recovery_exhausted"
|
|
189
|
+
parsed["error"].to_s.empty?.should == false
|
|
190
|
+
parsed["attempts"].should.be.kind_of(Array)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "honours a maxAttempts override" do
|
|
194
|
+
calls = []
|
|
195
|
+
result = recovery.run_generation_with_recovery(
|
|
196
|
+
base_prompt: "P", catalog: catalog, config: { "maxAttempts" => 2 },
|
|
197
|
+
invoke_subagent: ->(_p, attempt) { calls << attempt; bad_args },
|
|
198
|
+
build_envelope: build_envelope,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
result["ok"].should == false
|
|
202
|
+
calls.length.should == 2
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "treats a missing tool call as retryable" do
|
|
206
|
+
result = recovery.run_generation_with_recovery(
|
|
207
|
+
base_prompt: "P", catalog: catalog,
|
|
208
|
+
invoke_subagent: ->(_p, attempt) { attempt == 1 ? nil : good_args },
|
|
209
|
+
build_envelope: build_envelope,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
result["ok"].should == true
|
|
213
|
+
result["attempts"].length.should == 2
|
|
214
|
+
result["attempts"][0]["ok"].should == false
|
|
215
|
+
end
|
|
216
|
+
end
|