ruby-utcp 1.1.0 → 1.1.1
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 +4 -4
- data/Makefile +1 -0
- data/README.md +22 -0
- data/examples/code_mode.rb +29 -0
- data/lib/utcp/code_mode.rb +937 -0
- data/lib/utcp/errors.rb +14 -0
- data/lib/utcp/version.rb +1 -1
- data/lib/utcp.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99c878fb7098dd9c213ffa10c01349acf87fb03f6e5742f9214ec077a3502c0b
|
|
4
|
+
data.tar.gz: 0dfd532e70fde83d025aa0a9c6282c460a26b2c333cb917e6951d62ac979c780
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa88cea89f5be786a37ba117ee308e301d760d55b76d9d409be03a1370934cc6b11a534d80b16d5c2c335d0dcc4ea82358ee7dda6a6eaaff768a2f7ecf8f7f68
|
|
7
|
+
data.tar.gz: 6eeb7f901ca0e58de3d9f33f8ca92bf990ea8e547aad89e5b28a17a7337ed491ceef0bf747710fa81c26092706550ebb0407e849e38e1ed3ee0d6dbfb30c312e
|
data/Makefile
CHANGED
data/README.md
CHANGED
|
@@ -289,6 +289,28 @@ client.search_tools("weather forecast", limit: 5, any_of_tags_required: ["weathe
|
|
|
289
289
|
|
|
290
290
|
Pass objects that implement the repository or search interfaces through `tool_repository` and `tool_search_strategy` to replace the defaults.
|
|
291
291
|
|
|
292
|
+
## Code Mode
|
|
293
|
+
|
|
294
|
+
`CodeModeUtcpClient` can run a multi-step Ruby workflow as one call. Tools are invoked through the explicit `codemode` runtime API, and the final expression (or an explicit `return`) becomes the result:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
client = UTCP::CodeModeUtcpClient.create(config: config)
|
|
298
|
+
|
|
299
|
+
execution = client.call_tool_chain(<<~'RUBY', timeout: 30)
|
|
300
|
+
weather = codemode.call_tool("weather_service.get_weather", location: "Warsaw")
|
|
301
|
+
alerts = weather["alerts"].select { |alert| alert["severity"] == "high" }
|
|
302
|
+
puts "Found #{alerts.length} high-severity alerts"
|
|
303
|
+
{ temperature: weather["temperature"], alerts: alerts }
|
|
304
|
+
RUBY
|
|
305
|
+
|
|
306
|
+
puts execution["result"]
|
|
307
|
+
puts execution["logs"]
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Use `codemode.call_tool_stream` to collect a streaming call into an array. `codemode.search_tools`, `codemode.get_tool_interface`, and `codemode.interfaces` provide progressive discovery inside a workflow. `codemode.get(value, key, default)` safely reads dynamic results, and `get_all_tools_ruby_interfaces` provides the tool catalog outside the sandbox.
|
|
311
|
+
|
|
312
|
+
Code Mode accepts a constrained Ruby subset for local variables, JSON-like literals, arithmetic, conditionals, loops, indexing, and common collection transforms. It is interpreted without `eval`; filesystem, process, constant, reflection, import, and direct network APIs are not exposed. Executions also have code-size, step, result-size, log-size, and wall-clock limits. External effects remain possible through the UTCP tools that you deliberately register.
|
|
313
|
+
|
|
292
314
|
## Custom protocol plugins
|
|
293
315
|
|
|
294
316
|
Register a call-template class and a protocol implementation before creating a client:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "utcp"
|
|
5
|
+
|
|
6
|
+
# Reuses examples/servers/http_server.rb so Code Mode can focus on orchestration.
|
|
7
|
+
client = UTCP::CodeModeUtcpClient.create(config: {
|
|
8
|
+
manual_call_templates: [{
|
|
9
|
+
name: "rest",
|
|
10
|
+
call_template_type: "http",
|
|
11
|
+
url: ENV.fetch("UTCP_HTTP_MANUAL", "http://localhost:8080/utcp"),
|
|
12
|
+
http_method: "GET"
|
|
13
|
+
}]
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
execution = client.call_tool_chain(<<~'RUBY', timeout: 10)
|
|
17
|
+
matches = codemode.search_tools("echo", limit: 1)
|
|
18
|
+
tool_name = matches.first["name"]
|
|
19
|
+
|
|
20
|
+
first = codemode.call_tool(tool_name, body: { message: "Hello from Code Mode" })
|
|
21
|
+
second = codemode.call_tool(tool_name, body: { message: first["message"].upcase })
|
|
22
|
+
|
|
23
|
+
messages = [first, second].map { |response| response["message"] }
|
|
24
|
+
puts "Called #{tool_name} #{messages.length} times"
|
|
25
|
+
|
|
26
|
+
{ tool: tool_name, messages: messages }
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
puts JSON.pretty_generate(execution)
|
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ripper"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module UTCP
|
|
8
|
+
# Executes a deliberately small, non-eval Ruby subset for composing UTCP tools.
|
|
9
|
+
# The interpreter only exposes JSON-like values and explicit tool helpers.
|
|
10
|
+
class CodeMode
|
|
11
|
+
DEFAULT_TIMEOUT = 30
|
|
12
|
+
DEFAULT_MAX_STEPS = 100_000
|
|
13
|
+
MAX_CODE_BYTES = 64 * 1024
|
|
14
|
+
MAX_LOG_BYTES = 1024 * 1024
|
|
15
|
+
MAX_VALUE_BYTES = 1024 * 1024
|
|
16
|
+
MAX_VALUE_ITEMS = 10_000
|
|
17
|
+
|
|
18
|
+
attr_reader :client
|
|
19
|
+
|
|
20
|
+
def initialize(client)
|
|
21
|
+
@client = client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def execute(code, timeout: DEFAULT_TIMEOUT, max_steps: DEFAULT_MAX_STEPS)
|
|
25
|
+
source = String(code)
|
|
26
|
+
raise CodeModeLimitError, "Code exceeds #{MAX_CODE_BYTES} bytes" if source.bytesize > MAX_CODE_BYTES
|
|
27
|
+
|
|
28
|
+
seconds = Float(timeout)
|
|
29
|
+
raise CodeModeLimitError, "timeout must be greater than zero" unless seconds.positive?
|
|
30
|
+
|
|
31
|
+
evaluator = Evaluator.new(
|
|
32
|
+
client,
|
|
33
|
+
interfaces: interfaces,
|
|
34
|
+
timeout: seconds,
|
|
35
|
+
max_steps: Integer(max_steps)
|
|
36
|
+
)
|
|
37
|
+
result = Timeout.timeout(seconds, CodeModeTimeoutError) { evaluator.execute(source) }
|
|
38
|
+
{ "result" => result, "logs" => evaluator.logs.dup }
|
|
39
|
+
rescue CodeModeTimeoutError
|
|
40
|
+
raise CodeModeTimeoutError, "Code Mode execution exceeded #{timeout} seconds"
|
|
41
|
+
rescue CodeModeExecutionError => error
|
|
42
|
+
logs = defined?(evaluator) && evaluator ? evaluator.logs : error.logs
|
|
43
|
+
raise CodeModeExecutionError.new(error.message, logs: logs)
|
|
44
|
+
rescue CodeModeError
|
|
45
|
+
raise
|
|
46
|
+
rescue StandardError => error
|
|
47
|
+
logs = defined?(evaluator) && evaluator ? evaluator.logs : []
|
|
48
|
+
raise CodeModeExecutionError.new("Code Mode execution failed: #{error.message}", logs: logs)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def interfaces
|
|
52
|
+
InterfaceGenerator.new(client.list_tools).render
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tool_interface(name)
|
|
56
|
+
tool = client.list_tools.find { |candidate| candidate.name == name.to_s }
|
|
57
|
+
raise ToolNotFoundError, "Tool not found: #{name}" unless tool
|
|
58
|
+
|
|
59
|
+
InterfaceGenerator.tool_descriptor(tool)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class InterfaceGenerator
|
|
63
|
+
RUBY_KEYWORDS = %w[
|
|
64
|
+
BEGIN END alias and begin break case class def defined? do else elsif end ensure false
|
|
65
|
+
for if in module next nil not or redo rescue retry return self super then true undef
|
|
66
|
+
unless until when while yield
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
def tool_descriptor(tool)
|
|
71
|
+
{
|
|
72
|
+
"name" => tool.name,
|
|
73
|
+
"description" => tool.description,
|
|
74
|
+
"inputs" => tool.inputs.to_h,
|
|
75
|
+
"outputs" => tool.outputs.to_h,
|
|
76
|
+
"tags" => tool.tags.dup
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def identifier(value)
|
|
81
|
+
name = value.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
82
|
+
name = "_#{name}" if name.match?(/\A\d/)
|
|
83
|
+
name = "_#{name}" if RUBY_KEYWORDS.include?(name)
|
|
84
|
+
name.empty? ? "_" : name
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ruby_type(schema)
|
|
88
|
+
value = schema.is_a?(Hash) ? schema : {}
|
|
89
|
+
case value["type"] || value[:type]
|
|
90
|
+
when "string" then "String"
|
|
91
|
+
when "integer" then "Integer"
|
|
92
|
+
when "number" then "Numeric"
|
|
93
|
+
when "boolean" then "Boolean"
|
|
94
|
+
when "array" then "Array"
|
|
95
|
+
when "object" then "Hash"
|
|
96
|
+
when Array then (value["type"] || value[:type]).map { |type| ruby_type("type" => type) }.join(" | ")
|
|
97
|
+
else "Object"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def initialize(tools)
|
|
103
|
+
@tools = tools.sort_by(&:name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render
|
|
107
|
+
return "# No UTCP tools are registered." if @tools.empty?
|
|
108
|
+
|
|
109
|
+
@tools.map { |tool| render_tool(tool) }.join("\n\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def render_tool(tool)
|
|
115
|
+
descriptor = self.class.tool_descriptor(tool)
|
|
116
|
+
schema = descriptor["inputs"]
|
|
117
|
+
properties = schema.fetch("properties", {})
|
|
118
|
+
required = Array(schema["required"])
|
|
119
|
+
parameters = properties.map do |name, property|
|
|
120
|
+
suffix = required.include?(name.to_s) ? ":" : ": nil"
|
|
121
|
+
"#{self.class.identifier(name)}#{suffix}"
|
|
122
|
+
end
|
|
123
|
+
signature = parameters.empty? ? "" : parameters.join(", ")
|
|
124
|
+
description = descriptor["description"].to_s.strip
|
|
125
|
+
lines = []
|
|
126
|
+
lines << "# #{description}" unless description.empty?
|
|
127
|
+
unless properties.empty?
|
|
128
|
+
parameter_docs = properties.map do |name, property|
|
|
129
|
+
requirement = required.include?(name.to_s) ? "required" : "optional"
|
|
130
|
+
"#{self.class.identifier(name)} (#{self.class.ruby_type(property)}, #{requirement})"
|
|
131
|
+
end
|
|
132
|
+
lines << "# Parameters: #{parameter_docs.join(', ')}"
|
|
133
|
+
end
|
|
134
|
+
lines << "# Returns: #{self.class.ruby_type(descriptor["outputs"])}"
|
|
135
|
+
separator = signature.empty? ? "" : ", "
|
|
136
|
+
lines << "codemode.call_tool(#{descriptor["name"].inspect}#{separator}#{signature})"
|
|
137
|
+
lines.join("\n")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class Evaluator
|
|
142
|
+
ReturnSignal = Class.new(StandardError) do
|
|
143
|
+
attr_reader :value
|
|
144
|
+
|
|
145
|
+
def initialize(value)
|
|
146
|
+
@value = value
|
|
147
|
+
super()
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
BreakSignal = Class.new(StandardError) do
|
|
151
|
+
attr_reader :value
|
|
152
|
+
|
|
153
|
+
def initialize(value = nil)
|
|
154
|
+
@value = value
|
|
155
|
+
super()
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
NextSignal = Class.new(StandardError) do
|
|
159
|
+
attr_reader :value
|
|
160
|
+
|
|
161
|
+
def initialize(value = nil)
|
|
162
|
+
@value = value
|
|
163
|
+
super()
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
RuntimeAPI = Class.new
|
|
168
|
+
Block = Struct.new(:evaluator, :parameters, :body) do
|
|
169
|
+
def call(*values)
|
|
170
|
+
scope = {}
|
|
171
|
+
if parameters.length == 1 && values.length > 1
|
|
172
|
+
scope[parameters.first] = values
|
|
173
|
+
else
|
|
174
|
+
parameters.each_with_index { |name, index| scope[name] = values[index] }
|
|
175
|
+
end
|
|
176
|
+
evaluator.with_scope(scope) { evaluator.evaluate_statements(body) }
|
|
177
|
+
rescue NextSignal => signal
|
|
178
|
+
signal.value
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
SAFE_ENUMERABLE_METHODS = %w[
|
|
183
|
+
all? any? collect count each filter find map none? reduce inject reject select sort_by
|
|
184
|
+
].freeze
|
|
185
|
+
SAFE_VALUE_METHODS = %w[
|
|
186
|
+
abs ceil compact dig downcase drop empty? end_with? even? fetch first flatten floor
|
|
187
|
+
has_key? include? inspect join key? keys last length max merge min negative? odd? positive?
|
|
188
|
+
nil? reverse round size slice sort split start_with? strip sum take to_a to_f to_h to_i to_s
|
|
189
|
+
uniq upcase values zero?
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
192
|
+
attr_reader :logs
|
|
193
|
+
|
|
194
|
+
def initialize(client, interfaces:, timeout:, max_steps:)
|
|
195
|
+
raise CodeModeLimitError, "max_steps must be greater than zero" unless max_steps.positive?
|
|
196
|
+
|
|
197
|
+
@client = client
|
|
198
|
+
@interfaces = interfaces
|
|
199
|
+
@deadline = monotonic_now + timeout
|
|
200
|
+
@max_steps = max_steps
|
|
201
|
+
@steps = 0
|
|
202
|
+
@logs = []
|
|
203
|
+
@log_bytes = 0
|
|
204
|
+
@scopes = [{ "codemode" => RuntimeAPI.new }]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def execute(source)
|
|
208
|
+
tree = Ripper.sexp(source)
|
|
209
|
+
raise CodeModeSyntaxError, "Invalid Ruby syntax" unless tree
|
|
210
|
+
|
|
211
|
+
safe_tool_value(evaluate(tree))
|
|
212
|
+
rescue ReturnSignal => signal
|
|
213
|
+
safe_tool_value(signal.value)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def evaluate(node)
|
|
217
|
+
tick!
|
|
218
|
+
return nil if node.nil?
|
|
219
|
+
return node unless node.is_a?(Array)
|
|
220
|
+
return evaluate_statements(node) unless node.first.is_a?(Symbol)
|
|
221
|
+
|
|
222
|
+
type = node.first
|
|
223
|
+
case type
|
|
224
|
+
when :program then evaluate_statements(node[1])
|
|
225
|
+
when :void_stmt then nil
|
|
226
|
+
when :assign then assign(node[1], evaluate(node[2]))
|
|
227
|
+
when :opassign then evaluate_opassign(node)
|
|
228
|
+
when :var_ref then evaluate_variable_token(node[1])
|
|
229
|
+
when :vcall then evaluate_vcall(node[1])
|
|
230
|
+
when :fcall then call_function(token_value(node[1]), [], nil)
|
|
231
|
+
when :@int then Integer(node[1], 10)
|
|
232
|
+
when :@float then Float(node[1])
|
|
233
|
+
when :@kw then evaluate_keyword(node[1])
|
|
234
|
+
when :string_literal then evaluate(node[1]).to_s
|
|
235
|
+
when :string_content then node.drop(1).map { |part| evaluate_string_part(part) }.join
|
|
236
|
+
when :symbol_literal then evaluate_symbol(node[1])
|
|
237
|
+
when :array then Array(node[1]).map { |item| evaluate(item) }
|
|
238
|
+
when :hash then evaluate_hash(node[1])
|
|
239
|
+
when :bare_assoc_hash then evaluate_associations(node[1])
|
|
240
|
+
when :paren then evaluate_statements(node[1])
|
|
241
|
+
when :binary then evaluate_binary(node[1], node[2], node[3])
|
|
242
|
+
when :unary then evaluate_unary(node[1], node[2])
|
|
243
|
+
when :dot2 then Range.new(evaluate(node[1]), evaluate(node[2]), false)
|
|
244
|
+
when :dot3 then Range.new(evaluate(node[1]), evaluate(node[2]), true)
|
|
245
|
+
when :aref then safe_index(evaluate(node[1]), extract_arguments(node[2]))
|
|
246
|
+
when :call, :method_add_arg, :command, :command_call then evaluate_call(node)
|
|
247
|
+
when :method_add_block then evaluate_call(node[1], build_block(node[2]))
|
|
248
|
+
when :begin then evaluate(node[1])
|
|
249
|
+
when :bodystmt then evaluate_body_statement(node)
|
|
250
|
+
when :rescue_mod then evaluate_rescue_modifier(node)
|
|
251
|
+
when :if then evaluate_if(node[1], node[2], node[3])
|
|
252
|
+
when :unless then evaluate_unless(node[1], node[2], node[3])
|
|
253
|
+
when :if_mod then truthy?(evaluate(node[1])) ? evaluate(node[2]) : nil
|
|
254
|
+
when :unless_mod then truthy?(evaluate(node[1])) ? nil : evaluate(node[2])
|
|
255
|
+
when :while then evaluate_loop(node[1], node[2], until_condition: false)
|
|
256
|
+
when :until then evaluate_loop(node[1], node[2], until_condition: true)
|
|
257
|
+
when :return then raise ReturnSignal, return_value(node[1])
|
|
258
|
+
when :return0 then raise ReturnSignal, nil
|
|
259
|
+
when :break then raise BreakSignal, return_value(node[1])
|
|
260
|
+
when :next then raise NextSignal, return_value(node[1])
|
|
261
|
+
else
|
|
262
|
+
unsupported!(node)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def evaluate_statements(statements)
|
|
267
|
+
Array(statements).reduce(nil) { |_result, statement| evaluate(statement) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def with_scope(scope)
|
|
271
|
+
@scopes << scope
|
|
272
|
+
yield
|
|
273
|
+
ensure
|
|
274
|
+
@scopes.pop
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def tick!
|
|
280
|
+
@steps += 1
|
|
281
|
+
raise CodeModeLimitError, "Code Mode step limit exceeded" if @steps > @max_steps
|
|
282
|
+
raise CodeModeTimeoutError, "Code Mode execution timed out" if monotonic_now >= @deadline
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def monotonic_now
|
|
286
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def unsupported!(node)
|
|
290
|
+
line = find_line(node)
|
|
291
|
+
suffix = line ? " at line #{line}" : ""
|
|
292
|
+
raise CodeModeSyntaxError, "Unsupported Ruby construct #{node.first.inspect}#{suffix}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def find_line(node)
|
|
296
|
+
return node[2][0] if node.is_a?(Array) && node.first.to_s.start_with?("@") && node[2].is_a?(Array)
|
|
297
|
+
return nil unless node.is_a?(Array)
|
|
298
|
+
|
|
299
|
+
node.each do |child|
|
|
300
|
+
line = find_line(child)
|
|
301
|
+
return line if line
|
|
302
|
+
end
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def token_value(token)
|
|
307
|
+
token.is_a?(Array) ? token[1].to_s : token.to_s
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def evaluate_keyword(keyword)
|
|
311
|
+
case keyword
|
|
312
|
+
when "true" then true
|
|
313
|
+
when "false" then false
|
|
314
|
+
when "nil" then nil
|
|
315
|
+
else raise CodeModeSyntaxError, "Unsupported keyword #{keyword.inspect}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def evaluate_string_part(part)
|
|
320
|
+
return part[1] if part.first == :@tstring_content
|
|
321
|
+
return evaluate_statements(part[1]).to_s if part.first == :string_embexpr
|
|
322
|
+
|
|
323
|
+
unsupported!(part)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def evaluate_symbol(node)
|
|
327
|
+
token = node.first == :symbol ? node[1] : node
|
|
328
|
+
token_value(token).to_sym
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def evaluate_hash(contents)
|
|
332
|
+
return {} unless contents
|
|
333
|
+
return evaluate_associations(contents[1]) if contents.first == :assoclist_from_args
|
|
334
|
+
|
|
335
|
+
unsupported!(contents)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def evaluate_associations(associations)
|
|
339
|
+
Array(associations).each_with_object({}) do |association, result|
|
|
340
|
+
unsupported!(association) unless association.first == :assoc_new
|
|
341
|
+
key_node = association[1]
|
|
342
|
+
key = key_node.first == :@label ? key_node[1].sub(/:\z/, "") : evaluate(key_node)
|
|
343
|
+
result[key] = evaluate(association[2])
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def evaluate_variable_token(token)
|
|
348
|
+
return evaluate_keyword(token[1]) if token.first == :@kw
|
|
349
|
+
lookup(token_value(token))
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def evaluate_vcall(token)
|
|
353
|
+
name = token_value(token)
|
|
354
|
+
value = lookup(name, missing: :sentinel)
|
|
355
|
+
return value unless value == :sentinel
|
|
356
|
+
|
|
357
|
+
call_function(name, [], nil)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def lookup(name, missing: nil)
|
|
361
|
+
@scopes.reverse_each { |scope| return scope[name] if scope.key?(name) }
|
|
362
|
+
return missing if missing == :sentinel
|
|
363
|
+
|
|
364
|
+
raise CodeModeExecutionError, "Undefined local variable #{name.inspect}"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def assign(target, value)
|
|
368
|
+
case target.first
|
|
369
|
+
when :var_field
|
|
370
|
+
name = token_value(target[1])
|
|
371
|
+
scope = @scopes.reverse.find { |candidate| candidate.key?(name) } || @scopes.last
|
|
372
|
+
scope[name] = value
|
|
373
|
+
when :aref_field
|
|
374
|
+
receiver = evaluate(target[1])
|
|
375
|
+
arguments = extract_arguments(target[2])
|
|
376
|
+
raise CodeModeSyntaxError, "Indexed assignment requires exactly one key" unless arguments.length == 1
|
|
377
|
+
raise CodeModeSyntaxError, "Indexed assignment is only allowed on arrays and hashes" unless receiver.is_a?(Array) || receiver.is_a?(Hash)
|
|
378
|
+
|
|
379
|
+
receiver[arguments.first] = value
|
|
380
|
+
else
|
|
381
|
+
unsupported!(target)
|
|
382
|
+
end
|
|
383
|
+
value
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def evaluate_opassign(node)
|
|
387
|
+
target = node[1]
|
|
388
|
+
operator = token_value(node[2]).sub(/=\z/, "").to_sym
|
|
389
|
+
current = read_assignment_target(target)
|
|
390
|
+
assign(target, apply_binary(current, operator, evaluate(node[3])))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def read_assignment_target(target)
|
|
394
|
+
case target.first
|
|
395
|
+
when :var_field then lookup(token_value(target[1]))
|
|
396
|
+
when :aref_field then safe_index(evaluate(target[1]), extract_arguments(target[2]))
|
|
397
|
+
else unsupported!(target)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def evaluate_binary(left_node, operator, right_node)
|
|
402
|
+
left = evaluate(left_node)
|
|
403
|
+
return left unless truthy?(left) if [:'&&', :and].include?(operator)
|
|
404
|
+
return left if truthy?(left) if [:'||', :or].include?(operator)
|
|
405
|
+
|
|
406
|
+
apply_binary(left, operator, evaluate(right_node))
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def apply_binary(left, operator, right)
|
|
410
|
+
preflight_binary_size!(left, operator, right)
|
|
411
|
+
value = case operator
|
|
412
|
+
when :+ then left + right
|
|
413
|
+
when :- then left - right
|
|
414
|
+
when :* then left * right
|
|
415
|
+
when :/ then left / right
|
|
416
|
+
when :% then left % right
|
|
417
|
+
when :** then left**right
|
|
418
|
+
when :== then left == right
|
|
419
|
+
when :!= then left != right
|
|
420
|
+
when :< then left < right
|
|
421
|
+
when :<= then left <= right
|
|
422
|
+
when :> then left > right
|
|
423
|
+
when :>= then left >= right
|
|
424
|
+
when :<=> then left <=> right
|
|
425
|
+
when :'&&', :and, :'||', :or then right
|
|
426
|
+
else raise CodeModeSyntaxError, "Unsupported operator #{operator.inspect}"
|
|
427
|
+
end
|
|
428
|
+
bounded_value!(value)
|
|
429
|
+
rescue NoMethodError, TypeError, ArgumentError, ZeroDivisionError => error
|
|
430
|
+
raise CodeModeExecutionError, "Invalid #{operator} operation: #{error.message}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def evaluate_unary(operator, operand_node)
|
|
434
|
+
operand = evaluate(operand_node)
|
|
435
|
+
case operator
|
|
436
|
+
when :! then !truthy?(operand)
|
|
437
|
+
when :+@ then +operand
|
|
438
|
+
when :-@ then -operand
|
|
439
|
+
else raise CodeModeSyntaxError, "Unsupported unary operator #{operator.inspect}"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def truthy?(value)
|
|
444
|
+
!value.nil? && value != false
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def evaluate_if(condition, truthy_statements, alternative)
|
|
448
|
+
if truthy?(evaluate(condition))
|
|
449
|
+
evaluate_statements(truthy_statements)
|
|
450
|
+
elsif alternative&.first == :else
|
|
451
|
+
evaluate_statements(alternative[1])
|
|
452
|
+
elsif alternative&.first == :elsif
|
|
453
|
+
evaluate_if(alternative[1], alternative[2], alternative[3])
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def evaluate_unless(condition, statements, alternative)
|
|
458
|
+
unless truthy?(evaluate(condition))
|
|
459
|
+
evaluate_statements(statements)
|
|
460
|
+
else
|
|
461
|
+
evaluate_statements(alternative[1]) if alternative&.first == :else
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def evaluate_body_statement(node)
|
|
466
|
+
statements, rescue_clause, else_clause, ensure_clause = node.drop(1)
|
|
467
|
+
completed = false
|
|
468
|
+
result = begin
|
|
469
|
+
value = evaluate_statements(statements)
|
|
470
|
+
completed = true
|
|
471
|
+
value
|
|
472
|
+
rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
|
|
473
|
+
raise
|
|
474
|
+
rescue StandardError => error
|
|
475
|
+
raise unless rescue_clause
|
|
476
|
+
evaluate_rescue_clause(rescue_clause, error)
|
|
477
|
+
ensure
|
|
478
|
+
evaluate_statements(ensure_clause[1]) if ensure_clause&.first == :ensure
|
|
479
|
+
end
|
|
480
|
+
completed && else_clause ? evaluate_statements(else_clause) : result
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def evaluate_rescue_clause(node, error)
|
|
484
|
+
unsupported!(node) unless node.first == :rescue
|
|
485
|
+
raise CodeModeSyntaxError, "Code Mode rescue does not accept exception classes" if node[1]
|
|
486
|
+
|
|
487
|
+
scope = {}
|
|
488
|
+
assign_rescue_variable(scope, node[2], error) if node[2]
|
|
489
|
+
with_scope(scope) { evaluate_statements(node[3]) }
|
|
490
|
+
rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
|
|
491
|
+
raise
|
|
492
|
+
rescue StandardError => nested
|
|
493
|
+
next_clause = node[4]
|
|
494
|
+
raise nested unless next_clause&.first == :rescue
|
|
495
|
+
evaluate_rescue_clause(next_clause, nested)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def assign_rescue_variable(scope, target, error)
|
|
499
|
+
unsupported!(target) unless target.first == :var_field
|
|
500
|
+
scope[token_value(target[1])] = {
|
|
501
|
+
"message" => error.message,
|
|
502
|
+
"type" => error.class.name
|
|
503
|
+
}
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def evaluate_rescue_modifier(node)
|
|
507
|
+
evaluate(node[1])
|
|
508
|
+
rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
|
|
509
|
+
raise
|
|
510
|
+
rescue StandardError
|
|
511
|
+
evaluate(node[2])
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def evaluate_loop(condition, statements, until_condition:)
|
|
515
|
+
result = nil
|
|
516
|
+
loop do
|
|
517
|
+
matches = truthy?(evaluate(condition))
|
|
518
|
+
break if until_condition ? matches : !matches
|
|
519
|
+
|
|
520
|
+
begin
|
|
521
|
+
result = evaluate_statements(statements)
|
|
522
|
+
rescue NextSignal => signal
|
|
523
|
+
result = signal.value
|
|
524
|
+
rescue BreakSignal => signal
|
|
525
|
+
return signal.value
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
result
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def return_value(arguments_node)
|
|
532
|
+
arguments = extract_arguments(arguments_node)
|
|
533
|
+
arguments.length <= 1 ? arguments.first : arguments
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def extract_arguments(node)
|
|
537
|
+
return [] if node.nil?
|
|
538
|
+
case node.first
|
|
539
|
+
when :arg_paren then extract_arguments(node[1])
|
|
540
|
+
when :args_add_block then Array(node[1]).map { |argument| evaluate(argument) }
|
|
541
|
+
when :args_new then []
|
|
542
|
+
when :args_add then extract_arguments(node[1]) + [evaluate(node[2])]
|
|
543
|
+
else [evaluate(node)]
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def build_block(node)
|
|
548
|
+
unsupported!(node) unless %i[brace_block do_block].include?(node.first)
|
|
549
|
+
parameters = extract_block_parameters(node[1])
|
|
550
|
+
Block.new(self, parameters, node[2])
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def extract_block_parameters(node)
|
|
554
|
+
return [] unless node
|
|
555
|
+
params = node.first == :block_var ? node[1] : node
|
|
556
|
+
unsupported!(params) unless params&.first == :params
|
|
557
|
+
required = Array(params[1])
|
|
558
|
+
unsupported!(params) unless params.drop(2).all?(&:nil?)
|
|
559
|
+
|
|
560
|
+
required.map { |token| token_value(token) }
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def evaluate_call(node, block = nil)
|
|
564
|
+
case node.first
|
|
565
|
+
when :method_add_arg
|
|
566
|
+
invoke_call_target(node[1], extract_arguments(node[2]), block)
|
|
567
|
+
when :call
|
|
568
|
+
invoke_method(evaluate(node[1]), token_value(node[3]), [], block)
|
|
569
|
+
when :fcall
|
|
570
|
+
call_function(token_value(node[1]), [], block)
|
|
571
|
+
when :vcall
|
|
572
|
+
evaluate_vcall(node[1])
|
|
573
|
+
when :command
|
|
574
|
+
call_function(token_value(node[1]), extract_arguments(node[2]), block)
|
|
575
|
+
when :command_call
|
|
576
|
+
invoke_method(evaluate(node[1]), token_value(node[3]), extract_arguments(node[4]), block)
|
|
577
|
+
else
|
|
578
|
+
unsupported!(node)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def invoke_call_target(target, arguments, block)
|
|
583
|
+
case target.first
|
|
584
|
+
when :call
|
|
585
|
+
invoke_method(evaluate(target[1]), token_value(target[3]), arguments, block)
|
|
586
|
+
when :fcall
|
|
587
|
+
call_function(token_value(target[1]), arguments, block)
|
|
588
|
+
when :vcall
|
|
589
|
+
call_function(token_value(target[1]), arguments, block)
|
|
590
|
+
else
|
|
591
|
+
unsupported!(target)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def call_function(name, arguments, block)
|
|
596
|
+
case name
|
|
597
|
+
when "puts", "print", "p", "warn"
|
|
598
|
+
log(name, arguments)
|
|
599
|
+
nil
|
|
600
|
+
else
|
|
601
|
+
hint = name == "call_tool" ? "; use codemode.call_tool(...)" : ""
|
|
602
|
+
raise CodeModeSyntaxError, "Function #{name.inspect} is not available in Code Mode#{hint}"
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def invoke_method(receiver, method_name, arguments, block)
|
|
607
|
+
return invoke_runtime_api(method_name, arguments, block) if receiver.is_a?(RuntimeAPI)
|
|
608
|
+
return receiver[method_name] if receiver.is_a?(Hash) && arguments.empty? && !block && receiver.key?(method_name)
|
|
609
|
+
|
|
610
|
+
unless SAFE_ENUMERABLE_METHODS.include?(method_name) || SAFE_VALUE_METHODS.include?(method_name)
|
|
611
|
+
raise CodeModeSyntaxError, "Method #{method_name.inspect} is not available in Code Mode"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
invoke_safe_value_method(receiver, method_name, arguments, block)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def invoke_runtime_api(method_name, arguments, block)
|
|
618
|
+
raise CodeModeSyntaxError, "codemode.#{method_name} does not accept a block" if block
|
|
619
|
+
|
|
620
|
+
case method_name
|
|
621
|
+
when "call_tool"
|
|
622
|
+
require_arity!("codemode.call_tool", arguments, 1..2)
|
|
623
|
+
safe_tool_value(@client.call_tool(arguments[0], tool_arguments(arguments[1])))
|
|
624
|
+
when "call_tool_stream", "call_tool_streaming"
|
|
625
|
+
require_arity!("codemode.#{method_name}", arguments, 1..2)
|
|
626
|
+
safe_tool_value(@client.call_tool_streaming(arguments[0], tool_arguments(arguments[1])).to_a)
|
|
627
|
+
when "search_tools"
|
|
628
|
+
require_arity!("codemode.search_tools", arguments, 1..2)
|
|
629
|
+
options = arguments[1].is_a?(Hash) ? arguments[1] : {}
|
|
630
|
+
limit = arguments[1].is_a?(Numeric) ? arguments[1] : options.fetch("limit", 10)
|
|
631
|
+
safe_tool_value(@client.search_tools(arguments[0].to_s, limit: Integer(limit)).map do |tool|
|
|
632
|
+
InterfaceGenerator.tool_descriptor(tool)
|
|
633
|
+
end)
|
|
634
|
+
when "get_tool_interface"
|
|
635
|
+
require_arity!("codemode.get_tool_interface", arguments, 1)
|
|
636
|
+
tool = @client.list_tools.find { |candidate| candidate.name == arguments.first.to_s }
|
|
637
|
+
raise ToolNotFoundError, "Tool not found: #{arguments.first}" unless tool
|
|
638
|
+
safe_tool_value(InterfaceGenerator.tool_descriptor(tool))
|
|
639
|
+
when "interfaces"
|
|
640
|
+
require_arity!("codemode.interfaces", arguments, 0)
|
|
641
|
+
@interfaces.dup
|
|
642
|
+
when "get"
|
|
643
|
+
runtime_get(arguments)
|
|
644
|
+
else
|
|
645
|
+
raise CodeModeSyntaxError, "Method #{method_name.inspect} is not available on codemode"
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def runtime_get(arguments)
|
|
650
|
+
require_arity!("codemode.get", arguments, 2..3)
|
|
651
|
+
receiver, key, default = arguments
|
|
652
|
+
return receiver.fetch(key, default) if receiver.is_a?(Hash)
|
|
653
|
+
return receiver.fetch(Integer(key), default) if receiver.is_a?(Array)
|
|
654
|
+
return receiver[key] || default if receiver.is_a?(String)
|
|
655
|
+
|
|
656
|
+
raise CodeModeExecutionError, "codemode.get requires a hash, array, or string"
|
|
657
|
+
rescue IndexError, TypeError
|
|
658
|
+
default
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def invoke_safe_value_method(receiver, method_name, arguments, block)
|
|
662
|
+
return invoke_enumerable(receiver, method_name, arguments, block) if SAFE_ENUMERABLE_METHODS.include?(method_name)
|
|
663
|
+
|
|
664
|
+
case method_name
|
|
665
|
+
when "length", "size" then require_arity!(method_name, arguments, 0).then { receiver.respond_to?(:length) ? receiver.length : invalid_receiver!(receiver, method_name) }
|
|
666
|
+
when "empty?" then require_arity!(method_name, arguments, 0).then { receiver.respond_to?(:empty?) ? receiver.empty? : invalid_receiver!(receiver, method_name) }
|
|
667
|
+
when "nil?" then require_arity!(method_name, arguments, 0).then { receiver.nil? }
|
|
668
|
+
when "to_s" then require_arity!(method_name, arguments, 0).then { receiver.to_s }
|
|
669
|
+
when "inspect" then require_arity!(method_name, arguments, 0).then { receiver.inspect }
|
|
670
|
+
when "to_i" then require_arity!(method_name, arguments, 0).then { receiver.to_i }
|
|
671
|
+
when "to_f" then require_arity!(method_name, arguments, 0).then { receiver.to_f }
|
|
672
|
+
when "to_a" then require_arity!(method_name, arguments, 0).then { safe_to_a(receiver) }
|
|
673
|
+
when "to_h" then require_arity!(method_name, arguments, 0).then { receiver.is_a?(Hash) ? receiver.dup : invalid_receiver!(receiver, method_name) }
|
|
674
|
+
when "first" then require_arity!(method_name, arguments, 0..1).then { enumerable_value!(receiver, method_name).first(*arguments) }
|
|
675
|
+
when "last" then require_arity!(method_name, arguments, 0..1).then { enumerable_value!(receiver, method_name).last(*arguments) }
|
|
676
|
+
when "take" then require_arity!(method_name, arguments, 1).then { enumerable_value!(receiver, method_name).take(Integer(arguments[0])) }
|
|
677
|
+
when "drop" then require_arity!(method_name, arguments, 1).then { enumerable_value!(receiver, method_name).drop(Integer(arguments[0])) }
|
|
678
|
+
when "reverse", "sort", "uniq", "compact", "flatten", "keys", "values", "min", "max", "sum"
|
|
679
|
+
require_arity!(method_name, arguments, 0)
|
|
680
|
+
safe_zero_argument_method(receiver, method_name)
|
|
681
|
+
when "join" then safe_join(require_array!(receiver, method_name), arguments)
|
|
682
|
+
when "split" then bounded_value!(require_string!(receiver, method_name).split(*arguments))
|
|
683
|
+
when "strip", "downcase", "upcase" then require_string!(receiver, method_name).public_send(method_name)
|
|
684
|
+
when "include?" then collection_value!(receiver, method_name).include?(*arguments)
|
|
685
|
+
when "start_with?", "end_with?" then require_string!(receiver, method_name).public_send(method_name, *arguments)
|
|
686
|
+
when "key?", "has_key?" then require_hash!(receiver, method_name).key?(*arguments)
|
|
687
|
+
when "fetch" then indexable_value!(receiver, method_name).fetch(*arguments)
|
|
688
|
+
when "dig" then require_hash!(receiver, method_name).dig(*arguments)
|
|
689
|
+
when "slice" then indexable_value!(receiver, method_name).slice(*arguments)
|
|
690
|
+
when "merge" then bounded_value!(require_hash!(receiver, method_name).merge(require_hash!(arguments.fetch(0), method_name)))
|
|
691
|
+
when "abs", "ceil", "floor", "round", "zero?", "positive?", "negative?", "even?", "odd?"
|
|
692
|
+
require_numeric!(receiver, method_name).public_send(method_name, *arguments)
|
|
693
|
+
else
|
|
694
|
+
invalid_receiver!(receiver, method_name)
|
|
695
|
+
end
|
|
696
|
+
rescue NoMethodError, ArgumentError, TypeError, IndexError, KeyError => error
|
|
697
|
+
raise CodeModeExecutionError, "Invalid #{method_name} call: #{error.message}"
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def invoke_enumerable(receiver, method_name, arguments, block)
|
|
701
|
+
enumerable = enumerable_value!(receiver, method_name)
|
|
702
|
+
case method_name
|
|
703
|
+
when "map", "collect" then require_block!(method_name, block).then { enumerable.map { |*items| tick!; block.call(*block_arguments(enumerable, items)) } }
|
|
704
|
+
when "select", "filter" then require_block!(method_name, block).then { enumerable.select { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
705
|
+
when "reject" then require_block!(method_name, block).then { enumerable.reject { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
706
|
+
when "each"
|
|
707
|
+
require_block!(method_name, block)
|
|
708
|
+
enumerable.each { |*items| tick!; block.call(*block_arguments(enumerable, items)) }
|
|
709
|
+
receiver
|
|
710
|
+
when "find" then require_block!(method_name, block).then { enumerable.find { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
711
|
+
when "any?" then require_block!(method_name, block).then { enumerable.any? { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
712
|
+
when "all?" then require_block!(method_name, block).then { enumerable.all? { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
713
|
+
when "none?" then require_block!(method_name, block).then { enumerable.none? { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } }
|
|
714
|
+
when "count"
|
|
715
|
+
block ? enumerable.count { |*items| tick!; truthy?(block.call(*block_arguments(enumerable, items))) } : enumerable.count(*arguments)
|
|
716
|
+
when "sort_by" then require_block!(method_name, block).then { enumerable.sort_by { |*items| tick!; block.call(*block_arguments(enumerable, items)) } }
|
|
717
|
+
when "reduce", "inject"
|
|
718
|
+
reduce_enumerable(enumerable, arguments, require_block!(method_name, block))
|
|
719
|
+
else invalid_receiver!(receiver, method_name)
|
|
720
|
+
end
|
|
721
|
+
rescue BreakSignal => signal
|
|
722
|
+
signal.value
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def block_arguments(enumerable, items)
|
|
726
|
+
return items.first if enumerable.is_a?(Hash) && items.length == 1 && items.first.is_a?(Array)
|
|
727
|
+
|
|
728
|
+
items
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def reduce_enumerable(enumerable, arguments, block)
|
|
732
|
+
values = enumerable.to_a
|
|
733
|
+
if arguments.empty?
|
|
734
|
+
return nil if values.empty?
|
|
735
|
+
accumulator = values.shift
|
|
736
|
+
else
|
|
737
|
+
require_arity!("reduce", arguments, 1)
|
|
738
|
+
accumulator = arguments.first
|
|
739
|
+
end
|
|
740
|
+
values.each { |item| tick!; accumulator = block.call(accumulator, item) }
|
|
741
|
+
accumulator
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def safe_zero_argument_method(receiver, method_name)
|
|
745
|
+
allowed = case receiver
|
|
746
|
+
when Array then %w[reverse sort uniq compact flatten min max sum]
|
|
747
|
+
when Hash then %w[keys values]
|
|
748
|
+
when Range then %w[min max sum]
|
|
749
|
+
else []
|
|
750
|
+
end
|
|
751
|
+
invalid_receiver!(receiver, method_name) unless allowed.include?(method_name)
|
|
752
|
+
bounded_value!(receiver.public_send(method_name))
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def safe_to_a(receiver)
|
|
756
|
+
return receiver.dup if receiver.is_a?(Array)
|
|
757
|
+
if receiver.is_a?(Range)
|
|
758
|
+
if receiver.begin.is_a?(Integer) && receiver.end.is_a?(Integer)
|
|
759
|
+
size = receiver.end - receiver.begin + (receiver.exclude_end? ? 0 : 1)
|
|
760
|
+
raise CodeModeLimitError, "Range contains too many values" if size > MAX_VALUE_ITEMS
|
|
761
|
+
end
|
|
762
|
+
return bounded_value!(receiver.to_a)
|
|
763
|
+
end
|
|
764
|
+
return receiver.to_a if receiver.is_a?(Hash)
|
|
765
|
+
|
|
766
|
+
invalid_receiver!(receiver, "to_a")
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def safe_index(receiver, arguments)
|
|
770
|
+
raise CodeModeSyntaxError, "Indexing requires one or two arguments" unless (1..2).cover?(arguments.length)
|
|
771
|
+
raise CodeModeExecutionError, "#{receiver.class} values cannot be indexed" unless receiver.is_a?(Array) || receiver.is_a?(Hash) || receiver.is_a?(String)
|
|
772
|
+
|
|
773
|
+
receiver[*arguments]
|
|
774
|
+
rescue TypeError, IndexError => error
|
|
775
|
+
raise CodeModeExecutionError, "Invalid index: #{error.message}"
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def enumerable_value!(receiver, method_name)
|
|
779
|
+
return receiver if receiver.is_a?(Array) || receiver.is_a?(Hash) || receiver.is_a?(Range)
|
|
780
|
+
|
|
781
|
+
invalid_receiver!(receiver, method_name)
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def require_string!(value, method_name)
|
|
785
|
+
return value if value.is_a?(String)
|
|
786
|
+
|
|
787
|
+
invalid_receiver!(value, method_name)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def require_hash!(value, method_name)
|
|
791
|
+
return value if value.is_a?(Hash)
|
|
792
|
+
|
|
793
|
+
invalid_receiver!(value, method_name)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def require_array!(value, method_name)
|
|
797
|
+
return value if value.is_a?(Array)
|
|
798
|
+
|
|
799
|
+
invalid_receiver!(value, method_name)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def collection_value!(value, method_name)
|
|
803
|
+
return value if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String) || value.is_a?(Range)
|
|
804
|
+
|
|
805
|
+
invalid_receiver!(value, method_name)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def indexable_value!(value, method_name)
|
|
809
|
+
return value if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String)
|
|
810
|
+
|
|
811
|
+
invalid_receiver!(value, method_name)
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def require_numeric!(value, method_name)
|
|
815
|
+
return value if value.is_a?(Numeric)
|
|
816
|
+
|
|
817
|
+
invalid_receiver!(value, method_name)
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def safe_join(receiver, arguments)
|
|
821
|
+
separator = arguments.empty? ? "" : arguments.first.to_s
|
|
822
|
+
estimated = receiver.sum { |value| value.to_s.bytesize }
|
|
823
|
+
estimated += separator.bytesize * [receiver.length - 1, 0].max
|
|
824
|
+
raise CodeModeLimitError, "String result exceeds #{MAX_VALUE_BYTES} bytes" if estimated > MAX_VALUE_BYTES
|
|
825
|
+
|
|
826
|
+
receiver.join(*arguments)
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def preflight_binary_size!(left, operator, right)
|
|
830
|
+
if operator == :+ && left.is_a?(String) && right.is_a?(String)
|
|
831
|
+
raise CodeModeLimitError, "String result exceeds #{MAX_VALUE_BYTES} bytes" if left.bytesize + right.bytesize > MAX_VALUE_BYTES
|
|
832
|
+
elsif operator == :+ && left.is_a?(Array) && right.is_a?(Array)
|
|
833
|
+
raise CodeModeLimitError, "Collection result exceeds #{MAX_VALUE_ITEMS} items" if left.length + right.length > MAX_VALUE_ITEMS
|
|
834
|
+
elsif operator == :* && right.is_a?(Integer) && (left.is_a?(String) || left.is_a?(Array))
|
|
835
|
+
size = left.is_a?(String) ? left.bytesize : left.length
|
|
836
|
+
limit = left.is_a?(String) ? MAX_VALUE_BYTES : MAX_VALUE_ITEMS
|
|
837
|
+
raise CodeModeLimitError, "Repeated value exceeds Code Mode limits" if right.positive? && size > limit / right
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def bounded_value!(value)
|
|
842
|
+
if value.is_a?(String) && value.bytesize > MAX_VALUE_BYTES
|
|
843
|
+
raise CodeModeLimitError, "String result exceeds #{MAX_VALUE_BYTES} bytes"
|
|
844
|
+
end
|
|
845
|
+
if (value.is_a?(Array) || value.is_a?(Hash)) && value.length > MAX_VALUE_ITEMS
|
|
846
|
+
raise CodeModeLimitError, "Collection result exceeds #{MAX_VALUE_ITEMS} items"
|
|
847
|
+
end
|
|
848
|
+
value
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def invalid_receiver!(receiver, method_name)
|
|
852
|
+
raise CodeModeExecutionError, "#{method_name} is not supported for #{receiver.class} values"
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def require_block!(method_name, block)
|
|
856
|
+
raise CodeModeSyntaxError, "#{method_name} requires a block" unless block
|
|
857
|
+
block
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def require_arity!(name, arguments, expected)
|
|
861
|
+
valid = expected.is_a?(Range) ? expected.cover?(arguments.length) : arguments.length == expected
|
|
862
|
+
return true if valid
|
|
863
|
+
|
|
864
|
+
raise CodeModeSyntaxError, "#{name} received #{arguments.length} arguments"
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def tool_arguments(value)
|
|
868
|
+
return {} if value.nil?
|
|
869
|
+
raise CodeModeSyntaxError, "Tool arguments must be a hash" unless value.is_a?(Hash)
|
|
870
|
+
|
|
871
|
+
safe_tool_value(value)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def safe_tool_value(value, depth = 0, budget = { items: MAX_VALUE_ITEMS, bytes: MAX_VALUE_BYTES })
|
|
875
|
+
raise CodeModeLimitError, "Tool result nesting is too deep" if depth > 64
|
|
876
|
+
budget[:items] -= 1
|
|
877
|
+
raise CodeModeLimitError, "Tool result contains too many values" if budget[:items].negative?
|
|
878
|
+
|
|
879
|
+
case value
|
|
880
|
+
when String
|
|
881
|
+
budget[:bytes] -= value.bytesize
|
|
882
|
+
raise CodeModeLimitError, "Tool result contains too much string data" if budget[:bytes].negative?
|
|
883
|
+
value.dup
|
|
884
|
+
when nil, true, false, Numeric
|
|
885
|
+
value
|
|
886
|
+
when Array
|
|
887
|
+
value.map { |item| safe_tool_value(item, depth + 1, budget) }
|
|
888
|
+
when Hash
|
|
889
|
+
value.each_with_object({}) do |(key, item), result|
|
|
890
|
+
safe_key = key.is_a?(Symbol) ? key : key.to_s
|
|
891
|
+
result[safe_key] = safe_tool_value(item, depth + 1, budget)
|
|
892
|
+
end
|
|
893
|
+
else
|
|
894
|
+
raise CodeModeExecutionError, "Tool returned unsupported #{value.class} value"
|
|
895
|
+
end
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def log(kind, values)
|
|
899
|
+
rendered = values.map { |value| kind == "p" ? value.inspect : value.to_s }.join(kind == "print" ? "" : " ")
|
|
900
|
+
rendered = "[WARN] #{rendered}" if kind == "warn"
|
|
901
|
+
bytes = rendered.bytesize
|
|
902
|
+
raise CodeModeLimitError, "Code Mode log limit exceeded" if @log_bytes + bytes > MAX_LOG_BYTES
|
|
903
|
+
|
|
904
|
+
@logs << rendered
|
|
905
|
+
@log_bytes += bytes
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
class CodeModeUtcpClient < Client
|
|
912
|
+
AGENT_PROMPT_TEMPLATE = <<~PROMPT.freeze
|
|
913
|
+
Use `codemode.search_tools` before writing a Code Mode program when tool names are unknown.
|
|
914
|
+
Call tools as `codemode.call_tool("manual.tool", key: value)`. Use the exact qualified name.
|
|
915
|
+
Code Mode accepts a constrained Ruby subset: local variables, JSON-like literals, arithmetic,
|
|
916
|
+
conditionals, bounded loops, and common Array/Hash/String transforms. The final expression or an
|
|
917
|
+
explicit `return` is the result. Use `puts`, `print`, `p`, or `warn` for captured logs. The values
|
|
918
|
+
`codemode.interfaces` and `codemode.get_tool_interface("manual.tool")` describe registered tools. Filesystem,
|
|
919
|
+
process, constants, imports, reflection, eval, and direct network access are unavailable.
|
|
920
|
+
PROMPT
|
|
921
|
+
|
|
922
|
+
def call_tool_chain(code, timeout_value = nil, timeout: nil, max_steps: CodeMode::DEFAULT_MAX_STEPS)
|
|
923
|
+
selected_timeout = timeout || timeout_value || CodeMode::DEFAULT_TIMEOUT
|
|
924
|
+
CodeMode.new(self).execute(code, timeout: selected_timeout, max_steps: max_steps)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def get_all_tools_ruby_interfaces
|
|
928
|
+
CodeMode.new(self).interfaces
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def get_tool_interface(name)
|
|
932
|
+
CodeMode.new(self).tool_interface(name)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
CodeModeClient = CodeModeUtcpClient
|
|
937
|
+
end
|
data/lib/utcp/errors.rb
CHANGED
|
@@ -45,4 +45,18 @@ module UTCP
|
|
|
45
45
|
end
|
|
46
46
|
class SecurityError < Error; end
|
|
47
47
|
class TimeoutError < ToolCallError; end
|
|
48
|
+
|
|
49
|
+
class CodeModeError < Error; end
|
|
50
|
+
class CodeModeSyntaxError < CodeModeError; end
|
|
51
|
+
class CodeModeTimeoutError < CodeModeError; end
|
|
52
|
+
class CodeModeLimitError < CodeModeError; end
|
|
53
|
+
|
|
54
|
+
class CodeModeExecutionError < CodeModeError
|
|
55
|
+
attr_reader :logs
|
|
56
|
+
|
|
57
|
+
def initialize(message, logs: [])
|
|
58
|
+
@logs = logs.dup.freeze
|
|
59
|
+
super(message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
48
62
|
end
|
data/lib/utcp/version.rb
CHANGED
data/lib/utcp.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-utcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ruby-utcp contributors
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- README.md
|
|
63
63
|
- examples/basic.rb
|
|
64
64
|
- examples/cli.rb
|
|
65
|
+
- examples/code_mode.rb
|
|
65
66
|
- examples/generated/__init__.py
|
|
66
67
|
- examples/generated/utcp_pb2.py
|
|
67
68
|
- examples/generated/utcp_pb2_grpc.py
|
|
@@ -93,6 +94,7 @@ files:
|
|
|
93
94
|
- lib/ruby-utcp.rb
|
|
94
95
|
- lib/utcp.rb
|
|
95
96
|
- lib/utcp/client.rb
|
|
97
|
+
- lib/utcp/code_mode.rb
|
|
96
98
|
- lib/utcp/config.rb
|
|
97
99
|
- lib/utcp/errors.rb
|
|
98
100
|
- lib/utcp/migration.rb
|