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,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pointer"
|
|
4
|
+
require_relative "evaluator"
|
|
5
|
+
require_relative "checks"
|
|
6
|
+
|
|
7
|
+
module Poetry
|
|
8
|
+
module Agent
|
|
9
|
+
module A2UI
|
|
10
|
+
# One A2UI surface on the renderer side: its flat component list
|
|
11
|
+
# (an adjacency list keyed by id, `root` at the top), its data
|
|
12
|
+
# model, and a monotonic version the Turbo Stream delivery compares.
|
|
13
|
+
# A surface belongs to a catalog binding, which knows how each
|
|
14
|
+
# component references its children; the surface itself is
|
|
15
|
+
# catalog-agnostic beyond that.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# surface = Surface.new(id: "card", catalog: Catalogs::Basic.new)
|
|
19
|
+
# surface.update_components([{ "id" => "root", "component" => "Text", "text" => { "path" => "/name" } }])
|
|
20
|
+
# surface.update_data("/name", "Ada")
|
|
21
|
+
# surface.resolve({ "path" => "/name" }) # => "Ada"
|
|
22
|
+
class Surface
|
|
23
|
+
# Component names are UAX #31 identifiers.
|
|
24
|
+
NAME_PATTERN = /\A[\p{XID_Start}_]\p{XID_Continue}*\z/u
|
|
25
|
+
# The reserved container the renderer instantiates on createSurface.
|
|
26
|
+
RESERVED_COMPONENT = "Surface"
|
|
27
|
+
|
|
28
|
+
# @return [String]
|
|
29
|
+
attr_reader :id
|
|
30
|
+
# @return [String, nil] the catalog id the agent named
|
|
31
|
+
attr_reader :catalog_id
|
|
32
|
+
# @return [Object] the catalog binding (see {Catalogs::Basic})
|
|
33
|
+
attr_reader :catalog
|
|
34
|
+
# @return [Boolean] whether actions carry the whole data model
|
|
35
|
+
attr_reader :send_data_model
|
|
36
|
+
# @return [Hash{String => Hash}] components by id
|
|
37
|
+
attr_reader :components
|
|
38
|
+
# @return [Hash] the data model
|
|
39
|
+
attr_reader :data
|
|
40
|
+
# @return [Integer] bumps on every applied change
|
|
41
|
+
attr_reader :version
|
|
42
|
+
|
|
43
|
+
# @param id [String]
|
|
44
|
+
# @param catalog [Object] the catalog binding
|
|
45
|
+
# @param catalog_id [String, nil]
|
|
46
|
+
# @param send_data_model [Boolean]
|
|
47
|
+
# @param data [Hash, nil] the initial data model
|
|
48
|
+
# @param components [Array<Hash>] the initial component list
|
|
49
|
+
def initialize(id:, catalog:, catalog_id: nil, send_data_model: false, data: nil, components: [])
|
|
50
|
+
@id = id
|
|
51
|
+
@catalog = catalog
|
|
52
|
+
@catalog_id = catalog_id
|
|
53
|
+
@send_data_model = send_data_model ? true : false
|
|
54
|
+
@data = deep_copy(data.is_a?(Hash) ? data : {})
|
|
55
|
+
@components = {}
|
|
56
|
+
@version = 0
|
|
57
|
+
@errors = []
|
|
58
|
+
update_components(components) if components.any?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Upserts components by id and validates the result. Returns the
|
|
62
|
+
# validation errors (each `{ code:, path:, message: }`); a dangling
|
|
63
|
+
# child reference is not one - streaming delivers children later.
|
|
64
|
+
#
|
|
65
|
+
# @param list [Array<Hash>]
|
|
66
|
+
# @return [Array<Hash>]
|
|
67
|
+
def update_components(list)
|
|
68
|
+
errors = []
|
|
69
|
+
Array(list).each_with_index do |component, index|
|
|
70
|
+
error = component_error(component, index)
|
|
71
|
+
errors << error and next if error
|
|
72
|
+
|
|
73
|
+
@components[component["id"]] = deep_copy(component)
|
|
74
|
+
end
|
|
75
|
+
errors.concat(cycle_errors)
|
|
76
|
+
bump!
|
|
77
|
+
errors
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Applies an `updateDataModel` (upsert; nil removes; the root
|
|
81
|
+
# pointer replaces the whole model).
|
|
82
|
+
#
|
|
83
|
+
# @param path [String, nil]
|
|
84
|
+
# @param value [Object, nil]
|
|
85
|
+
# @return [void]
|
|
86
|
+
def update_data(path, value)
|
|
87
|
+
@data = Pointer.upsert(@data, path || "/", deep_copy(value))
|
|
88
|
+
@data = {} unless @data.is_a?(Hash)
|
|
89
|
+
bump!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Hash, nil] the top-level component
|
|
93
|
+
def root
|
|
94
|
+
@components["root"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param component_id [String]
|
|
98
|
+
# @return [Hash, nil]
|
|
99
|
+
def component(component_id)
|
|
100
|
+
@components[component_id]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Resolves a dynamic value in a scope: a `{ "path" => ... }` binding
|
|
104
|
+
# reads the data model (relative paths against the scope), a
|
|
105
|
+
# `{ "call" => ... }` function call runs through the catalog's
|
|
106
|
+
# functions (see {Evaluator}), anything else is a literal.
|
|
107
|
+
#
|
|
108
|
+
# @param value [Object]
|
|
109
|
+
# @param scope [String, nil] the collection-item pointer in effect
|
|
110
|
+
# @param on_error [#call, nil] receives each function problem's message
|
|
111
|
+
# @return [Object, nil]
|
|
112
|
+
def resolve(value, scope = nil, on_error: nil)
|
|
113
|
+
Evaluator.new(self, scope, on_error: on_error).resolve(value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The string a resolved value displays as (the spec's conversion
|
|
117
|
+
# rules: nil is empty, containers are JSON).
|
|
118
|
+
#
|
|
119
|
+
# @param value [Object]
|
|
120
|
+
# @param scope [String, nil]
|
|
121
|
+
# @param on_error [#call, nil] receives each function problem's message
|
|
122
|
+
# @return [String]
|
|
123
|
+
def text(value, scope = nil, on_error: nil)
|
|
124
|
+
evaluator = Evaluator.new(self, scope, on_error: on_error)
|
|
125
|
+
evaluator.stringify(evaluator.resolve(value))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Evaluates every rendered component's `checks` against the data
|
|
129
|
+
# model, keyed the way an action names its source (`id`, or
|
|
130
|
+
# `id@scope` inside a template).
|
|
131
|
+
#
|
|
132
|
+
# @param on_error [#call, nil] receives each function problem's message
|
|
133
|
+
# @return [Hash{String => Array<Hash>}] failures by component key
|
|
134
|
+
def failures(on_error: nil)
|
|
135
|
+
result = {}
|
|
136
|
+
walk do |component, scope|
|
|
137
|
+
next unless component["checks"].is_a?(Array)
|
|
138
|
+
|
|
139
|
+
found = Checks.failures(component, Evaluator.new(self, scope, on_error: on_error))
|
|
140
|
+
result[source_key(component, scope)] = found if found.any?
|
|
141
|
+
end
|
|
142
|
+
result
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param component [Hash]
|
|
146
|
+
# @param scope [String, nil]
|
|
147
|
+
# @return [String] `id`, or `id@scope` inside a template
|
|
148
|
+
def source_key(component, scope = nil)
|
|
149
|
+
scope ? "#{component["id"]}@#{scope}" : component["id"].to_s
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What a client-side evaluator needs to run the checks as the user
|
|
153
|
+
# types: every checked component's rules with its bindings made
|
|
154
|
+
# absolute for its scope, the bound inputs by absolute path with
|
|
155
|
+
# their kinds, and the data model for paths no input carries.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash] `{ "checks" => { key => { "kind", "rules" } }, "inputs" => { path => kind }, "model" => data }`
|
|
158
|
+
def program
|
|
159
|
+
checks = {}
|
|
160
|
+
walk do |component, scope|
|
|
161
|
+
rules = Array(component["checks"]).grep(Hash).select { |rule| rule["condition"] }
|
|
162
|
+
next if rules.empty?
|
|
163
|
+
|
|
164
|
+
checks[source_key(component, scope)] = {
|
|
165
|
+
"kind" => component["action"].is_a?(Hash) ? "button" : "input",
|
|
166
|
+
"rules" => rules.map do |rule|
|
|
167
|
+
{ "condition" => absolutize(rule["condition"], scope), "message" => rule["message"] }
|
|
168
|
+
end
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
{ "checks" => checks, "inputs" => inputs.to_h { |input| [input[:path], input[:kind].to_s] }, "model" => data }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param value [Object]
|
|
175
|
+
# @return [Boolean] whether the value is a `{ "path" => ... }` data binding
|
|
176
|
+
def binding?(value)
|
|
177
|
+
value.is_a?(Hash) && value.key?("path") && !value.key?("componentId")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param value [Object]
|
|
181
|
+
# @return [Boolean] whether the value is a `{ "call" => ... }` function call
|
|
182
|
+
def function_call?(value)
|
|
183
|
+
value.is_a?(Hash) && value.key?("call")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @param value [Object]
|
|
187
|
+
# @return [Boolean] whether the value is a `{ "componentId", "path" }` template
|
|
188
|
+
def template?(value)
|
|
189
|
+
value.is_a?(Hash) && value.key?("componentId")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Reads a bound path in a scope.
|
|
193
|
+
#
|
|
194
|
+
# @param path [String]
|
|
195
|
+
# @param scope [String, nil]
|
|
196
|
+
# @return [Object, nil]
|
|
197
|
+
def read(path, scope = nil)
|
|
198
|
+
Pointer.get(@data, Pointer.absolute(path, scope))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Expands a child reference into `[id, scope]` pairs: an id array
|
|
202
|
+
# keeps the scope, a template instantiates its component once per
|
|
203
|
+
# item of the bound array with the item's pointer as the scope.
|
|
204
|
+
#
|
|
205
|
+
# @param reference [Array<String>, Hash, String, nil]
|
|
206
|
+
# @param scope [String, nil]
|
|
207
|
+
# @return [Array<Array(String, String)>]
|
|
208
|
+
def expand(reference, scope = nil)
|
|
209
|
+
case reference
|
|
210
|
+
when String then [[reference, scope]]
|
|
211
|
+
when Array then reference.grep(String).map { |child| [child, scope] }
|
|
212
|
+
when Hash
|
|
213
|
+
return [] unless template?(reference)
|
|
214
|
+
|
|
215
|
+
path = Pointer.absolute(reference["path"].to_s, scope)
|
|
216
|
+
items = Pointer.get(@data, path)
|
|
217
|
+
return [] unless items.is_a?(Array)
|
|
218
|
+
|
|
219
|
+
items.each_index.map { |index| [reference["componentId"], "#{path}/#{index}"] }
|
|
220
|
+
else []
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Walks the rendered tree depth-first from the root, yielding each
|
|
225
|
+
# `[component, scope]` in render order (templates instantiate once
|
|
226
|
+
# per item; a cycle guard keeps the walk finite).
|
|
227
|
+
#
|
|
228
|
+
# @yieldparam component [Hash]
|
|
229
|
+
# @yieldparam scope [String, nil]
|
|
230
|
+
# @return [void]
|
|
231
|
+
def walk(&)
|
|
232
|
+
walk_from("root", nil, [], &)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Bound input descriptors of the rendered tree, absolute paths only.
|
|
236
|
+
#
|
|
237
|
+
# @return [Array<Hash>] `{ path:, kind: }` (kind: :string, :boolean, :number, :string_list)
|
|
238
|
+
def inputs
|
|
239
|
+
result = []
|
|
240
|
+
walk do |component, scope|
|
|
241
|
+
result.concat(Array(catalog.inputs(component, scope)))
|
|
242
|
+
end
|
|
243
|
+
result.uniq { |input| input[:path] }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [Hash] a JSON-ready snapshot
|
|
247
|
+
def to_h
|
|
248
|
+
{ "surfaceId" => id, "catalogId" => catalog_id, "sendDataModel" => send_data_model,
|
|
249
|
+
"version" => version, "components" => components.values, "dataModel" => data }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def bump!
|
|
255
|
+
@version += 1
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Rewrites every binding in a value to its absolute pointer.
|
|
259
|
+
def absolutize(value, scope)
|
|
260
|
+
case value
|
|
261
|
+
when Array then value.map { |item| absolutize(item, scope) }
|
|
262
|
+
when Hash
|
|
263
|
+
return { "path" => Pointer.absolute(value["path"].to_s, scope) } if binding?(value)
|
|
264
|
+
|
|
265
|
+
value.transform_values { |item| absolutize(item, scope) }
|
|
266
|
+
else value
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def walk_from(component_id, scope, stack, &)
|
|
271
|
+
key = [component_id, scope]
|
|
272
|
+
return if stack.include?(key)
|
|
273
|
+
|
|
274
|
+
component = @components[component_id]
|
|
275
|
+
return unless component
|
|
276
|
+
|
|
277
|
+
yield component, scope
|
|
278
|
+
Array(catalog.references(component)).each do |reference|
|
|
279
|
+
expand(reference, scope).each do |child_id, child_scope|
|
|
280
|
+
walk_from(child_id, child_scope, stack + [key], &)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def component_error(component, index)
|
|
286
|
+
path = "/components/#{index}"
|
|
287
|
+
return error(path, "component must be an object") unless component.is_a?(Hash)
|
|
288
|
+
|
|
289
|
+
id = component["id"]
|
|
290
|
+
name = component["component"]
|
|
291
|
+
return error(path, "id (string) is required") unless id.is_a?(String) && !id.empty?
|
|
292
|
+
return error("#{path}/component", "component (string) is required") unless name.is_a?(String)
|
|
293
|
+
return error("#{path}/component", "#{name.inspect} is not an identifier") unless name.match?(NAME_PATTERN)
|
|
294
|
+
return error("#{path}/component", "#{RESERVED_COMPONENT} is reserved") if name == RESERVED_COMPONENT
|
|
295
|
+
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Static edges (template components count) coloured depth-first.
|
|
300
|
+
def cycle_errors
|
|
301
|
+
state = {}
|
|
302
|
+
errors = []
|
|
303
|
+
@components.each_key do |component_id|
|
|
304
|
+
visit(component_id, state, [], errors)
|
|
305
|
+
end
|
|
306
|
+
errors
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def visit(component_id, state, stack, errors)
|
|
310
|
+
return if state[component_id] == :done
|
|
311
|
+
|
|
312
|
+
if state[component_id] == :open
|
|
313
|
+
errors << error("/components/#{component_id}",
|
|
314
|
+
"circular reference: #{(stack + [component_id]).join(" -> ")}")
|
|
315
|
+
return
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
state[component_id] = :open
|
|
319
|
+
static_children(component_id).each { |child| visit(child, state, stack + [component_id], errors) }
|
|
320
|
+
state[component_id] = :done
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def static_children(component_id)
|
|
324
|
+
component = @components[component_id]
|
|
325
|
+
return [] unless component
|
|
326
|
+
|
|
327
|
+
children = Array(catalog.references(component)).flat_map do |reference|
|
|
328
|
+
case reference
|
|
329
|
+
when String then [reference]
|
|
330
|
+
when Array then reference.grep(String)
|
|
331
|
+
when Hash then template?(reference) ? [reference["componentId"]] : []
|
|
332
|
+
else []
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
children.select { |child| @components.key?(child) }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def error(path, message)
|
|
339
|
+
{ code: "VALIDATION_FAILED", path: path, message: message }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def deep_copy(value)
|
|
343
|
+
case value
|
|
344
|
+
when Hash then value.to_h { |key, item| [key.to_s, deep_copy(item)] }
|
|
345
|
+
when Array then value.map { |item| deep_copy(item) }
|
|
346
|
+
else value
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "a2ui/protocol"
|
|
4
|
+
require_relative "a2ui/catalog"
|
|
5
|
+
require_relative "a2ui/pointer"
|
|
6
|
+
require_relative "a2ui/markdown"
|
|
7
|
+
require_relative "a2ui/expression"
|
|
8
|
+
require_relative "a2ui/functions"
|
|
9
|
+
require_relative "a2ui/evaluator"
|
|
10
|
+
require_relative "a2ui/checks"
|
|
11
|
+
require_relative "a2ui/surface"
|
|
12
|
+
require_relative "a2ui/session"
|
|
13
|
+
require_relative "a2ui/renderer"
|
|
14
|
+
require_relative "a2ui/catalogs/basic"
|
|
15
|
+
require_relative "a2ui/catalogs/native"
|
|
16
|
+
require_relative "a2ui/streams"
|
|
17
|
+
|
|
18
|
+
module Poetry
|
|
19
|
+
module Agent
|
|
20
|
+
# The A2UI surface: Google's declarative generative-UI format, where
|
|
21
|
+
# an agent emits a flat component list against a client-owned catalog
|
|
22
|
+
# and the client renders it with its own components. Two halves ship:
|
|
23
|
+
#
|
|
24
|
+
# - {Catalog} projects Poetry's registry into an A2UI v1.0 catalog
|
|
25
|
+
# document, so any A2UI agent generates against Poetry's vocabulary
|
|
26
|
+
# and a renderer validates what arrives against the same document.
|
|
27
|
+
# - The renderer: {Session} folds the envelope (`createSurface`,
|
|
28
|
+
# `updateComponents`, `updateDataModel`, `deleteSurface`) into
|
|
29
|
+
# {Surface}s, {Renderer} renders a surface through the host's view
|
|
30
|
+
# context with a catalog binding ({Catalogs::Basic} for the spec's
|
|
31
|
+
# basic catalog, {Catalogs::Native} for Poetry's own), {Streams}
|
|
32
|
+
# delivers changes as versioned Turbo Streams, and a submitted
|
|
33
|
+
# surface form becomes the spec's `action` message
|
|
34
|
+
# ({Session#action}). {Functions} holds the basic catalog's function
|
|
35
|
+
# set - the `formatString` grammar ({Expression}), the formatters,
|
|
36
|
+
# the validators behind `checks` ({Checks}) - which {Evaluator} runs
|
|
37
|
+
# for both catalogs.
|
|
38
|
+
module A2UI
|
|
39
|
+
# The catalog bindings a {Session} starts with: the spec's basic
|
|
40
|
+
# catalog and Poetry's own, keyed by catalog id.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{String => Object}]
|
|
43
|
+
def self.catalogs
|
|
44
|
+
{ Catalogs::Basic::ID => Catalogs::Basic.new, Catalog::DEFAULT_ID => Catalogs::Native.new }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Poetry
|
|
8
|
+
module Agent
|
|
9
|
+
module AGUI
|
|
10
|
+
# The HTTP client: POSTs a `RunAgentInput` to an AG-UI endpoint and
|
|
11
|
+
# yields the streamed events as they arrive (stdlib Net::HTTP,
|
|
12
|
+
# `text/event-stream`). One call is one run; the multi-run model
|
|
13
|
+
# (client tools, interrupts) is the caller's loop over {Transcript}.
|
|
14
|
+
class Client
|
|
15
|
+
# Raised for a non-success HTTP status.
|
|
16
|
+
class Error < Poetry::Core::Error
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
attr_reader :status
|
|
19
|
+
|
|
20
|
+
def initialize(message, status:)
|
|
21
|
+
super(message)
|
|
22
|
+
@status = status
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param url [String] the agent's run endpoint
|
|
27
|
+
# @param headers [Hash{String => String}] extra request headers (auth)
|
|
28
|
+
# @param open_timeout [Numeric] seconds
|
|
29
|
+
# @param read_timeout [Numeric] seconds between chunks
|
|
30
|
+
def initialize(url:, headers: {}, open_timeout: 10, read_timeout: 120)
|
|
31
|
+
@uri = URI(url)
|
|
32
|
+
@headers = headers
|
|
33
|
+
@open_timeout = open_timeout
|
|
34
|
+
@read_timeout = read_timeout
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Runs the agent and yields every event.
|
|
38
|
+
#
|
|
39
|
+
# @param input [Hash] the wire hash ({RunInput.build})
|
|
40
|
+
# @yieldparam event [Hash]
|
|
41
|
+
# @return [SSE::Parser] the parser (its `errors` list any unreadable lines)
|
|
42
|
+
# @raise [Error] on a non-2xx response
|
|
43
|
+
# @example
|
|
44
|
+
# client.run(input) { |event| transcript.apply(event) }
|
|
45
|
+
def run(input, &)
|
|
46
|
+
request = Net::HTTP::Post.new(@uri)
|
|
47
|
+
request["Content-Type"] = "application/json"
|
|
48
|
+
request["Accept"] = "text/event-stream"
|
|
49
|
+
@headers.each { |name, value| request[name] = value }
|
|
50
|
+
request.body = JSON.generate(input)
|
|
51
|
+
|
|
52
|
+
parser = SSE::Parser.new
|
|
53
|
+
Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https",
|
|
54
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
|
|
55
|
+
http.request(request) do |response|
|
|
56
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
57
|
+
raise Error.new("AG-UI endpoint answered #{response.code}", status: response.code.to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
response.read_body { |chunk| parser.feed(chunk, &) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
parser.finish(&)
|
|
64
|
+
parser
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Agent
|
|
5
|
+
module AGUI
|
|
6
|
+
# RFC 6902 JSON Patch over plain Ruby data (Hash / Array), with RFC
|
|
7
|
+
# 6901 JSON Pointer paths - what AG-UI's STATE_DELTA and
|
|
8
|
+
# ACTIVITY_DELTA carry. Applies atomically: the document is deep-
|
|
9
|
+
# copied first and the copy is returned, so a failing operation
|
|
10
|
+
# leaves the caller's document untouched.
|
|
11
|
+
module JsonPatch
|
|
12
|
+
# Raised for an operation the document cannot take (an unknown
|
|
13
|
+
# op, a missing path, a failed test).
|
|
14
|
+
class Error < Poetry::Core::Error; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Applies a patch and returns the patched copy.
|
|
19
|
+
#
|
|
20
|
+
# @param document [Hash, Array]
|
|
21
|
+
# @param operations [Array<Hash>] RFC 6902 operations (string or symbol keys)
|
|
22
|
+
# @return [Hash, Array] the new document
|
|
23
|
+
# @raise [Error] on an invalid operation
|
|
24
|
+
# @example
|
|
25
|
+
# JsonPatch.apply({ "a" => 1 }, [{ "op" => "replace", "path" => "/a", "value" => 2 }])
|
|
26
|
+
# # => { "a" => 2 }
|
|
27
|
+
def apply(document, operations)
|
|
28
|
+
result = deep_copy(document)
|
|
29
|
+
Array(operations).each do |operation|
|
|
30
|
+
op = AGUI.field(operation, "op").to_s
|
|
31
|
+
path = AGUI.field(operation, "path").to_s
|
|
32
|
+
case op
|
|
33
|
+
when "add" then result = add(result, path, deep_copy(AGUI.field(operation, "value")))
|
|
34
|
+
when "remove" then result = remove(result, path)
|
|
35
|
+
when "replace"
|
|
36
|
+
value = deep_copy(AGUI.field(operation, "value"))
|
|
37
|
+
result = path.empty? ? value : add(remove(result, path), path, value)
|
|
38
|
+
when "move"
|
|
39
|
+
from = AGUI.field(operation, "from").to_s
|
|
40
|
+
value = get(result, from)
|
|
41
|
+
result = remove(result, from)
|
|
42
|
+
result = add(result, path, value)
|
|
43
|
+
when "copy" then result = add(result, path, deep_copy(get(result, AGUI.field(operation, "from").to_s)))
|
|
44
|
+
when "test"
|
|
45
|
+
raise Error, "test failed at #{path}" unless get(result, path) == AGUI.field(operation, "value")
|
|
46
|
+
else raise Error, "unknown op #{op.inspect}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reads the value at a JSON Pointer.
|
|
53
|
+
#
|
|
54
|
+
# @param document [Hash, Array]
|
|
55
|
+
# @param path [String] RFC 6901 pointer ("" is the whole document)
|
|
56
|
+
# @return [Object]
|
|
57
|
+
# @raise [Error] when the path does not resolve
|
|
58
|
+
def get(document, path)
|
|
59
|
+
tokens(path).reduce(document) do |node, token|
|
|
60
|
+
case node
|
|
61
|
+
when Hash then node.key?(token) ? node[token] : raise(Error, "no such path #{path}")
|
|
62
|
+
when Array then node[index_of(node, token, path)] || raise(Error, "no such path #{path}")
|
|
63
|
+
else raise Error, "no such path #{path}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @api private
|
|
69
|
+
def add(document, path, value)
|
|
70
|
+
parts = tokens(path)
|
|
71
|
+
return value if parts.empty?
|
|
72
|
+
|
|
73
|
+
parent = get(document, pointer(parts[0...-1]))
|
|
74
|
+
last = parts.last
|
|
75
|
+
case parent
|
|
76
|
+
when Hash then parent[last] = value
|
|
77
|
+
when Array
|
|
78
|
+
if last == "-" then parent << value
|
|
79
|
+
else
|
|
80
|
+
index = Integer(last, exception: false) || raise(Error, "bad index #{last} at #{path}")
|
|
81
|
+
raise Error, "index out of range at #{path}" if index > parent.length || index.negative?
|
|
82
|
+
|
|
83
|
+
parent.insert(index, value)
|
|
84
|
+
end
|
|
85
|
+
else raise Error, "cannot add into #{parent.class} at #{path}"
|
|
86
|
+
end
|
|
87
|
+
document
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @api private
|
|
91
|
+
def remove(document, path)
|
|
92
|
+
parts = tokens(path)
|
|
93
|
+
raise Error, "cannot remove the whole document" if parts.empty?
|
|
94
|
+
|
|
95
|
+
parent = get(document, pointer(parts[0...-1]))
|
|
96
|
+
last = parts.last
|
|
97
|
+
case parent
|
|
98
|
+
when Hash then parent.key?(last) ? parent.delete(last) : raise(Error, "no such path #{path}")
|
|
99
|
+
when Array then parent.delete_at(index_of(parent, last, path))
|
|
100
|
+
else raise Error, "cannot remove from #{parent.class} at #{path}"
|
|
101
|
+
end
|
|
102
|
+
document
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @api private
|
|
106
|
+
def tokens(path)
|
|
107
|
+
return [] if path.nil? || path.empty?
|
|
108
|
+
raise Error, "pointer must start with / (#{path})" unless path.start_with?("/")
|
|
109
|
+
|
|
110
|
+
path[1..].split("/", -1).map { |token| token.gsub("~1", "/").gsub("~0", "~") }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @api private
|
|
114
|
+
def pointer(parts)
|
|
115
|
+
parts.map { |token| "/#{token.gsub("~", "~0").gsub("/", "~1")}" }.join
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @api private
|
|
119
|
+
def index_of(array, token, path)
|
|
120
|
+
index = Integer(token, exception: false)
|
|
121
|
+
raise Error, "bad index #{token} at #{path}" if index.nil? || index.negative? || index >= array.length
|
|
122
|
+
|
|
123
|
+
index
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @api private
|
|
127
|
+
def deep_copy(value)
|
|
128
|
+
case value
|
|
129
|
+
when Hash then value.to_h { |key, inner| [key, deep_copy(inner)] }
|
|
130
|
+
when Array then value.map { |inner| deep_copy(inner) }
|
|
131
|
+
else value
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|