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,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