ruby_llm-code_mode 0.2.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -20
  3. data/lib/ruby_llm/code_mode.rb +311 -203
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b92ccf0470dc8d3563eafa55662e7aa0db058095d8ac12a0d60a062547474f81
4
- data.tar.gz: c2955740ee6b04b1a38279b25f63703194df752fa51f3d4f49166bab3f318ac3
3
+ metadata.gz: fb23ad192879e9ce6bcac0204c62c44ad068943dadd5e46a3a2e82bd177411c7
4
+ data.tar.gz: 39b52ca9cf5f733224a1a8a999ae50cbe091467819d86f63a7c38610be665233
5
5
  SHA512:
6
- metadata.gz: 7fb1029e393ec6c0041d91d5a45fbef2acdc253fe28427986d0832a73eb33b43becfa42d97983212ac7a3312a771e9506765c271bd4bd2ebcf68968b7adf467e
7
- data.tar.gz: 52e9aa1bf43f9dcedf0a6e50deb9578c86716bdd5ae353da6717a6553b7f5170f3773f5c40a4229779589be9686e0a3274788d3dc658cdb084fcc5ff862c206a
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, and give it to
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 and save the report to /workspace/report.md"
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
@@ -50,7 +70,7 @@ a fixed description built from the declared mounts:
50
70
  - a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
51
71
  - a **Read-write folders** section listing each `mount_rw`;
52
72
  - a **Host tools** section listing every bound `RubyLLM::Tool` with its
53
- parameters, when any are bound with `tool` or `mcp`.
73
+ parameters, when any are bound with `tools` or `mcps`.
54
74
 
55
75
  The single parameter is `code` — a complete, self-contained Ruby script.
56
76
 
@@ -70,9 +90,10 @@ A JSON object sent back to the model:
70
90
  ```ruby
71
91
  mount source: host_path, dest: guest_path, description: "..." # read-only
72
92
  mount_rw source: host_path, dest: guest_path, description: "..." # read-write
73
- tool SomeRubyLLMTool # host tool
74
- tool "name" => SomeRubyLLMTool # explicit name
75
- mcp SomeMcpServer # MCP server (instance or class)
93
+ tools SomeRubyLLMTool # host tool
94
+ tools SomeTool, OtherTool # several (or arrays)
95
+ tools "name" => SomeRubyLLMTool # explicit name(s)
96
+ mcps SomeMcpServer # MCP server(s) (instance or class)
76
97
  ```
77
98
 
78
99
  - `source:` — folder on the host machine, relative to the process working
@@ -80,12 +101,15 @@ mcp SomeMcpServer # MCP server (
80
101
  - `dest:` — absolute, normalized path inside the sandbox (may not overlap
81
102
  the reserved `/work`, `/usr` or `/src` trees, and must be unique).
82
103
  - `description:` — shown to the model in the tool description.
83
- - `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
84
- is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
85
- or the explicit one from the one-pair form.
86
- - `mcp` — a `RubyLLM::MCP` instance or class (auto-instantiated, with the
87
- server's declared inputs as keywords); all of the server's tools are bound
88
- under their own names.
104
+ - `tools` — one or more `RubyLLM::Tool` classes/instances (or arrays of
105
+ them); the name inside the sandbox is derived from the class-name leaf
106
+ (`MyApp::Tools::Weather` → `weather`), or the explicit one from the
107
+ `"name" => tool` pairs. Called without arguments, it returns the bound
108
+ tools.
109
+ - `mcps` — one or more `RubyLLM::MCP` instances or classes
110
+ (auto-instantiated, with the server's declared inputs as keywords); all of
111
+ each server's tools are bound under their own names. Called without
112
+ arguments, it returns the connected servers.
89
113
 
90
114
  Invalid declarations raise at class-definition time, so a misconfigured tool
91
115
  never reaches a live chat. Subclasses inherit their parent's mounts and tools.
@@ -107,8 +131,8 @@ end
107
131
 
108
132
  class Assistant < RubyLLM::CodeMode
109
133
  mount source: "data", dest: "/data", description: "Project data"
110
- tool Weather # binds as `weather`
111
- tool "forecast" => Weather # or bind it under an explicit name
134
+ tools Weather # binds as `weather`
135
+ tools "forecast" => Weather # or bind it under an explicit name
112
136
  end
113
137
  ```
114
138
 
@@ -136,7 +160,7 @@ report = SB.call("weather", city: "Porto Alegre")
136
160
 
137
161
  ### MCP servers
138
162
 
139
- The `mcp` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
163
+ The `mcps` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
140
164
  and binds all their tools in one call, letting the sandboxed code reach the
141
165
  server through the host RPC channel:
142
166
 
@@ -144,9 +168,9 @@ server through the host RPC channel:
144
168
  docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
145
169
 
146
170
  class DocsResearch < RubyLLM::CodeMode
147
- mcp docs # instance
148
- # mcp MicrosoftDocs # or the class (instantiated with .new)
149
- # mcp Linear, user: current_user # class + the server's declared inputs
171
+ mcps docs # instance
172
+ # mcps MicrosoftDocs # or the class (instantiated with .new)
173
+ # mcps Linear, user: current_user # class + the server's declared inputs
150
174
  end
151
175
  ```
152
176
 
@@ -170,11 +194,41 @@ result[:error] # => true when the tool reported a failure
170
194
  and raise at definition time.
171
195
  - The class form binds the tools of one instance built at definition time —
172
196
  for servers that act per-user, pass a ready instance instead
173
- (`mcp Linear.new(user: current_user)`) where the user is available.
197
+ (`mcps Linear.new(user: current_user)`) where the user is available.
174
198
 
175
199
  The sandbox itself never sees the network — every `SB.call` performs the MCP
176
200
  request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
177
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
+
178
232
  ### Sandbox limits
179
233
 
180
234
  Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
@@ -5,14 +5,14 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.2.0"
8
+ VERSION = "0.4.0"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
12
12
  MAX_TOOLS = SecurityBox::Rpcs::MAX_RPCS
13
13
 
14
- TOOL_USAGE = "tool expects a RubyLLM::Tool class or instance, " \
15
- 'or exactly one "name" => tool pair'
14
+ TOOL_USAGE = "tools expects RubyLLM::Tool classes or instances, " \
15
+ 'or "name" => tool pairs'
16
16
 
17
17
  FIXED_DESCRIPTION = <<~DESC.freeze
18
18
  Executes Ruby code inside a secure sandbox and returns what it produced.
@@ -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
@@ -69,22 +284,24 @@ module RubyLLM
69
284
  add_mount(source, dest, :read_write, description)
70
285
  end
71
286
 
72
- def tool(mapping = nil, **kwargs)
73
- if !kwargs.empty?
74
- raise ArgumentError, TOOL_USAGE unless kwargs.size == 1 && mapping.nil?
75
-
76
- name, bound = kwargs.first
77
- mapping = { name.to_s => bound }
78
- end
79
-
80
- if mapping.is_a?(Hash)
81
- raise ArgumentError, TOOL_USAGE unless mapping.size == 1
82
-
83
- name, bound = mapping.first
84
- add_tool(bound, name.to_s)
85
- else
86
- add_tool(mapping, nil)
87
- end
287
+ # Binds RubyLLM::Tool classes/instances as host tools callable from
288
+ # inside the sandbox via SB.call:
289
+ #
290
+ # tools Weather # class (instantiated with .new)
291
+ # tools Weather, Echo # several at once
292
+ # tools [Weather, Echo] # or arrays
293
+ # tools "forecast" => Weather # explicit sandbox name
294
+ # tools forecast: Weather # same, as kwargs
295
+ #
296
+ # Without arguments it returns the hash of bound tools. The name is
297
+ # derived from the class-name leaf (`MyApp::Tools::Weather` binds as
298
+ # `weather`). Fails fast: every item must be a RubyLLM::Tool (class or
299
+ # instance), names must be unique and non-empty, and if anything goes
300
+ # wrong nothing is bound.
301
+ def tools(*args, **kwargs)
302
+ return @tools ||= {} if args.empty? && kwargs.empty?
303
+
304
+ Registry.register_tools(tools, args, kwargs)
88
305
  end
89
306
 
90
307
  # Connects MCP server(s) and binds all their tools into the sandbox:
@@ -92,66 +309,51 @@ module RubyLLM
92
309
  # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
93
310
  #
94
311
  # class DocsResearch < RubyLLM::CodeMode
95
- # mcp docs # instance
96
- # mcp MicrosoftDocs # or the class (instantiated with .new)
97
- # mcp Linear, user: current_user # class + declared inputs
98
- # mcp Docs, Github # several servers / arrays at once
312
+ # mcps docs # instance
313
+ # mcps MicrosoftDocs # or the class (instantiated with .new)
314
+ # mcps Linear, user: current_user # class + declared inputs
315
+ # mcps Docs, Github # several servers / arrays at once
99
316
  # end
100
317
  #
101
- # Server tools keep their names (`microsoft_docs_search`, ...); a tool
102
- # whose name is already bound is re-bound as "<server>_tool". The
103
- # servers' instructions (when the server sends them) are snapshotted
104
- # at definition and listed in the description under "## Server notes".
318
+ # Without arguments it returns the array of connected servers. Server
319
+ # tools keep their names (`microsoft_docs_search`, ...); a tool whose
320
+ # name is already bound is re-bound as "<server>_tool". The servers'
321
+ # instructions (when the server sends them) are snapshotted at
322
+ # definition and listed in the description under "## Server notes".
105
323
  # Fails fast: every argument must be a RubyLLM::MCP (class or
106
324
  # instance), and if anything goes wrong — a server that cannot be
107
325
  # reached, a name that stays colliding after prefixing — nothing is
108
326
  # bound.
109
- def mcp(*servers, **inputs)
110
- flat = servers.flatten(1)
111
- raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
112
- unless inputs.empty? || flat.all?(Class)
113
- raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
114
- end
327
+ def mcps(*servers, **inputs)
328
+ return @mcps ||= [] if servers.empty? && inputs.empty?
115
329
 
116
- snapshot_tools = tools.dup
117
- snapshot_mcps = mcps.dup
118
- normalize_mcp_servers(flat, inputs).each { |server| bind_mcp_server(server) }
119
- mcps
120
- rescue StandardError
121
- instance_variable_set(:@tools, snapshot_tools)
122
- instance_variable_set(:@mcps, snapshot_mcps)
123
- raise
330
+ Registry.register_mcps(tools, mcps, servers, inputs)
124
331
  end
125
332
 
126
333
  def mounts
127
334
  @mounts ||= []
128
335
  end
129
336
 
130
- def tools
131
- @tools ||= {}
132
- end
133
-
134
- def mcps
135
- @mcps ||= []
136
- end
137
-
138
337
  def configuration
139
338
  @configuration ||= SecurityBox::Configuration.build(
140
339
  timeout_ms: DEFAULT_TIMEOUT_MS,
141
340
  fuel_ms: DEFAULT_FUEL_MS,
142
341
  mounts: mounts.map(&:payload),
143
- rpcs: build_rpcs
342
+ rpcs: Registry.rpc_handlers(tools)
144
343
  )
145
344
  end
146
345
 
147
- def build_description
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: [])
148
350
  read_only, read_write = mounts.partition { |m| m.mode == :read_only }
149
351
  [
150
352
  FIXED_DESCRIPTION.rstrip,
151
353
  folder_section("## Read-only folders (readable, never writable)", read_only),
152
354
  folder_section("## Read-write folders (readable and writable)", read_write),
153
- tools_section,
154
- server_notes_section
355
+ Registry.tools_section(tools.merge(extra_tools)),
356
+ Registry.server_notes_section(mcps + extra_mcps)
155
357
  ].compact.join("\n\n")
156
358
  end
157
359
 
@@ -191,113 +393,6 @@ module RubyLLM
191
393
  "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
192
394
  end
193
395
 
194
- def add_tool(bound, name)
195
- instance = normalize_bound_tool(bound)
196
- if instance.is_a?(RubyLLM::CodeMode)
197
- raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
198
- end
199
-
200
- name = (name || tool_binding_name(instance)).to_s
201
- raise ArgumentError, "tool name must be a non-empty String" if name.empty?
202
- raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
203
- if tools.size >= MAX_TOOLS
204
- raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
205
- end
206
-
207
- tools[name] = ToolEntry.new(name: name, tool: instance)
208
- end
209
-
210
- def normalize_bound_tool(bound)
211
- if bound.is_a?(Class)
212
- unless bound <= RubyLLM::Tool
213
- raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
214
- end
215
-
216
- bound.new
217
- elsif bound.is_a?(RubyLLM::Tool)
218
- bound
219
- else
220
- raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
221
- end
222
- end
223
-
224
- # Leaf-only name derivation, like .tool_name — the guest has no use
225
- # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
226
- def leaf_tool_name(klass)
227
- leaf = klass.name.to_s.split('::').last
228
- return "" unless leaf
229
-
230
- RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
231
- end
232
-
233
- # Regular tools keep the leaf-only derivation; MCP tools carry their
234
- # own (server) name, which is what they bind under inside the sandbox.
235
- def tool_binding_name(instance)
236
- return instance.name.to_s if instance.respond_to?(:server_name)
237
-
238
- leaf_tool_name(instance.class)
239
- end
240
-
241
- def build_rpcs
242
- tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
243
- end
244
-
245
- def call_bound_tool(entry, args)
246
- unless args.is_a?(Hash)
247
- raise ArgumentError,
248
- "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
249
- end
250
-
251
- normalize_result(entry.tool.call(**args))
252
- end
253
-
254
- # MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
255
- # round-trips handler returns, so give the guest plain data instead
256
- # of an inspect string.
257
- def normalize_result(value)
258
- return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
259
-
260
- { text: value.text, structured: value.structured, error: value.error? }
261
- end
262
-
263
- def normalize_mcp_servers(servers, inputs)
264
- unless defined?(RubyLLM::MCP)
265
- raise ArgumentError,
266
- "RubyLLM::MCP is not available; mcp needs a ruby_llm version with MCP support"
267
- end
268
-
269
- servers.map do |server|
270
- case server
271
- when Class
272
- unless server <= RubyLLM::MCP
273
- raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
274
- end
275
-
276
- server.new(**inputs)
277
- when RubyLLM::MCP
278
- server
279
- else
280
- raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
281
- end
282
- end
283
- end
284
-
285
- def bind_mcp_server(server)
286
- # Fetching .tools connects to the server; snapshot the instructions
287
- # right after, so description builds never touch the network.
288
- tools_list = server.tools
289
- mcps << McpEntry.new(
290
- name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
291
- )
292
- tools_list.each { |tool| bind_mcp_tool(server, tool) }
293
- end
294
-
295
- def bind_mcp_tool(server, tool)
296
- name = tool_binding_name(tool)
297
- name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
298
- add_tool(tool, name)
299
- end
300
-
301
396
  def folder_section(header, entries)
302
397
  return nil if entries.empty?
303
398
 
@@ -308,54 +403,37 @@ module RubyLLM
308
403
  end
309
404
  [header, *lines].join("\n")
310
405
  end
406
+ end
311
407
 
312
- def tools_section
313
- return nil if tools.empty?
314
-
315
- [
316
- "## Host tools (call with SB.call)",
317
- HOST_TOOLS_INTRO.rstrip,
318
- *tools.values.flat_map { |entry| tool_lines(entry) }
319
- ].join("\n")
320
- end
321
-
322
- def server_notes_section
323
- notes = mcps.filter_map do |entry|
324
- instructions = entry.instructions.to_s
325
- next if instructions.empty?
326
-
327
- "- **#{entry.name}** — #{instructions}"
328
- end
329
- return nil if notes.empty?
330
-
331
- ["## Server notes", *notes].join("\n")
332
- end
333
-
334
- def tool_lines(entry)
335
- line = "- `#{entry.name}`"
336
- description = entry.tool.description.to_s
337
- line += " — #{description}" unless description.empty?
408
+ parameter :code, description: "Complete Ruby script to execute in the sandbox"
338
409
 
339
- [line, *param_lines(entry.tool).map { |param| " #{param}" }]
340
- end
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
341
423
 
342
- def param_lines(tool)
343
- schema = tool.parameters_schema || {}
344
- properties = schema["properties"] || {}
345
- required = schema["required"] || []
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!
346
430
 
347
- properties.filter_map do |param, spec|
348
- spec ||= {}
349
- status = required.include?(param) ? "required" : "optional"
350
- line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
351
- description = spec["description"].to_s
352
- line += " — #{description}" unless description.empty?
353
- line
354
- end
355
- end
431
+ Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
356
432
  end
357
433
 
358
- parameter :code, description: "Complete Ruby script to execute in the sandbox"
434
+ def description
435
+ self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
436
+ end
359
437
 
360
438
  def execute(code:)
361
439
  format_result(sandbox.eval(code))
@@ -365,8 +443,38 @@ module RubyLLM
365
443
 
366
444
  private
367
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
+
368
476
  def sandbox
369
- @sandbox ||= SecurityBox::Sandbox.new(self.class.configuration)
477
+ @sandbox ||= SecurityBox::Sandbox.new(configuration)
370
478
  end
371
479
 
372
480
  def format_result(result)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-code_mode
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira