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