ruby-utcp 1.1.2 → 1.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c041cd63497e9162042ccdd75ef1adad0bd1b3e8c7fe13ca67d84519bcced0c8
4
- data.tar.gz: 2ff6634e287d5b313f58de19de4c70c64c626d894e939cad2246339f2683c12a
3
+ metadata.gz: 1869c15823d927455e8339580df8200a4eae60a1313a1384b2d98f793786e78d
4
+ data.tar.gz: 3f63ab346e1316dd5ba26e367637f3a1c39d8b7202d1b5c117c3885cf0c8405a
5
5
  SHA512:
6
- metadata.gz: 37b298c327a546cfa35799045ad2450c76cdfba4ad3cd8aee9d6fa79206d70667bfdec596654bb37cbdc4a25029732eb116943ce06cffbbd75c977dc18d3a5c5
7
- data.tar.gz: 1b131b7c326f7eeb8f4e100d11160dac9d56e0f766cbb315ae4d2916f1351364b3f2cc39a81183e7b766634a06b2f9cecff1b49f26cb10b22f24c6ecdad88e40
6
+ metadata.gz: 0bb88097ddb4f9170b8aeec82fb14d34fc708abc15ac7cfed54df9c03737105183c0fd09ca202c2ae53fbd276e85221393c4d8adfa36f80f5e05296b1c4ca860
7
+ data.tar.gz: 4c9c0147d82493982d2717bc56b4f5b0486b09fa1c37b4a2a5d4ebf7b04dd0859b28ef4361eb6d7e8458d27b6a1975073b9af68784401695a300f97f2fa7ecfd
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Split Code Mode into focused components while preserving the public API; fix `do...end` block results.
6
+ - Add branch-focused interpreter tests, concurrent request tests, and local HTTP/TCP/WebSocket fault injection.
7
+ - Isolate WebRTC peers between clients, close failed registrations, and align native offer/answer negotiation with `webrtc-ruby`.
8
+ - Close WebSocket sockets when the opening handshake fails.
9
+ - Add an opt-in real gRPC/WebRTC integration suite and raise coverage gates to 80% lines / 60% branches.
10
+ - Bound native integration runs with a subprocess watchdog. Include an opt-in source patch for `webrtc-ruby` 1.0.0 shutdown deadlocks, pointer-lifetime protection, and real callback/concurrent-shutdown regression tests.
11
+
5
12
  - Isolate MCP sessions and resource mappings between clients and clean up failed registrations.
6
13
  - Follow MCP tool/resource pagination and raise `ToolCallError` for tool failures, preserving the original payload.
7
14
  - Enforce stdio deadlines across partial reads, notifications, and blocked writes; bound message and stderr buffers.
@@ -35,7 +35,7 @@ ExampleHTTP.server(port) do |server|
35
35
  server.mount_proc("/connect") do |request, response|
36
36
  payload = ExampleHTTP.request_json(request)
37
37
  candidates = []
38
- peer = WebRTC::RTCPeerConnection.new
38
+ peer = WebRTC::RTCPeerConnection.new({ disable_auto_negotiation: true })
39
39
  peer.on_ice_candidate { |candidate| candidates << candidate if candidate }
40
40
  peer.on_data_channel do |channel|
41
41
  channel.on_message do |event|
@@ -50,11 +50,11 @@ ExampleHTTP.server(port) do |server|
50
50
  offer = WebRTC::RTCSessionDescription.new(type: :offer, sdp: payload.fetch("sdp"))
51
51
  peer.set_remote_description(offer).await
52
52
  answer = peer.create_answer.await
53
- peer.set_local_description(answer).await
53
+ # The native binding already installs the answer while creating it.
54
54
  wait_for_ice(peer)
55
55
  peers_mutex.synchronize { peers[payload.fetch("peer_id")] = peer }
56
56
  ExampleHTTP.json(response, {
57
- sdp: peer.local_description.sdp,
57
+ sdp: answer.sdp,
58
58
  candidates: candidates.map(&:to_h),
59
59
  tools: [{ name: "echo", description: "Echo over a WebRTC DataChannel" }]
60
60
  })
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class CodeModeUtcpClient < Client
5
+ AGENT_PROMPT_TEMPLATE = <<~PROMPT.freeze
6
+ Use `codemode.search_tools` before writing a Code Mode program when tool names are unknown.
7
+ Call tools as `codemode.call_tool("manual.tool", key: value)`. Use the exact qualified name.
8
+ Code Mode accepts a constrained Ruby subset: local variables, JSON-like literals, arithmetic,
9
+ conditionals, bounded loops, and common Array/Hash/String transforms. The final expression or an
10
+ explicit `return` is the result. Use `puts`, `print`, `p`, or `warn` for captured logs. The values
11
+ `codemode.interfaces` and `codemode.get_tool_interface("manual.tool")` describe registered tools. Filesystem,
12
+ process, constants, imports, reflection, eval, and direct network access are unavailable.
13
+ PROMPT
14
+
15
+ def call_tool_chain(code, timeout_value = nil, timeout: nil, max_steps: CodeMode::DEFAULT_MAX_STEPS)
16
+ selected_timeout = timeout || timeout_value || CodeMode::DEFAULT_TIMEOUT
17
+ CodeMode.new(self).execute(code, timeout: selected_timeout, max_steps: max_steps)
18
+ end
19
+
20
+ def get_all_tools_ruby_interfaces
21
+ CodeMode.new(self).interfaces
22
+ end
23
+
24
+ def get_tool_interface(name)
25
+ CodeMode.new(self).tool_interface(name)
26
+ end
27
+ end
28
+
29
+ CodeModeClient = CodeModeUtcpClient
30
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class CodeMode
5
+ class Evaluator
6
+ private
7
+
8
+ def truthy?(value)
9
+ !value.nil? && value != false
10
+ end
11
+
12
+ def evaluate_if(condition, truthy_statements, alternative)
13
+ if truthy?(evaluate(condition))
14
+ evaluate_statements(truthy_statements)
15
+ elsif alternative&.first == :else
16
+ evaluate_statements(alternative[1])
17
+ elsif alternative&.first == :elsif
18
+ evaluate_if(alternative[1], alternative[2], alternative[3])
19
+ end
20
+ end
21
+
22
+ def evaluate_unless(condition, statements, alternative)
23
+ unless truthy?(evaluate(condition))
24
+ evaluate_statements(statements)
25
+ else
26
+ evaluate_statements(alternative[1]) if alternative&.first == :else
27
+ end
28
+ end
29
+
30
+ def evaluate_body_statement(node)
31
+ statements, rescue_clause, else_clause, ensure_clause = node.drop(1)
32
+ completed = false
33
+ result = begin
34
+ value = evaluate_statements(statements)
35
+ completed = true
36
+ value
37
+ rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
38
+ raise
39
+ rescue StandardError => error
40
+ raise unless rescue_clause
41
+ evaluate_rescue_clause(rescue_clause, error)
42
+ ensure
43
+ evaluate_statements(ensure_clause[1]) if ensure_clause&.first == :ensure
44
+ end
45
+ completed && else_clause ? evaluate_statements(else_clause) : result
46
+ end
47
+
48
+ def evaluate_rescue_clause(node, error)
49
+ unsupported!(node) unless node.first == :rescue
50
+ raise CodeModeSyntaxError, "Code Mode rescue does not accept exception classes" if node[1]
51
+
52
+ scope = {}
53
+ assign_rescue_variable(scope, node[2], error) if node[2]
54
+ with_scope(scope) { evaluate_statements(node[3]) }
55
+ rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
56
+ raise
57
+ rescue StandardError => nested
58
+ next_clause = node[4]
59
+ raise nested unless next_clause&.first == :rescue
60
+ evaluate_rescue_clause(next_clause, nested)
61
+ end
62
+
63
+ def assign_rescue_variable(scope, target, error)
64
+ unsupported!(target) unless target.first == :var_field
65
+ scope[token_value(target[1])] = {
66
+ "message" => error.message,
67
+ "type" => error.class.name
68
+ }
69
+ end
70
+
71
+ def evaluate_rescue_modifier(node)
72
+ evaluate(node[1])
73
+ rescue ReturnSignal, BreakSignal, NextSignal, CodeModeTimeoutError, CodeModeLimitError, CodeModeSyntaxError
74
+ raise
75
+ rescue StandardError
76
+ evaluate(node[2])
77
+ end
78
+
79
+ def evaluate_loop(condition, statements, until_condition:)
80
+ result = nil
81
+ loop do
82
+ matches = truthy?(evaluate(condition))
83
+ break if until_condition ? matches : !matches
84
+
85
+ begin
86
+ result = evaluate_statements(statements)
87
+ rescue NextSignal => signal
88
+ result = signal.value
89
+ rescue BreakSignal => signal
90
+ return signal.value
91
+ end
92
+ end
93
+ result
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class CodeMode
5
+ class Evaluator
6
+ ReturnSignal = Class.new(StandardError) do
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ super()
12
+ end
13
+ end
14
+ BreakSignal = Class.new(StandardError) do
15
+ attr_reader :value
16
+
17
+ def initialize(value = nil)
18
+ @value = value
19
+ super()
20
+ end
21
+ end
22
+ NextSignal = Class.new(StandardError) do
23
+ attr_reader :value
24
+
25
+ def initialize(value = nil)
26
+ @value = value
27
+ super()
28
+ end
29
+ end
30
+
31
+ RuntimeAPI = Class.new
32
+ Block = Struct.new(:evaluator, :parameters, :body) do
33
+ def call(*values)
34
+ scope = {}
35
+ if parameters.length == 1 && values.length > 1
36
+ scope[parameters.first] = values
37
+ else
38
+ parameters.each_with_index { |name, index| scope[name] = values[index] }
39
+ end
40
+ evaluator.with_scope(scope) { evaluator.evaluate(body) }
41
+ rescue NextSignal => signal
42
+ signal.value
43
+ end
44
+ end
45
+
46
+ SAFE_ENUMERABLE_METHODS = %w[
47
+ all? any? collect count each filter find map none? reduce inject reject select sort_by
48
+ ].freeze
49
+ SAFE_VALUE_METHODS = %w[
50
+ abs ceil compact dig downcase drop empty? end_with? even? fetch first flatten floor
51
+ has_key? include? inspect join key? keys last length max merge min negative? odd? positive?
52
+ nil? reverse round size slice sort split start_with? strip sum take to_a to_f to_h to_i to_s
53
+ uniq upcase values zero?
54
+ ].freeze
55
+
56
+ attr_reader :logs
57
+
58
+ def initialize(client, interfaces:, timeout:, max_steps:)
59
+ raise CodeModeLimitError, "max_steps must be greater than zero" unless max_steps.positive?
60
+
61
+ @client = client
62
+ @interfaces = interfaces
63
+ @deadline = monotonic_now + timeout
64
+ @max_steps = max_steps
65
+ @steps = 0
66
+ @logs = []
67
+ @log_bytes = 0
68
+ @scopes = [{ "codemode" => RuntimeAPI.new }]
69
+ end
70
+
71
+ def execute(source)
72
+ tree = Ripper.sexp(source)
73
+ raise CodeModeSyntaxError, "Invalid Ruby syntax" unless tree
74
+
75
+ safe_tool_value(evaluate(tree))
76
+ rescue ReturnSignal => signal
77
+ safe_tool_value(signal.value)
78
+ end
79
+
80
+ def evaluate(node)
81
+ tick!
82
+ return nil if node.nil?
83
+ return node unless node.is_a?(Array)
84
+ return evaluate_statements(node) unless node.first.is_a?(Symbol)
85
+
86
+ type = node.first
87
+ case type
88
+ when :program then evaluate_statements(node[1])
89
+ when :void_stmt then nil
90
+ when :assign then assign(node[1], evaluate(node[2]))
91
+ when :opassign then evaluate_opassign(node)
92
+ when :var_ref then evaluate_variable_token(node[1])
93
+ when :vcall then evaluate_vcall(node[1])
94
+ when :fcall then call_function(token_value(node[1]), [], nil)
95
+ when :@int then Integer(node[1], 10)
96
+ when :@float then Float(node[1])
97
+ when :@kw then evaluate_keyword(node[1])
98
+ when :string_literal then evaluate(node[1]).to_s
99
+ when :string_content then node.drop(1).map { |part| evaluate_string_part(part) }.join
100
+ when :symbol_literal then evaluate_symbol(node[1])
101
+ when :array then Array(node[1]).map { |item| evaluate(item) }
102
+ when :hash then evaluate_hash(node[1])
103
+ when :bare_assoc_hash then evaluate_associations(node[1])
104
+ when :paren then evaluate_statements(node[1])
105
+ when :binary then evaluate_binary(node[1], node[2], node[3])
106
+ when :unary then evaluate_unary(node[1], node[2])
107
+ when :dot2 then Range.new(evaluate(node[1]), evaluate(node[2]), false)
108
+ when :dot3 then Range.new(evaluate(node[1]), evaluate(node[2]), true)
109
+ when :aref then safe_index(evaluate(node[1]), extract_arguments(node[2]))
110
+ when :call, :method_add_arg, :command, :command_call then evaluate_call(node)
111
+ when :method_add_block then evaluate_call(node[1], build_block(node[2]))
112
+ when :begin then evaluate(node[1])
113
+ when :bodystmt then evaluate_body_statement(node)
114
+ when :rescue_mod then evaluate_rescue_modifier(node)
115
+ when :if then evaluate_if(node[1], node[2], node[3])
116
+ when :unless then evaluate_unless(node[1], node[2], node[3])
117
+ when :if_mod then truthy?(evaluate(node[1])) ? evaluate(node[2]) : nil
118
+ when :unless_mod then truthy?(evaluate(node[1])) ? nil : evaluate(node[2])
119
+ when :while then evaluate_loop(node[1], node[2], until_condition: false)
120
+ when :until then evaluate_loop(node[1], node[2], until_condition: true)
121
+ when :return then raise ReturnSignal, return_value(node[1])
122
+ when :return0 then raise ReturnSignal, nil
123
+ when :break then raise BreakSignal, return_value(node[1])
124
+ when :next then raise NextSignal, return_value(node[1])
125
+ else
126
+ unsupported!(node)
127
+ end
128
+ end
129
+
130
+ def evaluate_statements(statements)
131
+ Array(statements).reduce(nil) { |_result, statement| evaluate(statement) }
132
+ end
133
+
134
+ def with_scope(scope)
135
+ @scopes << scope
136
+ yield
137
+ ensure
138
+ @scopes.pop
139
+ end
140
+
141
+ private
142
+
143
+ def tick!
144
+ @steps += 1
145
+ raise CodeModeLimitError, "Code Mode step limit exceeded" if @steps > @max_steps
146
+ raise CodeModeTimeoutError, "Code Mode execution timed out" if monotonic_now >= @deadline
147
+ end
148
+
149
+ def monotonic_now
150
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+ end
152
+
153
+ def unsupported!(node)
154
+ line = find_line(node)
155
+ suffix = line ? " at line #{line}" : ""
156
+ raise CodeModeSyntaxError, "Unsupported Ruby construct #{node.first.inspect}#{suffix}"
157
+ end
158
+
159
+ def find_line(node)
160
+ return node[2][0] if node.is_a?(Array) && node.first.to_s.start_with?("@") && node[2].is_a?(Array)
161
+ return nil unless node.is_a?(Array)
162
+
163
+ node.each do |child|
164
+ line = find_line(child)
165
+ return line if line
166
+ end
167
+ nil
168
+ end
169
+
170
+ def token_value(token)
171
+ token.is_a?(Array) ? token[1].to_s : token.to_s
172
+ end
173
+
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class CodeMode
5
+ class Evaluator
6
+ private
7
+
8
+ def evaluate_keyword(keyword)
9
+ case keyword
10
+ when "true" then true
11
+ when "false" then false
12
+ when "nil" then nil
13
+ else raise CodeModeSyntaxError, "Unsupported keyword #{keyword.inspect}"
14
+ end
15
+ end
16
+
17
+ def evaluate_string_part(part)
18
+ return part[1] if part.first == :@tstring_content
19
+ return evaluate_statements(part[1]).to_s if part.first == :string_embexpr
20
+
21
+ unsupported!(part)
22
+ end
23
+
24
+ def evaluate_symbol(node)
25
+ token = node.first == :symbol ? node[1] : node
26
+ token_value(token).to_sym
27
+ end
28
+
29
+ def evaluate_hash(contents)
30
+ return {} unless contents
31
+ return evaluate_associations(contents[1]) if contents.first == :assoclist_from_args
32
+
33
+ unsupported!(contents)
34
+ end
35
+
36
+ def evaluate_associations(associations)
37
+ Array(associations).each_with_object({}) do |association, result|
38
+ unsupported!(association) unless association.first == :assoc_new
39
+ key_node = association[1]
40
+ key = key_node.first == :@label ? key_node[1].sub(/:\z/, "") : evaluate(key_node)
41
+ result[key] = evaluate(association[2])
42
+ end
43
+ end
44
+
45
+ def evaluate_variable_token(token)
46
+ return evaluate_keyword(token[1]) if token.first == :@kw
47
+ lookup(token_value(token))
48
+ end
49
+
50
+ def evaluate_vcall(token)
51
+ name = token_value(token)
52
+ value = lookup(name, missing: :sentinel)
53
+ return value unless value == :sentinel
54
+
55
+ call_function(name, [], nil)
56
+ end
57
+
58
+ def lookup(name, missing: nil)
59
+ @scopes.reverse_each { |scope| return scope[name] if scope.key?(name) }
60
+ return missing if missing == :sentinel
61
+
62
+ raise CodeModeExecutionError, "Undefined local variable #{name.inspect}"
63
+ end
64
+
65
+ def assign(target, value)
66
+ case target.first
67
+ when :var_field
68
+ name = token_value(target[1])
69
+ scope = @scopes.reverse.find { |candidate| candidate.key?(name) } || @scopes.last
70
+ scope[name] = value
71
+ when :aref_field
72
+ receiver = evaluate(target[1])
73
+ arguments = extract_arguments(target[2])
74
+ raise CodeModeSyntaxError, "Indexed assignment requires exactly one key" unless arguments.length == 1
75
+ raise CodeModeSyntaxError, "Indexed assignment is only allowed on arrays and hashes" unless receiver.is_a?(Array) || receiver.is_a?(Hash)
76
+
77
+ receiver[arguments.first] = value
78
+ else
79
+ unsupported!(target)
80
+ end
81
+ value
82
+ end
83
+
84
+ def evaluate_opassign(node)
85
+ target = node[1]
86
+ operator = token_value(node[2]).sub(/=\z/, "").to_sym
87
+ current = read_assignment_target(target)
88
+ assign(target, apply_binary(current, operator, evaluate(node[3])))
89
+ end
90
+
91
+ def read_assignment_target(target)
92
+ case target.first
93
+ when :var_field then lookup(token_value(target[1]))
94
+ when :aref_field then safe_index(evaluate(target[1]), extract_arguments(target[2]))
95
+ else unsupported!(target)
96
+ end
97
+ end
98
+
99
+ def evaluate_binary(left_node, operator, right_node)
100
+ left = evaluate(left_node)
101
+ return left unless truthy?(left) if [:'&&', :and].include?(operator)
102
+ return left if truthy?(left) if [:'||', :or].include?(operator)
103
+
104
+ apply_binary(left, operator, evaluate(right_node))
105
+ end
106
+
107
+ def apply_binary(left, operator, right)
108
+ preflight_binary_size!(left, operator, right)
109
+ value = case operator
110
+ when :+ then left + right
111
+ when :- then left - right
112
+ when :* then left * right
113
+ when :/ then left / right
114
+ when :% then left % right
115
+ when :** then left**right
116
+ when :== then left == right
117
+ when :!= then left != right
118
+ when :< then left < right
119
+ when :<= then left <= right
120
+ when :> then left > right
121
+ when :>= then left >= right
122
+ when :<=> then left <=> right
123
+ when :'&&', :and, :'||', :or then right
124
+ else raise CodeModeSyntaxError, "Unsupported operator #{operator.inspect}"
125
+ end
126
+ bounded_value!(value)
127
+ rescue NoMethodError, TypeError, ArgumentError, ZeroDivisionError => error
128
+ raise CodeModeExecutionError, "Invalid #{operator} operation: #{error.message}"
129
+ end
130
+
131
+ def evaluate_unary(operator, operand_node)
132
+ operand = evaluate(operand_node)
133
+ case operator
134
+ when :! then !truthy?(operand)
135
+ when :+@ then +operand
136
+ when :-@ then -operand
137
+ else raise CodeModeSyntaxError, "Unsupported unary operator #{operator.inspect}"
138
+ end
139
+ end
140
+
141
+ def return_value(arguments_node)
142
+ arguments = extract_arguments(arguments_node)
143
+ arguments.length <= 1 ? arguments.first : arguments
144
+ end
145
+
146
+ def extract_arguments(node)
147
+ return [] if node.nil?
148
+ case node.first
149
+ when :arg_paren then extract_arguments(node[1])
150
+ when :args_add_block then Array(node[1]).map { |argument| evaluate(argument) }
151
+ when :args_new then []
152
+ when :args_add then extract_arguments(node[1]) + [evaluate(node[2])]
153
+ else [evaluate(node)]
154
+ end
155
+ end
156
+
157
+ def build_block(node)
158
+ unsupported!(node) unless %i[brace_block do_block].include?(node.first)
159
+ parameters = extract_block_parameters(node[1])
160
+ Block.new(self, parameters, node[2])
161
+ end
162
+
163
+ def extract_block_parameters(node)
164
+ return [] unless node
165
+ params = node.first == :block_var ? node[1] : node
166
+ unsupported!(params) unless params&.first == :params
167
+ required = Array(params[1])
168
+ unsupported!(params) unless params.drop(2).all?(&:nil?)
169
+
170
+ required.map { |token| token_value(token) }
171
+ end
172
+
173
+ def evaluate_call(node, block = nil)
174
+ case node.first
175
+ when :method_add_arg
176
+ invoke_call_target(node[1], extract_arguments(node[2]), block)
177
+ when :call
178
+ invoke_method(evaluate(node[1]), token_value(node[3]), [], block)
179
+ when :fcall
180
+ call_function(token_value(node[1]), [], block)
181
+ when :vcall
182
+ evaluate_vcall(node[1])
183
+ when :command
184
+ call_function(token_value(node[1]), extract_arguments(node[2]), block)
185
+ when :command_call
186
+ invoke_method(evaluate(node[1]), token_value(node[3]), extract_arguments(node[4]), block)
187
+ else
188
+ unsupported!(node)
189
+ end
190
+ end
191
+
192
+ def invoke_call_target(target, arguments, block)
193
+ case target.first
194
+ when :call
195
+ invoke_method(evaluate(target[1]), token_value(target[3]), arguments, block)
196
+ when :fcall
197
+ call_function(token_value(target[1]), arguments, block)
198
+ when :vcall
199
+ call_function(token_value(target[1]), arguments, block)
200
+ else
201
+ unsupported!(target)
202
+ end
203
+ end
204
+
205
+ def call_function(name, arguments, block)
206
+ case name
207
+ when "puts", "print", "p", "warn"
208
+ log(name, arguments)
209
+ nil
210
+ else
211
+ hint = name == "call_tool" ? "; use codemode.call_tool(...)" : ""
212
+ raise CodeModeSyntaxError, "Function #{name.inspect} is not available in Code Mode#{hint}"
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class CodeMode
5
+ class InterfaceGenerator
6
+ RUBY_KEYWORDS = %w[
7
+ BEGIN END alias and begin break case class def defined? do else elsif end ensure false
8
+ for if in module next nil not or redo rescue retry return self super then true undef
9
+ unless until when while yield
10
+ ].freeze
11
+
12
+ class << self
13
+ def tool_descriptor(tool)
14
+ {
15
+ "name" => tool.name,
16
+ "description" => tool.description,
17
+ "inputs" => tool.inputs.to_h,
18
+ "outputs" => tool.outputs.to_h,
19
+ "tags" => tool.tags.dup
20
+ }
21
+ end
22
+
23
+ def identifier(value)
24
+ name = value.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
25
+ name = "_#{name}" if name.match?(/\A\d/)
26
+ name = "_#{name}" if RUBY_KEYWORDS.include?(name)
27
+ name.empty? ? "_" : name
28
+ end
29
+
30
+ def ruby_type(schema)
31
+ value = schema.is_a?(Hash) ? schema : {}
32
+ case value["type"] || value[:type]
33
+ when "string" then "String"
34
+ when "integer" then "Integer"
35
+ when "number" then "Numeric"
36
+ when "boolean" then "Boolean"
37
+ when "array" then "Array"
38
+ when "object" then "Hash"
39
+ when Array then (value["type"] || value[:type]).map { |type| ruby_type("type" => type) }.join(" | ")
40
+ else "Object"
41
+ end
42
+ end
43
+ end
44
+
45
+ def initialize(tools)
46
+ @tools = tools.sort_by(&:name)
47
+ end
48
+
49
+ def render
50
+ return "# No UTCP tools are registered." if @tools.empty?
51
+
52
+ @tools.map { |tool| render_tool(tool) }.join("\n\n")
53
+ end
54
+
55
+ private
56
+
57
+ def render_tool(tool)
58
+ descriptor = self.class.tool_descriptor(tool)
59
+ schema = descriptor["inputs"]
60
+ properties = schema.fetch("properties", {})
61
+ required = Array(schema["required"])
62
+ parameters = properties.map do |name, property|
63
+ suffix = required.include?(name.to_s) ? ":" : ": nil"
64
+ "#{self.class.identifier(name)}#{suffix}"
65
+ end
66
+ signature = parameters.empty? ? "" : parameters.join(", ")
67
+ description = descriptor["description"].to_s.strip
68
+ lines = []
69
+ lines << "# #{description}" unless description.empty?
70
+ unless properties.empty?
71
+ parameter_docs = properties.map do |name, property|
72
+ requirement = required.include?(name.to_s) ? "required" : "optional"
73
+ "#{self.class.identifier(name)} (#{self.class.ruby_type(property)}, #{requirement})"
74
+ end
75
+ lines << "# Parameters: #{parameter_docs.join(', ')}"
76
+ end
77
+ lines << "# Returns: #{self.class.ruby_type(descriptor["outputs"])}"
78
+ separator = signature.empty? ? "" : ", "
79
+ lines << "codemode.call_tool(#{descriptor["name"].inspect}#{separator}#{signature})"
80
+ lines.join("\n")
81
+ end
82
+ end
83
+ end
84
+ end