poetry-agent 0.0.2

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. metadata +117 -0
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require_relative "protocol"
6
+ require_relative "functions"
7
+
8
+ module Poetry
9
+ module Agent
10
+ module A2UI
11
+ # Projects the component registry into an A2UI v1.0 catalog: one
12
+ # JSON Schema component per registry entry (the discriminator
13
+ # `component: { const: Name }`, style axes as enums, options as typed
14
+ # properties, slots as child references, the content block as `text`
15
+ # or `children`, Button's `action`), the catalog's composition
16
+ # instructions, and the `$defs` the envelope schema references. The
17
+ # document obeys the v1.0 catalog rules: the allowed top-level keys
18
+ # only, `$defs` holding exactly `anyComponent` and `anyFunction`,
19
+ # external refs into `common_types.json` only.
20
+ #
21
+ # @example The docs site's catalog
22
+ # catalog = Poetry::Agent::A2UI::Catalog.from_registry(Poetry::Ui.root)
23
+ # catalog.to_h["components"].keys.first(3) # => ["Accordion", "ActionBar", "Alert"]
24
+ # catalog.inline # => { "catalogId" => ..., "components" => {...} }
25
+ class Catalog
26
+ # The default catalog id (a versioned, conventionally URI-shaped
27
+ # string identifier - it does not need to resolve).
28
+ DEFAULT_ID = "https://poetryui.com/a2ui/v1_0/catalog.json"
29
+
30
+ # Component and property names must be UAX #31 identifiers.
31
+ NAME_PATTERN = /\A[\p{XID_Start}_]\p{XID_Continue}*\z/u
32
+
33
+ # Options an agent never sets: styling escape hatches, the envelope's
34
+ # own `id`, and the universal wiring keywords.
35
+ SKIPPED_OPTIONS = %w[class id data aria key webmcp].freeze
36
+
37
+ # Option names whose values bind to the data model (`{path}`).
38
+ DYNAMIC_STRINGS = %w[value placeholder label text title description].freeze
39
+
40
+ # Option names whose boolean values bind to the data model.
41
+ DYNAMIC_BOOLEANS = %w[checked].freeze
42
+
43
+ # The catalog-level guidance every generation reads.
44
+ DEFAULT_INSTRUCTIONS = <<~TEXT.strip
45
+ Poetry components, rendered on the server. Compose a surface as a flat list of components referenced by id:
46
+ exactly one component has the id "root" (a layout or container such as Card, Box, or Stack). Put visible
47
+ text in a component's `text` property; put child components in `children` (an array of component ids) or a
48
+ slot property (one component id). Style axes are enums - pick by intent, never by color. Each component's
49
+ description carries its rules; they are binding. Functions: `formatString` interpolates `${/path}` and
50
+ `${name(arg: value)}` blocks; `formatNumber`, `formatCurrency`, `formatDate`, and `pluralize` format values;
51
+ `required`, `regex`, `length`, `numeric`, and `email` are checks; `and`, `or`, `not` combine them; `openUrl`
52
+ is a Button's local action.
53
+ TEXT
54
+
55
+ # @return [String]
56
+ attr_reader :catalog_id
57
+
58
+ # Builds the catalog from a registry root (the directory holding
59
+ # `config/component_registry.yml`), or from the bundled poetry-ui
60
+ # registry when no root is given.
61
+ #
62
+ # Keyword arguments pass through to {#initialize} (`catalog_id:`,
63
+ # `title:`, `description:`, `instructions:`, `exclude:`).
64
+ #
65
+ # @param root [String, Pathname, nil]
66
+ # @return [Catalog]
67
+ # @raise [ArgumentError] when no registry is found
68
+ def self.from_registry(root = nil, **)
69
+ new(entries: load_entries(root), **)
70
+ end
71
+
72
+ # Loads the registry entries under a root (the bundled poetry-ui
73
+ # registry when no root is given).
74
+ #
75
+ # @param root [String, Pathname, nil]
76
+ # @return [Hash{String => Hash}] entries by registry path
77
+ # @raise [ArgumentError] when no registry is found
78
+ def self.load_entries(root = nil)
79
+ root ||= Gem::Specification.find_all_by_name("poetry-ui").first&.gem_dir
80
+ path = root && File.join(root.to_s, Poetry::Core::Registry::RELATIVE_PATH)
81
+ raise ArgumentError, "no component registry at #{path || "(no root)"}" unless path && File.exist?(path)
82
+
83
+ YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true).fetch("components")
84
+ end
85
+
86
+ # The catalog component name of a registry path (PascalCase of its
87
+ # last segment).
88
+ #
89
+ # @param path [String]
90
+ # @return [String]
91
+ def self.component_name(path)
92
+ path.split("/").last.split("_").map(&:capitalize).join
93
+ end
94
+
95
+ # @param name [String] an option name
96
+ # @return [Boolean] whether an agent never sets it
97
+ def self.skipped_option?(name)
98
+ SKIPPED_OPTIONS.include?(name) || name.end_with?("_class")
99
+ end
100
+
101
+ # @param entries [Hash{String => Hash}] registry entries by path
102
+ # @param catalog_id [String]
103
+ # @param title [String]
104
+ # @param description [String]
105
+ # @param instructions [String] catalog-level guidance for the model
106
+ # @param exclude [Array<String>] registry paths to leave out
107
+ def initialize(entries:, catalog_id: DEFAULT_ID, title: "Poetry UI Catalog",
108
+ description: "Poetry's component library as an A2UI catalog, projected from its registry.",
109
+ instructions: DEFAULT_INSTRUCTIONS, exclude: [])
110
+ @entries = entries.except(*exclude)
111
+ @catalog_id = catalog_id
112
+ @title = title
113
+ @description = description
114
+ @instructions = instructions
115
+ end
116
+
117
+ # The catalog document (JSON Schema, string keys, components sorted
118
+ # by name).
119
+ #
120
+ # @return [Hash]
121
+ def to_h
122
+ @to_h ||= {
123
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
124
+ "$id" => @catalog_id,
125
+ "protocolVersion" => PROTOCOL_VERSION,
126
+ "title" => @title,
127
+ "description" => @description,
128
+ "catalogId" => @catalog_id,
129
+ "instructions" => @instructions,
130
+ "components" => components,
131
+ "functions" => functions.schema,
132
+ "$defs" => {
133
+ "anyComponent" => {
134
+ "oneOf" => components.keys.map { |name| { "$ref" => "#/components/#{name}" } },
135
+ "discriminator" => { "propertyName" => "component" }
136
+ },
137
+ "anyFunction" => functions.any_function
138
+ }
139
+ }
140
+ end
141
+
142
+ # The functions the catalog declares (the basic catalog's set, which
143
+ # the renderer implements).
144
+ #
145
+ # @return [Functions]
146
+ def functions
147
+ Functions.basic
148
+ end
149
+
150
+ # The inline form a transport ships to an agent or a middleware
151
+ # fetches at boot.
152
+ #
153
+ # @return [Hash] `{ "catalogId", "components" }`
154
+ def inline
155
+ { "catalogId" => @catalog_id, "components" => components }
156
+ end
157
+
158
+ # @return [String] the document as JSON
159
+ def to_json(*)
160
+ JSON.pretty_generate(to_h)
161
+ end
162
+
163
+ # The component schemas by name.
164
+ #
165
+ # @return [Hash{String => Hash}]
166
+ def components
167
+ @components ||= @entries.map { |path, entry| [component_name(path), component_schema(path, entry)] }
168
+ .sort_by(&:first).to_h
169
+ end
170
+
171
+ # The A2UI component name of a registry path (`poetry/ui/alert_dialog`
172
+ # becomes `AlertDialog`).
173
+ #
174
+ # @param path [String]
175
+ # @return [String]
176
+ def component_name(path)
177
+ self.class.component_name(path)
178
+ end
179
+
180
+ private
181
+
182
+ def component_schema(path, entry)
183
+ properties = { "component" => { "const" => component_name(path) } }
184
+ entry.fetch("styles", []).each { |axis| properties[axis["name"]] = enum_schema(axis) }
185
+ entry.fetch("options", []).each do |option|
186
+ next if skipped_option?(option["name"])
187
+
188
+ properties[option["name"]] = option_schema(option)
189
+ end
190
+ entry.fetch("slots", []).each do |slot|
191
+ properties[slot["name"]] =
192
+ slot["many"] ? child_list(slot["description"]) : component_id(slot["description"])
193
+ end
194
+ content = content_bearing?(entry)
195
+ if content
196
+ properties["text"] = dynamic("DynamicString", "The content block as text (Markdown is not interpreted).")
197
+ properties["children"] = child_list("Component ids rendered as the content block, in order.")
198
+ end
199
+ if actionable?(path)
200
+ properties["action"] =
201
+ { "$ref" => "#{COMMON_TYPES}Action", "description" => "What the press does." }
202
+ end
203
+
204
+ schema = { "type" => "object", "description" => description_of(entry), "properties" => properties,
205
+ "required" => ["component"] }
206
+ if content && entry["requires_content"]
207
+ schema["anyOf"] =
208
+ [{ "required" => ["text"] }, { "required" => ["children"] }]
209
+ end
210
+ schema
211
+ end
212
+
213
+ def enum_schema(axis)
214
+ schema = { "type" => "string", "enum" => Array(axis["variants"]).map(&:to_s) }
215
+ schema["default"] = axis["default"].to_s if axis.key?("default") && !axis["default"].nil?
216
+ schema["description"] = axis["description"].to_s if axis["description"]
217
+ schema
218
+ end
219
+
220
+ def option_schema(option)
221
+ name = option["name"].to_s
222
+ schema =
223
+ if DYNAMIC_STRINGS.include?(name) then dynamic("DynamicString", option["description"])
224
+ elsif DYNAMIC_BOOLEANS.include?(name) then dynamic("DynamicBoolean", option["description"])
225
+ elsif option["variants"] then enum_schema(option)
226
+ else typed(option["type"].to_s, option["description"])
227
+ end
228
+ if option.key?("default") && !option["default"].nil? && !schema.key?("$ref") && !schema.key?("default")
229
+ schema["default"] = option["default"].is_a?(Symbol) ? option["default"].to_s : option["default"]
230
+ end
231
+ schema
232
+ end
233
+
234
+ def typed(type, description)
235
+ schema = case type
236
+ when "boolean" then { "type" => "boolean" }
237
+ when "integer" then { "type" => "integer" }
238
+ when "number", "float" then { "type" => "number" }
239
+ when "list", "array" then { "type" => "array", "items" => { "type" => "string" } }
240
+ when "hash" then { "type" => "object" }
241
+ else { "type" => "string" }
242
+ end
243
+ schema["description"] = description.to_s if description
244
+ schema
245
+ end
246
+
247
+ def dynamic(kind, description)
248
+ schema = { "$ref" => "#{COMMON_TYPES}#{kind}" }
249
+ schema["description"] = description.to_s if description
250
+ schema
251
+ end
252
+
253
+ def component_id(description)
254
+ { "$ref" => "#{COMMON_TYPES}ComponentId",
255
+ "description" => "The id of the component rendered here. #{description}".strip }
256
+ end
257
+
258
+ def child_list(description)
259
+ { "$ref" => "#{COMMON_TYPES}ChildList", "description" => description.to_s }
260
+ end
261
+
262
+ def skipped_option?(name)
263
+ self.class.skipped_option?(name)
264
+ end
265
+
266
+ # A component takes a content block when the registry says one is
267
+ # required, when its requires_any group names content, or when its
268
+ # element roster has a content cell.
269
+ def content_bearing?(entry)
270
+ return true if entry["requires_content"]
271
+ return true if Array(entry["requires_any"]).any? { |group| group["content"] }
272
+
273
+ Array(entry["elements"]).include?("content")
274
+ end
275
+
276
+ def actionable?(path)
277
+ path.end_with?("/button")
278
+ end
279
+
280
+ def description_of(entry)
281
+ rules = Array(entry["agent_rules"])
282
+ text = entry["description"].to_s
283
+ text = "#{text} Rules: #{rules.join(" ")}" if rules.any?
284
+ text.length > 1500 ? "#{text[0, 1497]}..." : text
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end