ruby_llm-code_mode 0.1.1 → 0.2.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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +2 -2
  3. data/README.md +101 -7
  4. data/lib/ruby_llm/code_mode.rb +240 -3
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d933b7782ef9b21eb6c9c704630a456b1aa96ffecc52e4a258aa50fd99e09ebf
4
- data.tar.gz: d006d9fbac9d85b569a0978f5bf74cca9430ffe8bf7ba47c526a83ecdb52b7ac
3
+ metadata.gz: b92ccf0470dc8d3563eafa55662e7aa0db058095d8ac12a0d60a062547474f81
4
+ data.tar.gz: c2955740ee6b04b1a38279b25f63703194df752fa51f3d4f49166bab3f318ac3
5
5
  SHA512:
6
- metadata.gz: 0ce747225815a3dd6a9aeccc8ccbb74ac64d9a24e7716797941d75afb009e55c7a56a899abafaeac5a490ad7a921ce0c771955ced03d72861f9472c42f3a2c13
7
- data.tar.gz: e62fdd4dbe81c6dd7034ea4e27d1ff18c0aa2bf22d83ab58a94d35e1b51fc045db31e73fd4a560c13ffc5615d8e800cdb83be2f0c38c704c1a8e9c767fec97cd
6
+ metadata.gz: 7fb1029e393ec6c0041d91d5a45fbef2acdc253fe28427986d0832a73eb33b43becfa42d97983212ac7a3312a771e9506765c271bd4bd2ebcf68968b7adf467e
7
+ data.tar.gz: 52e9aa1bf43f9dcedf0a6e50deb9578c86716bdd5ae353da6717a6553b7f5170f3773f5c40a4229779589be9686e0a3274788d3dc658cdb084fcc5ff862c206a
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Juneira
3
+ Copyright (c) 2026 Marcelo Junior
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
data/README.md CHANGED
@@ -48,7 +48,9 @@ a fixed description built from the declared mounts:
48
48
  from the host, with a private `/work` scratch directory wiped between
49
49
  executions and no state carried over between runs;
50
50
  - a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
51
- - a **Read-write folders** section listing each `mount_rw`.
51
+ - a **Read-write folders** section listing each `mount_rw`;
52
+ - a **Host tools** section listing every bound `RubyLLM::Tool` with its
53
+ parameters, when any are bound with `tool` or `mcp`.
52
54
 
53
55
  The single parameter is `code` — a complete, self-contained Ruby script.
54
56
 
@@ -68,6 +70,9 @@ A JSON object sent back to the model:
68
70
  ```ruby
69
71
  mount source: host_path, dest: guest_path, description: "..." # read-only
70
72
  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)
71
76
  ```
72
77
 
73
78
  - `source:` — folder on the host machine, relative to the process working
@@ -75,9 +80,100 @@ mount_rw source: host_path, dest: guest_path, description: "..." # read-write
75
80
  - `dest:` — absolute, normalized path inside the sandbox (may not overlap
76
81
  the reserved `/work`, `/usr` or `/src` trees, and must be unique).
77
82
  - `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.
78
89
 
79
90
  Invalid declarations raise at class-definition time, so a misconfigured tool
80
- never reaches a live chat. Subclasses inherit their parent's mounts.
91
+ never reaches a live chat. Subclasses inherit their parent's mounts and tools.
92
+
93
+ ### Host tools
94
+
95
+ Bind existing `RubyLLM::Tool`s so the sandboxed code can call them through
96
+ SecurityBox's host RPC channel:
97
+
98
+ ```ruby
99
+ class Weather < RubyLLM::Tool
100
+ description "Current weather for a city"
101
+ parameter :city, type: "string", description: "City name"
102
+
103
+ def execute(city:)
104
+ # host-side HTTP call, database query, ...
105
+ end
106
+ end
107
+
108
+ class Assistant < RubyLLM::CodeMode
109
+ 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
112
+ end
113
+ ```
114
+
115
+ The description tells the model what is available, and inside the sandbox it
116
+ writes Ruby like:
117
+
118
+ ```ruby
119
+ report = SB.call("weather", city: "Porto Alegre")
120
+ ```
121
+
122
+ - Bound classes are instantiated once at definition time; every call shares
123
+ that instance, so state persists across calls (like the sandbox itself).
124
+ - Arguments must be JSON-serializable; they arrive as the tool's keyword
125
+ arguments (top-level string keys become symbols, nested data keeps string
126
+ keys). Bad arguments come back as an `error` hash — RubyLLM's
127
+ recoverable-failure convention.
128
+ - The return value is JSON round-tripped: hashes, arrays and scalars pass
129
+ through; non-serializable objects surface as their `inspect` string.
130
+ - A tool that raises surfaces as `SB::ToolError` (class name and message
131
+ only — no backtrace), which the sandboxed code can rescue. Unbound names
132
+ raise `SB::UnknownTool`.
133
+ - Limits per execution: 1000 tool calls and 1 MiB per result. At most 64
134
+ tools can be bound to one class.
135
+ - A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
136
+
137
+ ### MCP servers
138
+
139
+ The `mcp` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
140
+ and binds all their tools in one call, letting the sandboxed code reach the
141
+ server through the host RPC channel:
142
+
143
+ ```ruby
144
+ docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
145
+
146
+ 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
150
+ end
151
+ ```
152
+
153
+ Each server tool keeps its server name and its JSON schema is rendered into
154
+ the tool description, so inside the sandbox the model writes:
155
+
156
+ ```ruby
157
+ result = SB.call("microsoft_docs_search", query: "Azure Blob Storage")
158
+ result[:text] # => the tool's text output
159
+ result[:structured] # => parsed structured content, or nil
160
+ result[:error] # => true when the tool reported a failure
161
+ ```
162
+
163
+ - A tool whose name is already bound is re-bound as `<server>_tool`
164
+ (`search` on server `github` → `github_search`); a name that still collides
165
+ after prefixing raises at definition time.
166
+ - Servers that send `instructions` have them listed in the description under
167
+ a "## Server notes" section.
168
+ - Everything fails fast: an argument that is not a `RubyLLM::MCP`, a server
169
+ that cannot be reached, or a stuck name collision roll the whole call back
170
+ and raise at definition time.
171
+ - The class form binds the tools of one instance built at definition time —
172
+ for servers that act per-user, pass a ready instance instead
173
+ (`mcp Linear.new(user: current_user)`) where the user is available.
174
+
175
+ The sandbox itself never sees the network — every `SB.call` performs the MCP
176
+ request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
81
177
 
82
178
  ### Sandbox limits
83
179
 
@@ -96,11 +192,9 @@ every subsequent evaluation then takes a few hundred milliseconds.
96
192
  - Guest code is sandboxed by [SecurityBox](https://rubygems.org/gems/security_box):
97
193
  no network, no threads, no processes, no host filesystem beyond the mounts,
98
194
  deterministic CPU/memory/time limits, and forged results are rejected.
99
-
100
- ## Roadmap
101
-
102
- - v2: a **Tools** section in the description backed by SecurityBox host RPC
103
- handlers, so guest code can call registered host functions via `SB.call`.
195
+ - Bound host tools execute on the host, behind the sandbox's call boundary:
196
+ their arguments come from model-generated code, so treat them as untrusted
197
+ input, and only bind tools whose effects you are willing to grant the model.
104
198
 
105
199
  ## Development
106
200
 
@@ -5,10 +5,14 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.1.1"
8
+ VERSION = "0.2.0"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
12
+ MAX_TOOLS = SecurityBox::Rpcs::MAX_RPCS
13
+
14
+ TOOL_USAGE = "tool expects a RubyLLM::Tool class or instance, " \
15
+ 'or exactly one "name" => tool pair'
12
16
 
13
17
  FIXED_DESCRIPTION = <<~DESC.freeze
14
18
  Executes Ruby code inside a secure sandbox and returns what it produced.
@@ -29,12 +33,26 @@ module RubyLLM
29
33
  class, message and backtrace are returned so you can fix and retry.
30
34
  DESC
31
35
 
36
+ HOST_TOOLS_INTRO = <<~DESC.freeze
37
+ Registered host tools are callable from inside the sandbox. Call one with
38
+ `SB.call('name', key: value)`: arguments must be JSON-serializable and become
39
+ the tool's keyword arguments, and the return value is the tool's result
40
+ (non-serializable values come back as their inspect string). Bad arguments
41
+ come back as an `error` hash; handler failures raise `SB::ToolError`
42
+ (rescuable); unknown names raise `SB::UnknownTool`. Limits: at most 1000
43
+ tool calls per execution and 1 MiB per result.
44
+ DESC
45
+
32
46
  Mount = Struct.new(:host, :guest, :mode, :description, keyword_init: true) do
33
47
  def payload
34
48
  { host: host, guest: guest, mode: mode }
35
49
  end
36
50
  end
37
51
 
52
+ ToolEntry = Struct.new(:name, :tool, keyword_init: true)
53
+
54
+ McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
55
+
38
56
  class << self
39
57
  def tool_name
40
58
  leaf = name.to_s.split('::').last
@@ -51,15 +69,78 @@ module RubyLLM
51
69
  add_mount(source, dest, :read_write, description)
52
70
  end
53
71
 
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
88
+ end
89
+
90
+ # Connects MCP server(s) and binds all their tools into the sandbox:
91
+ #
92
+ # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
93
+ #
94
+ # 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
99
+ # end
100
+ #
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".
105
+ # Fails fast: every argument must be a RubyLLM::MCP (class or
106
+ # instance), and if anything goes wrong — a server that cannot be
107
+ # reached, a name that stays colliding after prefixing — nothing is
108
+ # 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
115
+
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
124
+ end
125
+
54
126
  def mounts
55
127
  @mounts ||= []
56
128
  end
57
129
 
130
+ def tools
131
+ @tools ||= {}
132
+ end
133
+
134
+ def mcps
135
+ @mcps ||= []
136
+ end
137
+
58
138
  def configuration
59
139
  @configuration ||= SecurityBox::Configuration.build(
60
140
  timeout_ms: DEFAULT_TIMEOUT_MS,
61
141
  fuel_ms: DEFAULT_FUEL_MS,
62
- mounts: mounts.map(&:payload)
142
+ mounts: mounts.map(&:payload),
143
+ rpcs: build_rpcs
63
144
  )
64
145
  end
65
146
 
@@ -68,7 +149,9 @@ module RubyLLM
68
149
  [
69
150
  FIXED_DESCRIPTION.rstrip,
70
151
  folder_section("## Read-only folders (readable, never writable)", read_only),
71
- folder_section("## Read-write folders (readable and writable)", read_write)
152
+ folder_section("## Read-write folders (readable and writable)", read_write),
153
+ tools_section,
154
+ server_notes_section
72
155
  ].compact.join("\n\n")
73
156
  end
74
157
 
@@ -87,6 +170,8 @@ module RubyLLM
87
170
  def inherited(subclass)
88
171
  super
89
172
  subclass.instance_variable_set(:@mounts, mounts.dup)
173
+ subclass.instance_variable_set(:@tools, tools.dup)
174
+ subclass.instance_variable_set(:@mcps, mcps.dup)
90
175
  subclass.instance_variable_set(:@configuration, nil)
91
176
  end
92
177
 
@@ -106,6 +191,113 @@ module RubyLLM
106
191
  "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
107
192
  end
108
193
 
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
+
109
301
  def folder_section(header, entries)
110
302
  return nil if entries.empty?
111
303
 
@@ -116,6 +308,51 @@ module RubyLLM
116
308
  end
117
309
  [header, *lines].join("\n")
118
310
  end
311
+
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?
338
+
339
+ [line, *param_lines(entry.tool).map { |param| " #{param}" }]
340
+ end
341
+
342
+ def param_lines(tool)
343
+ schema = tool.parameters_schema || {}
344
+ properties = schema["properties"] || {}
345
+ required = schema["required"] || []
346
+
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
119
356
  end
120
357
 
121
358
  parameter :code, description: "Complete Ruby script to execute in the sandbox"
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.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira