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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- 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
|