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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "functions"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module A2UI
8
+ # Evaluates a component's `checks`: each rule's condition (a binding
9
+ # or a function call) yields a ValidationResult - `{ "valid",
10
+ # "code", "message", "severity" }` - or a boolean; a failing rule
11
+ # of error severity is a failure, carrying the result's message or
12
+ # the rule's fallback.
13
+ module Checks
14
+ # The message when neither the result nor the rule carries one.
15
+ DEFAULT_MESSAGE = "Check failed"
16
+
17
+ module_function
18
+
19
+ # @param component [Hash]
20
+ # @param evaluator [Evaluator]
21
+ # @return [Array<Hash>] failures as `{ code:, message:, severity: }`
22
+ def failures(component, evaluator)
23
+ Array(component["checks"]).filter_map do |rule|
24
+ next unless rule.is_a?(Hash) && rule["condition"]
25
+
26
+ outcome = interpret(evaluator.resolve(rule["condition"]), rule)
27
+ outcome unless outcome[:valid] || outcome[:severity] != "error"
28
+ end
29
+ end
30
+
31
+ # @api private
32
+ def interpret(result, rule)
33
+ if result.is_a?(Hash)
34
+ { valid: result["valid"] == true, code: result["code"],
35
+ message: result["message"] || rule["message"] || DEFAULT_MESSAGE,
36
+ severity: result["severity"] || "error" }
37
+ else
38
+ { valid: Functions.truthy?(result), code: nil, message: rule["message"] || DEFAULT_MESSAGE,
39
+ severity: "error" }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "expression"
5
+ require_relative "functions"
6
+
7
+ module Poetry
8
+ module Agent
9
+ module A2UI
10
+ # Resolves dynamic values in a scope: a `{ "path" }` binding reads
11
+ # the data model, a `{ "call", "args" }` invokes a registered
12
+ # function with its arguments resolved first (a string argument with
13
+ # `${}` blocks interpolates, an array resolves item by item), and
14
+ # `@index` reads the collection index the scope carries. Problems
15
+ # (an unknown function, a bad argument, a malformed expression)
16
+ # resolve to nil and reach the `on_error` callback.
17
+ #
18
+ # @example
19
+ # Evaluator.new(surface, "/items/2").resolve({ "call" => "@index", "args" => { "offset" => 1 } }) # => 3
20
+ class Evaluator
21
+ # @return [Surface]
22
+ attr_reader :surface
23
+ # @return [String, nil] the collection-item pointer in effect
24
+ attr_reader :scope
25
+
26
+ # @param surface [Surface]
27
+ # @param scope [String, nil]
28
+ # @param on_error [#call, nil] receives each problem's message
29
+ def initialize(surface, scope = nil, on_error: nil)
30
+ @surface = surface
31
+ @scope = scope
32
+ @on_error = on_error
33
+ end
34
+
35
+ # Resolves a component property: bindings and calls resolve,
36
+ # everything else is a literal.
37
+ #
38
+ # @param value [Object]
39
+ # @return [Object, nil]
40
+ def resolve(value)
41
+ return value unless value.is_a?(Hash)
42
+ return surface.read(value["path"], scope) if surface.binding?(value)
43
+ return call(value["call"], value["args"]) if surface.function_call?(value)
44
+
45
+ value
46
+ end
47
+
48
+ # Resolves a function argument: like {#resolve}, plus strings
49
+ # interpolate and arrays and plain objects resolve inside.
50
+ #
51
+ # @param value [Object]
52
+ # @return [Object, nil]
53
+ def argument(value)
54
+ case value
55
+ when String then Expression.dynamic?(value) ? interpolate(value) : value
56
+ when Array then value.map { |item| argument(item) }
57
+ when Hash
58
+ return resolve(value) if surface.binding?(value) || surface.function_call?(value)
59
+
60
+ value.transform_values { |item| argument(item) }
61
+ else value
62
+ end
63
+ end
64
+
65
+ # Calls a function by name.
66
+ #
67
+ # @param name [String]
68
+ # @param args [Hash, nil] raw arguments (resolved here unless `resolved:`)
69
+ # @param resolved [Boolean] whether the arguments are already resolved
70
+ # @return [Object, nil]
71
+ def call(name, args, resolved: false)
72
+ args = (args.is_a?(Hash) ? args : {}).to_h { |key, value| [key.to_s, resolved ? value : argument(value)] }
73
+ return index_of(args) if name == "@index"
74
+
75
+ surface.catalog.functions.call(name.to_s, args, self)
76
+ rescue Functions::Error, Expression::SyntaxError => e
77
+ fail!("function #{name.inspect}: #{e.message}")
78
+ end
79
+
80
+ # Interpolates a `formatString` template.
81
+ #
82
+ # @param text [String]
83
+ # @return [String]
84
+ def interpolate(text)
85
+ evaluate(Expression.parse(text))
86
+ rescue Expression::SyntaxError => e
87
+ fail!("formatString: #{e.message}")
88
+ ""
89
+ end
90
+
91
+ # Evaluates a parsed expression node.
92
+ #
93
+ # @param node [Array]
94
+ # @return [Object, nil]
95
+ def evaluate(node)
96
+ case node[0]
97
+ when :text, :literal then node[1]
98
+ when :path then surface.read(node[1], scope)
99
+ when :call then call(node[1], node[2].transform_values { |item| evaluate(item) }, resolved: true)
100
+ when :template then node[1].map { |item| stringify(evaluate(item)) }.join
101
+ end
102
+ end
103
+
104
+ # The string a value displays as (the spec's conversion rules: nil
105
+ # is empty, containers are JSON, whole floats drop their fraction).
106
+ #
107
+ # @param value [Object]
108
+ # @return [String]
109
+ def stringify(value)
110
+ case value
111
+ when nil then ""
112
+ when Float then value.finite? && value == value.floor && value.abs < 1e15 ? value.to_i.to_s : value.to_s
113
+ when Hash, Array then JSON.generate(value)
114
+ else value.to_s
115
+ end
116
+ end
117
+
118
+ # @return [Integer, nil] the collection index the scope carries
119
+ def index
120
+ token = scope.to_s.split("/").last
121
+ token&.match?(/\A\d+\z/) ? token.to_i : nil
122
+ end
123
+
124
+ private
125
+
126
+ def index_of(args)
127
+ position = index or raise Functions::Error, "@index is only available inside a template"
128
+
129
+ position + (Functions.number(args["offset"]) || 0)
130
+ end
131
+
132
+ def fail!(message)
133
+ @on_error&.call(message)
134
+ nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Agent
5
+ module A2UI
6
+ # The `formatString` grammar: literal text with `${...}` blocks, each
7
+ # block a data path, a literal, or a function call with named
8
+ # arguments whose values are expressions again (a bare argument is
9
+ # `value`); `\${` is a literal `${`. Parsing yields a plain tree the
10
+ # {Evaluator} walks:
11
+ #
12
+ # [:template, nodes] the whole string
13
+ # [:text, "literal text"]
14
+ # [:path, "/absolute"] or a relative path
15
+ # [:literal, 12] / [:literal, "quoted"] / [:literal, true]
16
+ # [:call, "formatDate", { "value" => node, "format" => node }]
17
+ #
18
+ # @example
19
+ # Expression.parse("Hi ${/user/name}, ${formatNumber(value: ${/n}, decimals: 1)}")
20
+ module Expression
21
+ # A malformed expression.
22
+ class SyntaxError < StandardError; end
23
+
24
+ # Identifier: function names, argument names, relative path heads.
25
+ IDENT = /[A-Za-z_@][A-Za-z0-9_]*/
26
+ # A number literal not followed by a path or identifier character.
27
+ NUMBER = %r{-?\d+(?:\.\d+)?(?![\w/])}
28
+ # Keyword literals.
29
+ KEYWORDS = { "true" => true, "false" => false, "null" => nil }.freeze
30
+ # Nesting depth beyond which an expression is refused.
31
+ MAX_DEPTH = 32
32
+
33
+ module_function
34
+
35
+ # @param text [String]
36
+ # @return [Array] the `[:template, nodes]` tree
37
+ # @raise [SyntaxError]
38
+ def parse(text)
39
+ Parser.new(text.to_s).template
40
+ end
41
+
42
+ # @param text [String]
43
+ # @return [Boolean] whether the text carries an interpolation block
44
+ def dynamic?(text)
45
+ text.is_a?(String) && text.include?("${")
46
+ end
47
+
48
+ # The recursive-descent parser.
49
+ class Parser
50
+ # @param source [String]
51
+ def initialize(source)
52
+ @source = source
53
+ @index = 0
54
+ @depth = 0
55
+ end
56
+
57
+ # @return [Array] the `[:template, nodes]` tree
58
+ def template
59
+ nodes = []
60
+ buffer = +""
61
+ until eos?
62
+ if peek(3) == "\\${"
63
+ buffer << "${"
64
+ @index += 3
65
+ elsif peek(2) == "${"
66
+ nodes << [:text, buffer] unless buffer.empty?
67
+ buffer = +""
68
+ nodes << block
69
+ else
70
+ buffer << @source[@index]
71
+ @index += 1
72
+ end
73
+ end
74
+ nodes << [:text, buffer] unless buffer.empty?
75
+ [:template, nodes]
76
+ end
77
+
78
+ private
79
+
80
+ # `${` expression `}`
81
+ def block
82
+ expect("${")
83
+ node = expression
84
+ skip_space
85
+ expect("}")
86
+ node
87
+ end
88
+
89
+ def expression
90
+ @depth += 1
91
+ raise SyntaxError, "expression nested deeper than #{MAX_DEPTH}" if @depth > MAX_DEPTH
92
+
93
+ skip_space
94
+ node = if peek(2) == "${" then block
95
+ elsif (match = scan(/'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"/))
96
+ [:literal, unescape(match[1] || match[2])]
97
+ elsif (match = scan(NUMBER)) then [:literal, number(match[0])]
98
+ elsif (match = scan(%r{(true|false|null)(?![\w/])})) then [:literal, KEYWORDS[match[1]]]
99
+ elsif peek(1) == "/" then [:path, scan(%r</[^\s,)}]*>)[0]]
100
+ elsif (match = scan(IDENT)) then identifier(match[0])
101
+ else raise SyntaxError, "unexpected #{peek(1).inspect} at #{@index}"
102
+ end
103
+ @depth -= 1
104
+ node
105
+ end
106
+
107
+ # A name followed by `(` is a call; otherwise a relative path.
108
+ def identifier(name)
109
+ skip_space
110
+ return [:path, name + (scan(%r<(?:/[^\s,)}/]*)*>)&.[](0) || "")] unless peek(1) == "("
111
+
112
+ @index += 1
113
+ [:call, name, arguments]
114
+ end
115
+
116
+ def arguments
117
+ args = {}
118
+ skip_space
119
+ if peek(1) == ")"
120
+ @index += 1
121
+ return args
122
+ end
123
+ loop do
124
+ skip_space
125
+ named = scan(/(#{IDENT.source})\s*:/)
126
+ args[named ? named[1] : "value"] = expression
127
+ skip_space
128
+ case peek(1)
129
+ when "," then @index += 1
130
+ when ")"
131
+ @index += 1
132
+ return args
133
+ else raise SyntaxError, "expected , or ) at #{@index}"
134
+ end
135
+ end
136
+ end
137
+
138
+ def number(text)
139
+ text.include?(".") ? Float(text) : Integer(text, 10)
140
+ end
141
+
142
+ def unescape(text)
143
+ text.gsub(/\\(.)/) { Regexp.last_match(1) }
144
+ end
145
+
146
+ def scan(pattern)
147
+ match = pattern.match(@source, @index)
148
+ return unless match && match.begin(0) == @index
149
+
150
+ @index = match.end(0)
151
+ match
152
+ end
153
+
154
+ def expect(token)
155
+ raise SyntaxError, "expected #{token.inspect} at #{@index}" unless peek(token.length) == token
156
+
157
+ @index += token.length
158
+ end
159
+
160
+ def peek(length)
161
+ @source[@index, length]
162
+ end
163
+
164
+ def skip_space
165
+ @index += 1 while @source[@index]&.match?(/\s/)
166
+ end
167
+
168
+ def eos?
169
+ @index >= @source.length
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end