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