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,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require_relative "pointer"
|
|
5
|
+
require_relative "markdown"
|
|
6
|
+
|
|
7
|
+
module Poetry
|
|
8
|
+
module Agent
|
|
9
|
+
module A2UI
|
|
10
|
+
# Renders one {Surface} to HTML through the host's view context,
|
|
11
|
+
# dispatching each component to the surface's catalog binding. The
|
|
12
|
+
# surface becomes a form when an `action_url` is given: bound inputs
|
|
13
|
+
# are named by their absolute data-model pointer and every agent
|
|
14
|
+
# action is a submit button, so a user action posts the surface's
|
|
15
|
+
# current inputs plus the source component - the spec's "inputs sync
|
|
16
|
+
# only on an action" contract, in Hotwire's native shape. The
|
|
17
|
+
# wrapper carries the surface's version for the versioned Turbo
|
|
18
|
+
# Stream replace.
|
|
19
|
+
#
|
|
20
|
+
# Rendering never raises for an agent's mistake: an unknown
|
|
21
|
+
# component, a dangling reference, a component the library refuses
|
|
22
|
+
# to build, or an unsupported function renders nothing and lands in
|
|
23
|
+
# {#warnings}.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# renderer = Renderer.new(surface, view: view_context, action_url: "/a2ui/action")
|
|
27
|
+
# html = renderer.call
|
|
28
|
+
# renderer.warnings # => []
|
|
29
|
+
class Renderer
|
|
30
|
+
# The DOM id prefix of a rendered surface.
|
|
31
|
+
ELEMENT_PREFIX = "a2ui-"
|
|
32
|
+
# The parameter carrying the bound values, keyed by absolute pointer.
|
|
33
|
+
VALUES_PARAM = "a2ui[values]"
|
|
34
|
+
# The parameter carrying the source component of the action.
|
|
35
|
+
ACTION_PARAM = "a2ui[action]"
|
|
36
|
+
# The parameter carrying the surface id.
|
|
37
|
+
SURFACE_PARAM = "a2ui[surface]"
|
|
38
|
+
# The errors an agent-authored component may provoke in the library.
|
|
39
|
+
COMPONENT_ERRORS = [ArgumentError, NameError].freeze
|
|
40
|
+
# The Stimulus controller that runs a surface's checks as the user types.
|
|
41
|
+
SURFACE_CONTROLLER = "poetry--agent--a2ui-surface"
|
|
42
|
+
# The form events that re-run the checks.
|
|
43
|
+
EVALUATE_ACTIONS = "input->#{SURFACE_CONTROLLER}#evaluate change->#{SURFACE_CONTROLLER}#evaluate".freeze
|
|
44
|
+
|
|
45
|
+
# @return [Surface]
|
|
46
|
+
attr_reader :surface
|
|
47
|
+
# @return [Object] the view context
|
|
48
|
+
attr_reader :view
|
|
49
|
+
# @return [String, nil]
|
|
50
|
+
attr_reader :action_url
|
|
51
|
+
# @return [Array<String>] what could not be rendered, in render order
|
|
52
|
+
attr_reader :warnings
|
|
53
|
+
# @return [Hash{String => Array<Hash>}] check failures by component key (see {Surface#failures})
|
|
54
|
+
attr_reader :errors
|
|
55
|
+
|
|
56
|
+
# @param surface_or_id [Surface, String]
|
|
57
|
+
# @return [String] the DOM id of the surface's wrapper
|
|
58
|
+
def self.element_id(surface_or_id)
|
|
59
|
+
id = surface_or_id.respond_to?(:id) ? surface_or_id.id : surface_or_id
|
|
60
|
+
"#{ELEMENT_PREFIX}#{id}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param surface [Surface]
|
|
64
|
+
# @param view [Object] an ActionView context (`view_context`)
|
|
65
|
+
# @param action_url [String, nil] where actions post; nil renders a plain container
|
|
66
|
+
# @param html [Hash] extra attributes for the wrapper (`class:` etc.)
|
|
67
|
+
# @param errors [Hash{String => Array<Hash>}] check failures to show, by
|
|
68
|
+
# component key (a rejected action's `errors`)
|
|
69
|
+
def initialize(surface, view:, action_url: nil, html: {}, errors: {})
|
|
70
|
+
@surface = surface
|
|
71
|
+
@view = view
|
|
72
|
+
@action_url = action_url
|
|
73
|
+
@html = html
|
|
74
|
+
@errors = errors || {}
|
|
75
|
+
@warnings = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String] the surface's HTML (html_safe)
|
|
79
|
+
def call
|
|
80
|
+
body = Poetry::Core::StableId.with_seed("a2ui:#{surface.id}") do
|
|
81
|
+
surface.root ? render_component("root", nil) : blank
|
|
82
|
+
end
|
|
83
|
+
data = { a2ui_surface: surface.id, version: surface.version }.merge(@html[:data] || {})
|
|
84
|
+
attributes = { id: self.class.element_id(surface), data: data }.merge(@html.except(:data))
|
|
85
|
+
return view.tag.div(body, **attributes) unless action_url
|
|
86
|
+
|
|
87
|
+
program = surface.program
|
|
88
|
+
if program["checks"].any?
|
|
89
|
+
data[:controller] = [data[:controller], SURFACE_CONTROLLER].compact.join(" ")
|
|
90
|
+
data[:"#{SURFACE_CONTROLLER}-program-value"] = JSON.generate(program)
|
|
91
|
+
data[:action] = [data[:action], EVALUATE_ACTIONS].compact.join(" ")
|
|
92
|
+
end
|
|
93
|
+
view.form_with(url: action_url, method: :post, **attributes) do
|
|
94
|
+
view.safe_join([view.hidden_field_tag(SURFACE_PARAM, surface.id, id: nil), body])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param component_id [String]
|
|
99
|
+
# @param scope [String, nil]
|
|
100
|
+
# @return [String] the component's HTML (empty when it cannot render)
|
|
101
|
+
def render_component(component_id, scope = nil)
|
|
102
|
+
component = surface.component(component_id)
|
|
103
|
+
return warn("unknown component id #{component_id.inspect}") unless component
|
|
104
|
+
|
|
105
|
+
previous = @current_key
|
|
106
|
+
@current_key = surface.source_key(component, scope)
|
|
107
|
+
surface.catalog.render(component, scope, self) || blank
|
|
108
|
+
rescue *COMPONENT_ERRORS, Poetry::Core::Error => e
|
|
109
|
+
warn("#{component["component"]} #{component_id.inspect}: #{e.message}")
|
|
110
|
+
ensure
|
|
111
|
+
@current_key = previous
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Renders a child reference (an id, an id list, or a template).
|
|
115
|
+
#
|
|
116
|
+
# @param reference [String, Array<String>, Hash, nil]
|
|
117
|
+
# @param scope [String, nil]
|
|
118
|
+
# @return [String]
|
|
119
|
+
def render_children(reference, scope = nil)
|
|
120
|
+
view.safe_join(surface.expand(reference, scope).map { |id, child_scope| render_component(id, child_scope) })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Builds and renders a library component. Every instance gets a
|
|
124
|
+
# render-stable `key:` (the surface, the component, its scope, and
|
|
125
|
+
# a suffix for repeated instances), so Turbo morph pairs the same
|
|
126
|
+
# logical element across updates and local state survives.
|
|
127
|
+
#
|
|
128
|
+
# @param klass [Class] the component class
|
|
129
|
+
# @param attributes [Hash] constructor keywords
|
|
130
|
+
# @param suffix [String, nil] distinguishes several instances of one class for one component
|
|
131
|
+
# @param keywords [Hash] constructor keywords given keyword-style (merged into `attributes`)
|
|
132
|
+
# @yield the content block (the component instance is yielded)
|
|
133
|
+
# @return [String]
|
|
134
|
+
def component(klass, attributes = {}, suffix: nil, **keywords, &)
|
|
135
|
+
attributes = attributes.merge(keywords)
|
|
136
|
+
attributes = { key: stable_key(suffix) }.merge(attributes) unless attributes.key?(:key)
|
|
137
|
+
view.render(klass.new(**attributes), &)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param suffix [String, nil]
|
|
141
|
+
# @return [String] the render-stable key of the component being rendered
|
|
142
|
+
def stable_key(suffix = nil)
|
|
143
|
+
["a2ui", surface.id, @current_key, suffix].compact.join("-")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [String, nil] the key of the component being rendered (`id`, or `id@scope`)
|
|
147
|
+
attr_reader :current_key
|
|
148
|
+
|
|
149
|
+
# The display string of a dynamic value; a function problem warns.
|
|
150
|
+
#
|
|
151
|
+
# @param value [Object]
|
|
152
|
+
# @param scope [String, nil]
|
|
153
|
+
# @return [String]
|
|
154
|
+
def text(value, scope = nil)
|
|
155
|
+
surface.text(value, scope, on_error: method(:warn))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param value [Object]
|
|
159
|
+
# @param scope [String, nil]
|
|
160
|
+
# @return [Object, nil] the resolved dynamic value
|
|
161
|
+
def resolve(value, scope = nil)
|
|
162
|
+
surface.resolve(value, scope, on_error: method(:warn))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Calls a catalog function; a problem warns and returns nil.
|
|
166
|
+
#
|
|
167
|
+
# @param name [String]
|
|
168
|
+
# @param args [Hash, nil]
|
|
169
|
+
# @param scope [String, nil]
|
|
170
|
+
# @return [Object, nil]
|
|
171
|
+
def call_function(name, args, scope = nil)
|
|
172
|
+
Evaluator.new(surface, scope, on_error: method(:warn)).call(name, args)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param component [Hash]
|
|
176
|
+
# @param scope [String, nil]
|
|
177
|
+
# @return [String, nil] the first check failure message for the component
|
|
178
|
+
def error_for(component, scope = nil)
|
|
179
|
+
failure = Array(errors[surface.source_key(component, scope)]).first
|
|
180
|
+
failure && failure[:message]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param path [String] a bound pointer
|
|
184
|
+
# @param scope [String, nil]
|
|
185
|
+
# @return [String] the input's form name
|
|
186
|
+
def input_name(path, scope = nil)
|
|
187
|
+
"#{VALUES_PARAM}[#{Pointer.absolute(path, scope)}]"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @param component [Hash]
|
|
191
|
+
# @param scope [String, nil]
|
|
192
|
+
# @return [String] a DOM id for the component's control
|
|
193
|
+
def control_id(component, scope = nil)
|
|
194
|
+
suffix = scope ? "-#{scope.delete_prefix("/").tr("/", "-")}" : ""
|
|
195
|
+
"#{self.class.element_id(surface)}-#{component["id"]}#{suffix}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The attributes that make a button an agent action.
|
|
199
|
+
#
|
|
200
|
+
# @param component [Hash]
|
|
201
|
+
# @param scope [String, nil]
|
|
202
|
+
# @return [Hash]
|
|
203
|
+
def submit_attributes(component, scope = nil)
|
|
204
|
+
return { type: :button } unless action_url
|
|
205
|
+
|
|
206
|
+
{ type: :submit, name: ACTION_PARAM, value: surface.source_key(component, scope) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @param component [Hash]
|
|
210
|
+
# @param scope [String, nil]
|
|
211
|
+
# @return [String, nil] the component's accessibility label
|
|
212
|
+
def aria_label(component, scope = nil)
|
|
213
|
+
accessibility = component["accessibility"]
|
|
214
|
+
return unless accessibility.is_a?(Hash) && accessibility["label"]
|
|
215
|
+
|
|
216
|
+
label = text(accessibility["label"], scope)
|
|
217
|
+
label.empty? ? nil : label
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param text [String]
|
|
221
|
+
# @return [String] the Markdown subset rendered (html_safe)
|
|
222
|
+
def markdown(text)
|
|
223
|
+
Markdown.render(text).html_safe
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @return [String] an empty html_safe string
|
|
227
|
+
def blank
|
|
228
|
+
view.safe_join([])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Records a problem and renders nothing for it.
|
|
232
|
+
#
|
|
233
|
+
# @param message [String]
|
|
234
|
+
# @return [String] an empty html_safe string
|
|
235
|
+
def warn(message)
|
|
236
|
+
@warnings << message
|
|
237
|
+
blank
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "surface"
|
|
6
|
+
|
|
7
|
+
module Poetry
|
|
8
|
+
module Agent
|
|
9
|
+
module A2UI
|
|
10
|
+
# The renderer-side consumer of the A2UI envelope: applies
|
|
11
|
+
# `createSurface`, `updateComponents`, `updateDataModel`, and
|
|
12
|
+
# `deleteSurface` to a set of {Surface}s, answers what it cannot
|
|
13
|
+
# honor with renderer-to-agent error messages, and turns a
|
|
14
|
+
# submitted form into the spec's `action` message.
|
|
15
|
+
#
|
|
16
|
+
# @example Fold a stream of messages and read back the surfaces
|
|
17
|
+
# session = Session.new
|
|
18
|
+
# session.apply_all(messages) # => ["login"]
|
|
19
|
+
# session.surfaces["login"].data
|
|
20
|
+
# session.errors # => [] or renderer-to-agent error messages
|
|
21
|
+
class Session
|
|
22
|
+
# Every message carries exactly one of these keys.
|
|
23
|
+
MESSAGE_KEYS = %w[createSurface updateComponents updateDataModel deleteSurface
|
|
24
|
+
callRendererFunction agentFunctionResponse].freeze
|
|
25
|
+
|
|
26
|
+
# A user action, ready for the agent: the spec message plus the
|
|
27
|
+
# AG-UI placement (`forwardedProps.a2uiAction.userAction`).
|
|
28
|
+
Action = Struct.new(:message, :surface, :errors, keyword_init: true) do
|
|
29
|
+
# @return [Boolean] whether every check passed and the message exists
|
|
30
|
+
def valid?
|
|
31
|
+
!message.nil? && (errors.nil? || errors.empty?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash, nil] the `{ "version", "action" }` renderer-to-agent
|
|
35
|
+
# message; nil when a check failed
|
|
36
|
+
def to_h
|
|
37
|
+
message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash] the AG-UI `forwardedProps` carrying the action (and the
|
|
41
|
+
# data model when the surface asked for it); empty when invalid
|
|
42
|
+
def forwarded_props
|
|
43
|
+
return {} unless valid?
|
|
44
|
+
|
|
45
|
+
props = { "userAction" => message["action"] }
|
|
46
|
+
props["dataModel"] = surface.data if surface.send_data_model
|
|
47
|
+
{ "a2uiAction" => props }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash{String => Surface}] live surfaces by id
|
|
52
|
+
attr_reader :surfaces
|
|
53
|
+
# @return [Array<Hash>] renderer-to-agent error messages, in order
|
|
54
|
+
attr_reader :errors
|
|
55
|
+
# @return [Array<String>] ids of deleted surfaces, in order
|
|
56
|
+
attr_reader :deleted
|
|
57
|
+
# @return [Array<Hash>] renderer-to-agent `rendererFunctionResponse` messages, in order
|
|
58
|
+
attr_reader :responses
|
|
59
|
+
# @return [Hash{String => Object}] catalog bindings by catalog id
|
|
60
|
+
attr_reader :catalogs
|
|
61
|
+
|
|
62
|
+
# @param catalogs [Hash{String => Object}] catalog bindings by id
|
|
63
|
+
# @param default_catalog [Object, nil] the binding for unknown catalog ids
|
|
64
|
+
# (Poetry's own when nil)
|
|
65
|
+
def initialize(catalogs: A2UI.catalogs, default_catalog: nil)
|
|
66
|
+
@catalogs = catalogs
|
|
67
|
+
@default_catalog = default_catalog || catalogs[Catalog::DEFAULT_ID] || catalogs.values.first
|
|
68
|
+
@surfaces = {}
|
|
69
|
+
@errors = []
|
|
70
|
+
@deleted = []
|
|
71
|
+
@responses = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Applies one envelope message. Returns the ids of the surfaces
|
|
75
|
+
# it changed (a deleted surface counts); problems are recorded in
|
|
76
|
+
# {#errors} and return no ids.
|
|
77
|
+
#
|
|
78
|
+
# @param message [Hash]
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def apply(message)
|
|
81
|
+
key = message_key(message)
|
|
82
|
+
return [] unless key
|
|
83
|
+
|
|
84
|
+
body = message[key]
|
|
85
|
+
return reject("INVALID_MESSAGE", "#{key} must be an object") unless body.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
send(:"apply_#{key.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }}", body)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param messages [Array<Hash>]
|
|
91
|
+
# @return [Array<String>] the changed surface ids, deduplicated
|
|
92
|
+
def apply_all(messages)
|
|
93
|
+
Array(messages).flat_map { |message| apply(message) }.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Applies the A2UI messages an AG-UI `a2ui-surface` activity
|
|
97
|
+
# carries (an `a2ui_operations`, `messages`, or `operations` list,
|
|
98
|
+
# or one bare message).
|
|
99
|
+
#
|
|
100
|
+
# @param content [Hash, Array]
|
|
101
|
+
# @return [Array<String>] the changed surface ids
|
|
102
|
+
def apply_activity(content)
|
|
103
|
+
list = case content
|
|
104
|
+
when Array then content
|
|
105
|
+
when Hash
|
|
106
|
+
content["a2ui_operations"] || content["messages"] || content["operations"] || [content]
|
|
107
|
+
else []
|
|
108
|
+
end
|
|
109
|
+
apply_all(list.grep(Hash))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param surface_id [String]
|
|
113
|
+
# @return [Surface, nil]
|
|
114
|
+
def surface(surface_id)
|
|
115
|
+
@surfaces[surface_id]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Turns a submitted surface form into the agent's `action`
|
|
119
|
+
# message: bound input values are written to the data model first
|
|
120
|
+
# (two-way binding syncs on an action), then the source
|
|
121
|
+
# component's event context resolves against the updated model.
|
|
122
|
+
# Returns nil when the source has no agent event (a local action,
|
|
123
|
+
# or an unknown component), and an invalid action - no message,
|
|
124
|
+
# `errors` by component key - when a `checks` rule fails.
|
|
125
|
+
#
|
|
126
|
+
# @param surface_id [String]
|
|
127
|
+
# @param source [String] the submit button's value (`id` or `id@scope`)
|
|
128
|
+
# @param values [Hash{String => Object}] submitted values by absolute pointer
|
|
129
|
+
# @param timestamp [Time]
|
|
130
|
+
# @return [Action, nil]
|
|
131
|
+
def action(surface_id:, source:, values: {}, timestamp: Time.now.utc)
|
|
132
|
+
surface = @surfaces[surface_id]
|
|
133
|
+
return unless surface
|
|
134
|
+
|
|
135
|
+
write_inputs(surface, values)
|
|
136
|
+
component_id, scope = source.to_s.split("@", 2)
|
|
137
|
+
component = surface.component(component_id)
|
|
138
|
+
event = component&.dig("action", "event")
|
|
139
|
+
return unless event.is_a?(Hash) && event["name"].is_a?(String)
|
|
140
|
+
|
|
141
|
+
failures = surface.failures
|
|
142
|
+
return Action.new(message: nil, surface: surface, errors: failures) if failures.any?
|
|
143
|
+
|
|
144
|
+
context = (event["context"] || {}).to_h { |name, value| [name.to_s, surface.resolve(value, scope)] }
|
|
145
|
+
action = { "name" => event["name"], "surfaceId" => surface_id, "sourceComponentId" => component_id,
|
|
146
|
+
"timestamp" => timestamp.utc.iso8601(3), "context" => context }
|
|
147
|
+
action["userMessage"] = event["userMessage"] if event["userMessage"].is_a?(String)
|
|
148
|
+
Action.new(message: { "version" => "v#{PROTOCOL_VERSION}", "action" => action }, surface: surface, errors: {})
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param catalog_id [String, nil]
|
|
152
|
+
# @return [Object] the catalog binding for an id (the default when unknown)
|
|
153
|
+
def catalog_for(catalog_id)
|
|
154
|
+
@catalogs[catalog_id] || @default_catalog
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def message_key(message)
|
|
160
|
+
return reject("INVALID_MESSAGE", "message must be an object") && nil unless message.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
version = message["version"]
|
|
163
|
+
unless version.nil? || version == "v#{PROTOCOL_VERSION}"
|
|
164
|
+
reject("INVALID_MESSAGE", "unsupported version #{version.inspect}")
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
keys = MESSAGE_KEYS & message.keys
|
|
169
|
+
return keys.first if keys.length == 1
|
|
170
|
+
|
|
171
|
+
reject("INVALID_MESSAGE", "message must carry exactly one of #{MESSAGE_KEYS.join(", ")}")
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def apply_create_surface(body)
|
|
176
|
+
surface_id = body["surfaceId"]
|
|
177
|
+
return reject("INVALID_MESSAGE", "createSurface.surfaceId is required") unless surface_id.is_a?(String)
|
|
178
|
+
if @surfaces[surface_id]
|
|
179
|
+
return reject("DUPLICATE_SURFACE", "surface #{surface_id} already exists",
|
|
180
|
+
surface_id)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
catalog_id = body["catalogId"]
|
|
184
|
+
surface = Surface.new(id: surface_id, catalog: catalog_for(catalog_id), catalog_id: catalog_id,
|
|
185
|
+
send_data_model: body["sendDataModel"] == true, data: body["dataModel"])
|
|
186
|
+
@surfaces[surface_id] = surface
|
|
187
|
+
record(surface_id, surface.update_components(body["components"])) if body["components"].is_a?(Array)
|
|
188
|
+
[surface_id]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_update_components(body)
|
|
192
|
+
surface = find(body, "updateComponents") or return []
|
|
193
|
+
components = body["components"]
|
|
194
|
+
unless components.is_a?(Array)
|
|
195
|
+
return reject("INVALID_MESSAGE", "updateComponents.components must be an array", surface.id)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
record(surface.id, surface.update_components(components))
|
|
199
|
+
[surface.id]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def apply_update_data_model(body)
|
|
203
|
+
surface = find(body, "updateDataModel") or return []
|
|
204
|
+
return reject("INVALID_MESSAGE", "updateDataModel.value is required", surface.id) unless body.key?("value")
|
|
205
|
+
|
|
206
|
+
surface.update_data(body["path"], body["value"])
|
|
207
|
+
[surface.id]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def apply_delete_surface(body)
|
|
211
|
+
surface = find(body, "deleteSurface") or return []
|
|
212
|
+
@surfaces.delete(surface.id)
|
|
213
|
+
@deleted << surface.id
|
|
214
|
+
[surface.id]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# An agent may invoke a function its catalog admits (`agentOnly` or
|
|
218
|
+
# `rendererOrAgent`); the value comes back as a
|
|
219
|
+
# `rendererFunctionResponse`, anything else as the spec's error.
|
|
220
|
+
def apply_call_renderer_function(body)
|
|
221
|
+
call = body["callFunction"].is_a?(Hash) ? body["callFunction"] : {}
|
|
222
|
+
name = call["call"].to_s
|
|
223
|
+
catalog = catalog_for(call["catalogId"])
|
|
224
|
+
unless catalog.functions.agent_callable?(name)
|
|
225
|
+
return refuse_call(body, "function #{name.inspect} is not invocable by an agent")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
evaluator = Evaluator.new(Surface.new(id: "callRendererFunction", catalog: catalog))
|
|
229
|
+
value = catalog.functions.call(name, evaluator.argument(call["args"] || {}), evaluator)
|
|
230
|
+
@responses << { "version" => "v#{PROTOCOL_VERSION}",
|
|
231
|
+
"rendererFunctionResponse" => { "functionCallId" => body["functionCallId"],
|
|
232
|
+
"value" => value } }
|
|
233
|
+
[]
|
|
234
|
+
rescue Functions::Error, Expression::SyntaxError => e
|
|
235
|
+
refuse_call(body, e.message)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def refuse_call(body, message)
|
|
239
|
+
error = { "code" => "INVALID_FUNCTION_CALL", "message" => message }
|
|
240
|
+
error["functionCallId"] = body["functionCallId"] if body["functionCallId"]
|
|
241
|
+
@errors << { "version" => "v#{PROTOCOL_VERSION}", "error" => error }
|
|
242
|
+
[]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# This renderer never calls agent functions; a response has nothing to match.
|
|
246
|
+
def apply_agent_function_response(_body)
|
|
247
|
+
[]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def find(body, key)
|
|
251
|
+
surface_id = body["surfaceId"]
|
|
252
|
+
surface = surface_id.is_a?(String) && @surfaces[surface_id]
|
|
253
|
+
reject("UNKNOWN_SURFACE", "#{key}: no surface #{surface_id.inspect}", surface_id) unless surface
|
|
254
|
+
surface || nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def record(surface_id, validation_errors)
|
|
258
|
+
validation_errors.each do |error|
|
|
259
|
+
@errors << { "version" => "v#{PROTOCOL_VERSION}",
|
|
260
|
+
"error" => { "code" => error[:code], "surfaceId" => surface_id, "path" => error[:path],
|
|
261
|
+
"message" => error[:message] } }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def reject(code, message, surface_id = nil)
|
|
266
|
+
error = { "code" => code, "message" => message }
|
|
267
|
+
error["surfaceId"] = surface_id if surface_id.is_a?(String)
|
|
268
|
+
@errors << { "version" => "v#{PROTOCOL_VERSION}", "error" => error }
|
|
269
|
+
[]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Only bound paths are writable, each coerced to its input's kind.
|
|
273
|
+
def write_inputs(surface, values)
|
|
274
|
+
return unless values.respond_to?(:each_pair)
|
|
275
|
+
|
|
276
|
+
kinds = surface.inputs.to_h { |input| [input[:path], input[:kind]] }
|
|
277
|
+
values.each_pair do |path, value|
|
|
278
|
+
kind = kinds[path.to_s] or next
|
|
279
|
+
|
|
280
|
+
surface.update_data(path.to_s, coerce(value, kind))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def coerce(value, kind)
|
|
285
|
+
case kind
|
|
286
|
+
when :boolean then %w[true 1 on].include?(value.to_s.downcase)
|
|
287
|
+
when :number then number(value)
|
|
288
|
+
when :string_list then Array(value).map(&:to_s).reject(&:empty?)
|
|
289
|
+
else value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def number(value)
|
|
294
|
+
text = value.to_s
|
|
295
|
+
return nil if text.strip.empty?
|
|
296
|
+
|
|
297
|
+
text.match?(/\A-?\d+\z/) ? text.to_i : Float(text, exception: false)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../agui/turbo_stream"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Agent
|
|
7
|
+
module A2UI
|
|
8
|
+
# Delivers a {Session}'s surfaces as Turbo Streams: a surface's first
|
|
9
|
+
# appearance appends into the container (when one is given), every
|
|
10
|
+
# later change is a versioned replace of its wrapper (`vreplace`,
|
|
11
|
+
# from the AG-UI relay: a stale version never overwrites a newer
|
|
12
|
+
# one), and a deletion removes it. The host renders each surface
|
|
13
|
+
# through the `render` callable (typically a {Renderer}).
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# streams = Streams.new(session: session, container: "surfaces",
|
|
17
|
+
# render: ->(surface) { Renderer.new(surface, view: view_context).call })
|
|
18
|
+
# response.stream.write(AGUI::TurboStream.sse(streams.apply(message)))
|
|
19
|
+
class Streams
|
|
20
|
+
# @return [Session]
|
|
21
|
+
attr_reader :session
|
|
22
|
+
|
|
23
|
+
# @param session [Session]
|
|
24
|
+
# @param render [#call] `(surface) -> html`
|
|
25
|
+
# @param container [String, nil] the DOM id new surfaces append into
|
|
26
|
+
# @param morph [Boolean] morph replaced surfaces (the default) so local state -
|
|
27
|
+
# typed text, a selected tab, an open dialog - survives an update; false swaps
|
|
28
|
+
def initialize(session:, render:, container: nil, morph: true)
|
|
29
|
+
@session = session
|
|
30
|
+
@render = render
|
|
31
|
+
@container = container
|
|
32
|
+
@morph = morph
|
|
33
|
+
@seen = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Applies one message and returns the streams for what changed.
|
|
37
|
+
#
|
|
38
|
+
# @param message [Hash]
|
|
39
|
+
# @return [String] Turbo Stream HTML (empty when nothing changed)
|
|
40
|
+
def apply(message)
|
|
41
|
+
streams(session.apply(message))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param messages [Array<Hash>]
|
|
45
|
+
# @return [String] Turbo Stream HTML
|
|
46
|
+
def apply_all(messages)
|
|
47
|
+
streams(session.apply_all(messages))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The streams for a set of surface ids.
|
|
51
|
+
#
|
|
52
|
+
# @param ids [Array<String>]
|
|
53
|
+
# @return [String]
|
|
54
|
+
def streams(ids)
|
|
55
|
+
Array(ids).map { |id| stream_for(id) }.join
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param id [String]
|
|
59
|
+
# @return [String] the stream for one surface (remove, append, or vreplace)
|
|
60
|
+
def stream_for(id)
|
|
61
|
+
target = Renderer.element_id(id)
|
|
62
|
+
surface = session.surface(id)
|
|
63
|
+
return AGUI::TurboStream.remove(target) unless surface
|
|
64
|
+
|
|
65
|
+
html = @render.call(surface)
|
|
66
|
+
first = @container && !@seen[id]
|
|
67
|
+
@seen[id] = true
|
|
68
|
+
first ? AGUI::TurboStream.append(@container, html) : AGUI::TurboStream.vreplace(target, html, morph: @morph)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Marks surfaces as already on the page (rendered server-side), so
|
|
72
|
+
# their next change replaces instead of appending.
|
|
73
|
+
#
|
|
74
|
+
# @param ids [Array<String>]
|
|
75
|
+
# @return [void]
|
|
76
|
+
def mark_seen(*ids)
|
|
77
|
+
ids.flatten.each { |id| @seen[id] = true }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|