ruby_llm-code_mode 0.3.0 → 0.4.0
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/README.md +53 -3
- data/lib/ruby_llm/code_mode.rb +281 -184
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb23ad192879e9ce6bcac0204c62c44ad068943dadd5e46a3a2e82bd177411c7
|
|
4
|
+
data.tar.gz: 39b52ca9cf5f733224a1a8a999ae50cbe091467819d86f63a7c38610be665233
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e41645fd4404b2a70e3a3606ab6b1aba047e20e1e610278ddaac8ae85b8512d0c0af134a897fface831e7a1547b77610444bbb8dffb6a80b468bf4a79f09af2
|
|
7
|
+
data.tar.gz: 60adf578f5deafbd36822e14179e8bcd74856ba6d4b598910cc2be40520ef577f8fd534ffde0ecad2d5ab5c512ff2ac2b98ae60f8c66b0cc0dfa8253bde5b609
|
data/README.md
CHANGED
|
@@ -22,21 +22,41 @@ gem "ruby_llm-code_mode"
|
|
|
22
22
|
|
|
23
23
|
## Usage
|
|
24
24
|
|
|
25
|
-
Define a tool class, declare the host folders the sandbox may see,
|
|
26
|
-
your chat:
|
|
25
|
+
Define a tool class, declare the host folders the sandbox may see, bind the
|
|
26
|
+
host tools and MCP servers the code may call, and give it to your chat:
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
require "ruby_llm"
|
|
30
30
|
require "ruby_llm/code_mode"
|
|
31
31
|
|
|
32
|
+
# A host tool the sandboxed code can call with SB.call("notes", note: "...").
|
|
33
|
+
class Notes < RubyLLM::Tool
|
|
34
|
+
description "Records a finding in the analysis log"
|
|
35
|
+
|
|
36
|
+
parameter :note, type: "string", description: "The finding to record"
|
|
37
|
+
|
|
38
|
+
def execute(note:)
|
|
39
|
+
# host-side write: log file, HTTP call, database query, ...
|
|
40
|
+
{ recorded: note }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# An MCP server whose tools become callable from inside the sandbox too.
|
|
45
|
+
docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
46
|
+
|
|
32
47
|
class Analytics < RubyLLM::CodeMode
|
|
33
48
|
mount source: "data/input", dest: "/data", description: "CSV files with the raw data (read only)"
|
|
34
49
|
mount_rw source: "workspace", dest: "/workspace", description: "Write generated reports and artifacts here"
|
|
50
|
+
|
|
51
|
+
tools Notes # one, several (`tools A, B`) or arrays (`tools [A, B]`)
|
|
52
|
+
mcps docs # one, several (`mcps A, B`) or arrays — classes work too
|
|
35
53
|
end
|
|
36
54
|
|
|
37
55
|
chat = RubyLLM.chat
|
|
38
56
|
chat.with_tools(Analytics)
|
|
39
|
-
chat.ask "Compute the total sales per month from the CSVs in /data
|
|
57
|
+
chat.ask "Compute the total sales per month from the CSVs in /data, save the " \
|
|
58
|
+
"report to /workspace/report.md, record the biggest finding in the " \
|
|
59
|
+
"notes log and check the Azure docs for the storage API you used"
|
|
40
60
|
```
|
|
41
61
|
|
|
42
62
|
### What the model sees
|
|
@@ -179,6 +199,36 @@ result[:error] # => true when the tool reported a failure
|
|
|
179
199
|
The sandbox itself never sees the network — every `SB.call` performs the MCP
|
|
180
200
|
request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
|
|
181
201
|
|
|
202
|
+
### Per-instance tools and servers
|
|
203
|
+
|
|
204
|
+
Class-level `tools`/`mcps` bind once for every instance of the class. When the
|
|
205
|
+
same tool class needs a different toolset per chat — per-user credentials, a
|
|
206
|
+
server list composed at runtime — bind additions on the instance instead:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
tool = Analytics.new
|
|
210
|
+
tool.add_tools(UserSearch.new(user: current_user)) # ready instance (classes work too)
|
|
211
|
+
tool.add_mcps(user_linear) # server instance, class or array
|
|
212
|
+
|
|
213
|
+
chat = RubyLLM.chat
|
|
214
|
+
chat.with_tools(tool) # the description already includes the additions
|
|
215
|
+
chat.ask "Search the docs and log the findings"
|
|
216
|
+
tool.execute(code: 'SB.call("user_search", q: "...")')
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `add_tools` accepts the same forms as `tools`: classes, ready instances,
|
|
220
|
+
several at once, arrays and `"name" => tool` pairs. Names may not collide
|
|
221
|
+
with the class-level bindings or each other, and the 64-tool limit counts
|
|
222
|
+
both levels together.
|
|
223
|
+
- `add_mcps` accepts the same forms as `mcps`; the servers' tools bind into
|
|
224
|
+
this instance only — other instances and the class itself are unaffected.
|
|
225
|
+
- Both are all-or-nothing: on any failure nothing is bound.
|
|
226
|
+
- The instance description includes the additions, so register the tool with
|
|
227
|
+
the chat after calling them.
|
|
228
|
+
- The sandbox is configured on the first execution: after that,
|
|
229
|
+
`add_tools`/`add_mcps` raise — create a new instance instead. Instances
|
|
230
|
+
without additions share the class configuration.
|
|
231
|
+
|
|
182
232
|
### Sandbox limits
|
|
183
233
|
|
|
184
234
|
Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
|
data/lib/ruby_llm/code_mode.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "security_box"
|
|
|
5
5
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
class CodeMode < Tool
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.4.0"
|
|
9
9
|
|
|
10
10
|
DEFAULT_TIMEOUT_MS = 30_000
|
|
11
11
|
DEFAULT_FUEL_MS = 10_000
|
|
@@ -53,6 +53,221 @@ module RubyLLM
|
|
|
53
53
|
|
|
54
54
|
McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
|
|
55
55
|
|
|
56
|
+
# Shared registration and rendering core, parameterized by the target
|
|
57
|
+
# collections: the class-level DSL (tools/mcps) and the instance-level API
|
|
58
|
+
# (add_tools/add_mcps) run the exact same validation and rollback logic,
|
|
59
|
+
# only over different hashes/arrays.
|
|
60
|
+
module Registry
|
|
61
|
+
EMPTY = {}.freeze
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# Binds tools into `target` (a name => ToolEntry hash). Accepts tool
|
|
65
|
+
# classes/instances, arrays of them, and "name" => tool pairs (hash
|
|
66
|
+
# arguments or kwargs). All-or-nothing: on any failure `target` is
|
|
67
|
+
# restored and the error re-raised. `reserved` lists names already
|
|
68
|
+
# taken elsewhere (e.g. the class-level bindings for instance
|
|
69
|
+
# additions); duplicates against it raise.
|
|
70
|
+
def register_tools(target, args, kwargs, reserved: EMPTY)
|
|
71
|
+
snapshot = target.dup
|
|
72
|
+
begin
|
|
73
|
+
args.flatten(1).each do |item|
|
|
74
|
+
if item.is_a?(Hash)
|
|
75
|
+
item.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
|
|
76
|
+
else
|
|
77
|
+
add_tool(target, item, nil, reserved)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
kwargs.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
|
|
81
|
+
rescue StandardError
|
|
82
|
+
target.replace(snapshot)
|
|
83
|
+
raise
|
|
84
|
+
end
|
|
85
|
+
target
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Connects MCP server(s) and binds all their tools into
|
|
89
|
+
# `target_tools`/`target_mcps`. All-or-nothing across both
|
|
90
|
+
# collections.
|
|
91
|
+
def register_mcps(target_tools, target_mcps, servers, inputs, reserved: EMPTY)
|
|
92
|
+
snapshot_tools = target_tools.dup
|
|
93
|
+
snapshot_mcps = target_mcps.dup
|
|
94
|
+
flat = servers.flatten(1)
|
|
95
|
+
raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
|
|
96
|
+
unless inputs.empty? || flat.all?(Class)
|
|
97
|
+
raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
normalize_mcp_servers(flat, inputs).each do |server|
|
|
101
|
+
bind_mcp_server(target_tools, target_mcps, server, reserved)
|
|
102
|
+
end
|
|
103
|
+
target_mcps
|
|
104
|
+
rescue StandardError
|
|
105
|
+
target_tools.replace(snapshot_tools)
|
|
106
|
+
target_mcps.replace(snapshot_mcps)
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rpc_handlers(tools)
|
|
111
|
+
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def tools_section(tools)
|
|
115
|
+
return nil if tools.empty?
|
|
116
|
+
|
|
117
|
+
[
|
|
118
|
+
"## Host tools (call with SB.call)",
|
|
119
|
+
HOST_TOOLS_INTRO.rstrip,
|
|
120
|
+
*tools.values.flat_map { |entry| tool_lines(entry) }
|
|
121
|
+
].join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def server_notes_section(mcps)
|
|
125
|
+
notes = mcps.filter_map do |entry|
|
|
126
|
+
instructions = entry.instructions.to_s
|
|
127
|
+
next if instructions.empty?
|
|
128
|
+
|
|
129
|
+
"- **#{entry.name}** — #{instructions}"
|
|
130
|
+
end
|
|
131
|
+
return nil if notes.empty?
|
|
132
|
+
|
|
133
|
+
["## Server notes", *notes].join("\n")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def add_tool(target, bound, name, reserved)
|
|
139
|
+
instance = normalize_bound_tool(bound)
|
|
140
|
+
if instance.is_a?(RubyLLM::CodeMode)
|
|
141
|
+
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
name = (name || tool_binding_name(instance)).to_s
|
|
145
|
+
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
146
|
+
if target.key?(name) || reserved.key?(name)
|
|
147
|
+
raise ArgumentError, "tool name #{name.inspect} is already bound"
|
|
148
|
+
end
|
|
149
|
+
total = target.size + reserved.size
|
|
150
|
+
if total >= MAX_TOOLS
|
|
151
|
+
raise ArgumentError, "too many tools (#{total + 1}); the limit is #{MAX_TOOLS}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
target[name] = ToolEntry.new(name: name, tool: instance)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_bound_tool(bound)
|
|
158
|
+
if bound.is_a?(Class)
|
|
159
|
+
unless bound <= RubyLLM::Tool
|
|
160
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
bound.new
|
|
164
|
+
elsif bound.is_a?(RubyLLM::Tool)
|
|
165
|
+
bound
|
|
166
|
+
else
|
|
167
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
172
|
+
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
173
|
+
def leaf_tool_name(klass)
|
|
174
|
+
leaf = klass.name.to_s.split('::').last
|
|
175
|
+
return "" unless leaf
|
|
176
|
+
|
|
177
|
+
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Regular tools keep the leaf-only derivation; MCP tools carry their
|
|
181
|
+
# own (server) name, which is what they bind under inside the sandbox.
|
|
182
|
+
def tool_binding_name(instance)
|
|
183
|
+
return instance.name.to_s if instance.respond_to?(:server_name)
|
|
184
|
+
|
|
185
|
+
leaf_tool_name(instance.class)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def call_bound_tool(entry, args)
|
|
189
|
+
unless args.is_a?(Hash)
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
normalize_result(entry.tool.call(**args))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
|
|
198
|
+
# round-trips handler returns, so give the guest plain data instead
|
|
199
|
+
# of an inspect string.
|
|
200
|
+
def normalize_result(value)
|
|
201
|
+
return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
|
|
202
|
+
|
|
203
|
+
{ text: value.text, structured: value.structured, error: value.error? }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize_mcp_servers(servers, inputs)
|
|
207
|
+
unless defined?(RubyLLM::MCP)
|
|
208
|
+
raise ArgumentError,
|
|
209
|
+
"RubyLLM::MCP is not available; mcps needs a ruby_llm version with MCP support"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
servers.map do |server|
|
|
213
|
+
case server
|
|
214
|
+
when Class
|
|
215
|
+
unless server <= RubyLLM::MCP
|
|
216
|
+
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
server.new(**inputs)
|
|
220
|
+
when RubyLLM::MCP
|
|
221
|
+
server
|
|
222
|
+
else
|
|
223
|
+
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def bind_mcp_server(target_tools, target_mcps, server, reserved)
|
|
229
|
+
# Fetching .tools connects to the server; snapshot the instructions
|
|
230
|
+
# right after, so description builds never touch the network.
|
|
231
|
+
tools_list = server.tools
|
|
232
|
+
target_mcps << McpEntry.new(
|
|
233
|
+
name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
|
|
234
|
+
)
|
|
235
|
+
tools_list.each { |tool| bind_mcp_tool(target_tools, server, tool, reserved) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def bind_mcp_tool(target_tools, server, tool, reserved)
|
|
239
|
+
name = tool_binding_name(tool)
|
|
240
|
+
if (target_tools.key?(name) || reserved.key?(name)) && !server.name.to_s.empty?
|
|
241
|
+
name = "#{server.name}_#{name}"
|
|
242
|
+
end
|
|
243
|
+
add_tool(target_tools, tool, name, reserved)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def tool_lines(entry)
|
|
247
|
+
line = "- `#{entry.name}`"
|
|
248
|
+
description = entry.tool.description.to_s
|
|
249
|
+
line += " — #{description}" unless description.empty?
|
|
250
|
+
|
|
251
|
+
[line, *param_lines(entry.tool).map { |param| " #{param}" }]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def param_lines(tool)
|
|
255
|
+
schema = tool.parameters_schema || {}
|
|
256
|
+
properties = schema["properties"] || {}
|
|
257
|
+
required = schema["required"] || []
|
|
258
|
+
|
|
259
|
+
properties.filter_map do |param, spec|
|
|
260
|
+
spec ||= {}
|
|
261
|
+
status = required.include?(param) ? "required" : "optional"
|
|
262
|
+
line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
|
|
263
|
+
description = spec["description"].to_s
|
|
264
|
+
line += " — #{description}" unless description.empty?
|
|
265
|
+
line
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
56
271
|
class << self
|
|
57
272
|
def tool_name
|
|
58
273
|
leaf = name.to_s.split('::').last
|
|
@@ -86,21 +301,7 @@ module RubyLLM
|
|
|
86
301
|
def tools(*args, **kwargs)
|
|
87
302
|
return @tools ||= {} if args.empty? && kwargs.empty?
|
|
88
303
|
|
|
89
|
-
|
|
90
|
-
begin
|
|
91
|
-
args.flatten(1).each do |item|
|
|
92
|
-
if item.is_a?(Hash)
|
|
93
|
-
item.each { |name, bound| add_tool(bound, name.to_s) }
|
|
94
|
-
else
|
|
95
|
-
add_tool(item, nil)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
kwargs.each { |name, bound| add_tool(bound, name.to_s) }
|
|
99
|
-
rescue StandardError
|
|
100
|
-
instance_variable_set(:@tools, snapshot)
|
|
101
|
-
raise
|
|
102
|
-
end
|
|
103
|
-
tools
|
|
304
|
+
Registry.register_tools(tools, args, kwargs)
|
|
104
305
|
end
|
|
105
306
|
|
|
106
307
|
# Connects MCP server(s) and binds all their tools into the sandbox:
|
|
@@ -126,20 +327,7 @@ module RubyLLM
|
|
|
126
327
|
def mcps(*servers, **inputs)
|
|
127
328
|
return @mcps ||= [] if servers.empty? && inputs.empty?
|
|
128
329
|
|
|
129
|
-
|
|
130
|
-
raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
|
|
131
|
-
unless inputs.empty? || flat.all?(Class)
|
|
132
|
-
raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
snapshot_tools = tools.dup
|
|
136
|
-
snapshot_mcps = mcps.dup
|
|
137
|
-
normalize_mcp_servers(flat, inputs).each { |server| bind_mcp_server(server) }
|
|
138
|
-
mcps
|
|
139
|
-
rescue StandardError
|
|
140
|
-
instance_variable_set(:@tools, snapshot_tools)
|
|
141
|
-
instance_variable_set(:@mcps, snapshot_mcps)
|
|
142
|
-
raise
|
|
330
|
+
Registry.register_mcps(tools, mcps, servers, inputs)
|
|
143
331
|
end
|
|
144
332
|
|
|
145
333
|
def mounts
|
|
@@ -151,18 +339,21 @@ module RubyLLM
|
|
|
151
339
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
152
340
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
153
341
|
mounts: mounts.map(&:payload),
|
|
154
|
-
rpcs:
|
|
342
|
+
rpcs: Registry.rpc_handlers(tools)
|
|
155
343
|
)
|
|
156
344
|
end
|
|
157
345
|
|
|
158
|
-
|
|
346
|
+
# Builds the description the model sees. `extra_tools`/`extra_mcps`
|
|
347
|
+
# carry the per-instance additions (see #add_tools/#add_mcps); with no
|
|
348
|
+
# extras the output is identical to the class-level description.
|
|
349
|
+
def build_description(extra_tools: {}, extra_mcps: [])
|
|
159
350
|
read_only, read_write = mounts.partition { |m| m.mode == :read_only }
|
|
160
351
|
[
|
|
161
352
|
FIXED_DESCRIPTION.rstrip,
|
|
162
353
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
163
354
|
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
164
|
-
tools_section,
|
|
165
|
-
server_notes_section
|
|
355
|
+
Registry.tools_section(tools.merge(extra_tools)),
|
|
356
|
+
Registry.server_notes_section(mcps + extra_mcps)
|
|
166
357
|
].compact.join("\n\n")
|
|
167
358
|
end
|
|
168
359
|
|
|
@@ -202,113 +393,6 @@ module RubyLLM
|
|
|
202
393
|
"Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
|
|
203
394
|
end
|
|
204
395
|
|
|
205
|
-
def add_tool(bound, name)
|
|
206
|
-
instance = normalize_bound_tool(bound)
|
|
207
|
-
if instance.is_a?(RubyLLM::CodeMode)
|
|
208
|
-
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
name = (name || tool_binding_name(instance)).to_s
|
|
212
|
-
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
213
|
-
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
214
|
-
if tools.size >= MAX_TOOLS
|
|
215
|
-
raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
tools[name] = ToolEntry.new(name: name, tool: instance)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def normalize_bound_tool(bound)
|
|
222
|
-
if bound.is_a?(Class)
|
|
223
|
-
unless bound <= RubyLLM::Tool
|
|
224
|
-
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
bound.new
|
|
228
|
-
elsif bound.is_a?(RubyLLM::Tool)
|
|
229
|
-
bound
|
|
230
|
-
else
|
|
231
|
-
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
236
|
-
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
237
|
-
def leaf_tool_name(klass)
|
|
238
|
-
leaf = klass.name.to_s.split('::').last
|
|
239
|
-
return "" unless leaf
|
|
240
|
-
|
|
241
|
-
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Regular tools keep the leaf-only derivation; MCP tools carry their
|
|
245
|
-
# own (server) name, which is what they bind under inside the sandbox.
|
|
246
|
-
def tool_binding_name(instance)
|
|
247
|
-
return instance.name.to_s if instance.respond_to?(:server_name)
|
|
248
|
-
|
|
249
|
-
leaf_tool_name(instance.class)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def build_rpcs
|
|
253
|
-
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def call_bound_tool(entry, args)
|
|
257
|
-
unless args.is_a?(Hash)
|
|
258
|
-
raise ArgumentError,
|
|
259
|
-
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
normalize_result(entry.tool.call(**args))
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
# MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
|
|
266
|
-
# round-trips handler returns, so give the guest plain data instead
|
|
267
|
-
# of an inspect string.
|
|
268
|
-
def normalize_result(value)
|
|
269
|
-
return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
|
|
270
|
-
|
|
271
|
-
{ text: value.text, structured: value.structured, error: value.error? }
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def normalize_mcp_servers(servers, inputs)
|
|
275
|
-
unless defined?(RubyLLM::MCP)
|
|
276
|
-
raise ArgumentError,
|
|
277
|
-
"RubyLLM::MCP is not available; mcps needs a ruby_llm version with MCP support"
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
servers.map do |server|
|
|
281
|
-
case server
|
|
282
|
-
when Class
|
|
283
|
-
unless server <= RubyLLM::MCP
|
|
284
|
-
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
server.new(**inputs)
|
|
288
|
-
when RubyLLM::MCP
|
|
289
|
-
server
|
|
290
|
-
else
|
|
291
|
-
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def bind_mcp_server(server)
|
|
297
|
-
# Fetching .tools connects to the server; snapshot the instructions
|
|
298
|
-
# right after, so description builds never touch the network.
|
|
299
|
-
tools_list = server.tools
|
|
300
|
-
mcps << McpEntry.new(
|
|
301
|
-
name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
|
|
302
|
-
)
|
|
303
|
-
tools_list.each { |tool| bind_mcp_tool(server, tool) }
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
def bind_mcp_tool(server, tool)
|
|
307
|
-
name = tool_binding_name(tool)
|
|
308
|
-
name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
|
|
309
|
-
add_tool(tool, name)
|
|
310
|
-
end
|
|
311
|
-
|
|
312
396
|
def folder_section(header, entries)
|
|
313
397
|
return nil if entries.empty?
|
|
314
398
|
|
|
@@ -319,54 +403,37 @@ module RubyLLM
|
|
|
319
403
|
end
|
|
320
404
|
[header, *lines].join("\n")
|
|
321
405
|
end
|
|
406
|
+
end
|
|
322
407
|
|
|
323
|
-
|
|
324
|
-
return nil if tools.empty?
|
|
325
|
-
|
|
326
|
-
[
|
|
327
|
-
"## Host tools (call with SB.call)",
|
|
328
|
-
HOST_TOOLS_INTRO.rstrip,
|
|
329
|
-
*tools.values.flat_map { |entry| tool_lines(entry) }
|
|
330
|
-
].join("\n")
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def server_notes_section
|
|
334
|
-
notes = mcps.filter_map do |entry|
|
|
335
|
-
instructions = entry.instructions.to_s
|
|
336
|
-
next if instructions.empty?
|
|
337
|
-
|
|
338
|
-
"- **#{entry.name}** — #{instructions}"
|
|
339
|
-
end
|
|
340
|
-
return nil if notes.empty?
|
|
341
|
-
|
|
342
|
-
["## Server notes", *notes].join("\n")
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def tool_lines(entry)
|
|
346
|
-
line = "- `#{entry.name}`"
|
|
347
|
-
description = entry.tool.description.to_s
|
|
348
|
-
line += " — #{description}" unless description.empty?
|
|
408
|
+
parameter :code, description: "Complete Ruby script to execute in the sandbox"
|
|
349
409
|
|
|
350
|
-
|
|
351
|
-
|
|
410
|
+
# Binds more host tools onto this specific instance, on top of the
|
|
411
|
+
# class-level ones. Accepts the same forms as the class-level .tools:
|
|
412
|
+
# classes, ready instances, several at once, arrays, and
|
|
413
|
+
# "name" => tool pairs (hash or kwargs). Instances of the same class are
|
|
414
|
+
# unaffected, and the additions show up in this instance's description,
|
|
415
|
+
# so chats built after the call expose them to the model. Fails fast and
|
|
416
|
+
# rolls everything back; once the first execution happened the sandbox is
|
|
417
|
+
# already built and further additions raise.
|
|
418
|
+
def add_tools(*args, **kwargs)
|
|
419
|
+
ensure_extendable!
|
|
420
|
+
|
|
421
|
+
Registry.register_tools(instance_tools, args, kwargs, reserved: self.class.tools)
|
|
422
|
+
end
|
|
352
423
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
424
|
+
# Connects MCP server(s) onto this specific instance, on top of the
|
|
425
|
+
# class-level ones. Same forms and fail-fast semantics as .mcps
|
|
426
|
+
# (instances, classes with their declared inputs, arrays); the servers'
|
|
427
|
+
# tools bind into this instance only. Raises after the first execution.
|
|
428
|
+
def add_mcps(*servers, **inputs)
|
|
429
|
+
ensure_extendable!
|
|
357
430
|
|
|
358
|
-
|
|
359
|
-
spec ||= {}
|
|
360
|
-
status = required.include?(param) ? "required" : "optional"
|
|
361
|
-
line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
|
|
362
|
-
description = spec["description"].to_s
|
|
363
|
-
line += " — #{description}" unless description.empty?
|
|
364
|
-
line
|
|
365
|
-
end
|
|
366
|
-
end
|
|
431
|
+
Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
|
|
367
432
|
end
|
|
368
433
|
|
|
369
|
-
|
|
434
|
+
def description
|
|
435
|
+
self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
|
|
436
|
+
end
|
|
370
437
|
|
|
371
438
|
def execute(code:)
|
|
372
439
|
format_result(sandbox.eval(code))
|
|
@@ -376,8 +443,38 @@ module RubyLLM
|
|
|
376
443
|
|
|
377
444
|
private
|
|
378
445
|
|
|
446
|
+
def ensure_extendable!
|
|
447
|
+
return unless instance_variable_defined?(:@sandbox)
|
|
448
|
+
|
|
449
|
+
raise ArgumentError,
|
|
450
|
+
"cannot add tools or MCP servers after the first execution; " \
|
|
451
|
+
"create a new #{self.class.name} instance instead"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def instance_tools
|
|
455
|
+
@instance_tools ||= {}
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def instance_mcps
|
|
459
|
+
@instance_mcps ||= []
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Instances without additions share the class configuration; instances
|
|
463
|
+
# with additions build their own, merging the class rpcs with the
|
|
464
|
+
# instance ones (names cannot collide — the registrations validate that).
|
|
465
|
+
def configuration
|
|
466
|
+
return self.class.configuration if instance_tools.empty? && instance_mcps.empty?
|
|
467
|
+
|
|
468
|
+
@configuration ||= SecurityBox::Configuration.build(
|
|
469
|
+
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
470
|
+
fuel_ms: DEFAULT_FUEL_MS,
|
|
471
|
+
mounts: self.class.mounts.map(&:payload),
|
|
472
|
+
rpcs: self.class.configuration.rpcs.merge(Registry.rpc_handlers(instance_tools))
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
|
|
379
476
|
def sandbox
|
|
380
|
-
@sandbox ||= SecurityBox::Sandbox.new(
|
|
477
|
+
@sandbox ||= SecurityBox::Sandbox.new(configuration)
|
|
381
478
|
end
|
|
382
479
|
|
|
383
480
|
def format_result(result)
|