ruby_llm-code_mode 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65329e122014fe96a542cab62f9eb0bb002f3c1ac9f53fae0f5d7b384183433e
4
- data.tar.gz: c9f4272b1b65da90128fb4a62d211a47606814091ee07095fe55535126aea154
3
+ metadata.gz: 71758c99ee2449675bbfb17951f4f12e6d9a4e35b7bd414cf9a5ff2a5219cddc
4
+ data.tar.gz: 0ebbe47c2d6b8267583edee716be3f6039cdf27c67debc699dbdf960e8c4e0b5
5
5
  SHA512:
6
- metadata.gz: 7f31a40ab23606bfd6f2c5799ff90c780c514eac95bd3f63d4e4ef933c97adf38ae7d61ca65fe4dc53ba472f8ed38a8a64ae3fcf286656c7694ac2c8c9842817
7
- data.tar.gz: 1316c738fba0e23e1dc0db07ea63d926cb535bd082cf649a0511c68639e86e777e9077cbe7f1a9973b05e110f023ba9d4d21df3638602956525ca33a2d8e3602
6
+ metadata.gz: a95977801ea3cf1fe0c057e2e69e3f965145306e6d2edc588d66c0cc953f0f219ee22da193bed054695ad550e4d921fa29bb36c9d9eaa200a3866953fb94f667
7
+ data.tar.gz: 0a394a1b1c7aafa6d44240b13afb28096dc31a311d22cb6713c2847893c3e1d879c1b0a99732140f695fe65165d62110b8c3ebb982155267bac4c25eeb628b5d
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # ruby_llm-code_mode
1
+ # RubyLLM::CodeMode
2
2
 
3
3
  A [RubyLLM](https://rubyllm.com) tool that runs model-generated Ruby code inside a
4
4
  secure sandbox, powered by [SecurityBox](https://rubygems.org/gems/security_box).
@@ -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
@@ -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).
@@ -219,4 +269,4 @@ rake release # specs + git tag vX.Y.Z + push to GitHub + pu
219
269
  ```
220
270
 
221
271
  `rake release` refuses a dirty working tree or an existing tag and uses the
222
- RubyGems credentials from `~/.gem/credentials`.
272
+ RubyGems credentials from `~/.gem/credentials`.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class CodeMode
5
+ FIXED_DESCRIPTION = <<~DESC.freeze
6
+ Executes Ruby code inside a secure sandbox and returns what it produced.
7
+
8
+ ## How to use
9
+
10
+ Pass a complete, self-contained Ruby script as the `code` argument. It runs
11
+ as a fresh Ruby 4.0 process with:
12
+ - no network, no threads, no subprocesses, and no access to the host
13
+ filesystem outside the mounted folders listed below;
14
+ - a private writable scratch directory at `/work`, wiped between executions;
15
+ - no state carried over between executions, except the contents of
16
+ read-write folders.
17
+
18
+ Capture progress with `puts` (returned as `stdout`) and end with a value or
19
+ a JSON-serializable data structure (returned as `value`; non-serializable
20
+ objects come back as their inspect string). If the code raises, the error
21
+ class, message and backtrace are returned so you can fix and retry.
22
+ DESC
23
+
24
+ HOST_TOOLS_INTRO = <<~DESC.freeze
25
+ Registered host tools are callable from inside the sandbox. Call one with
26
+ `SB.call('name', key: value)`: arguments must be JSON-serializable and become
27
+ the tool's keyword arguments, and the return value is the tool's result
28
+ (non-serializable values come back as their inspect string). Bad arguments
29
+ come back as an `error` hash; handler failures raise `SB::ToolError`
30
+ (rescuable); unknown names raise `SB::UnknownTool`. Limits: at most 1000
31
+ tool calls per execution and 1 MiB per result.
32
+ DESC
33
+
34
+ class << self
35
+ # Builds the description the model sees for this tool: the fixed
36
+ # "How to use" text, one section per mount kind listing each mounted
37
+ # folder, the "Host tools (call with SB.call)" section with every
38
+ # bound tool and its parameters, and the "Server notes" section with
39
+ # each MCP server's instructions. `extra_tools`/`extra_mcps` carry
40
+ # the per-instance additions (see #add_tools/#add_mcps); with no
41
+ # extras the output is identical to the class-level description.
42
+ def build_description(extra_tools: {}, extra_mcps: [])
43
+ read_only, read_write = mounts.partition { |m| m.mode == :read_only }
44
+ [
45
+ FIXED_DESCRIPTION.rstrip,
46
+ folder_section("## Read-only folders (readable, never writable)", read_only),
47
+ folder_section("## Read-write folders (readable and writable)", read_write),
48
+ Registry.tools_section(tools.merge(extra_tools)),
49
+ Registry.server_notes_section(mcps + extra_mcps)
50
+ ].compact.join("\n\n")
51
+ end
52
+
53
+ # Reads the tool description the model sees (the build_description
54
+ # output). Passing a text raises ArgumentError: the description is
55
+ # built from the declared mounts, bound tools and MCP servers, and
56
+ # cannot be set by hand.
57
+ def description(text = nil)
58
+ raise ArgumentError,
59
+ "RubyLLM::CodeMode builds the tool description from the declared mounts; " \
60
+ "the description is fixed and cannot be set by hand" if text
61
+
62
+ build_description
63
+ end
64
+
65
+ private
66
+
67
+ def folder_section(header, entries)
68
+ return nil if entries.empty?
69
+
70
+ lines = entries.map do |entry|
71
+ line = "- `#{entry.guest}`"
72
+ line += " — #{entry.description}" unless entry.description.empty?
73
+ line
74
+ end
75
+ [header, *lines].join("\n")
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class CodeMode
5
+ class << self
6
+ # The tool name presented to the model: the underscored class-name leaf
7
+ # (`MyApp::Tools::Weather` → `weather`, `SalesNotes` → `sales_notes`),
8
+ # without the `_tool` suffix. The base RubyLLM::Tool.tool_name would
9
+ # leak the namespace (`ruby_llm--code_mode`), so CodeMode derives the
10
+ # name from the leaf only.
11
+ def tool_name
12
+ leaf = name.to_s.split('::').last
13
+ return '' unless leaf
14
+
15
+ RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
16
+ end
17
+
18
+ # Mounts a host folder into the sandbox as read-only. `source:` is
19
+ # expanded against the boot working directory (absolute paths pass
20
+ # through); `dest:` must be absolute, unique and outside the reserved
21
+ # trees (`/work`, `/usr`, `/src`). Invalid declarations raise
22
+ # ArgumentError at class-definition time, so a misconfigured tool
23
+ # never reaches a live chat.
24
+ def mount(source:, dest:, description: nil)
25
+ add_mount(source, dest, :read_only, description)
26
+ end
27
+
28
+ # Same as .mount, but the sandboxed code can create, modify and delete
29
+ # files inside the folder (enforced by wasmtime; the contents persist
30
+ # between executions of the same tool instance).
31
+ def mount_rw(source:, dest:, description: nil)
32
+ add_mount(source, dest, :read_write, description)
33
+ end
34
+
35
+ # Binds RubyLLM::Tool classes/instances as host tools callable from
36
+ # inside the sandbox via SB.call:
37
+ #
38
+ # tools Weather # class (instantiated with .new)
39
+ # tools Weather, Echo # several at once
40
+ # tools [Weather, Echo] # or arrays
41
+ # tools "forecast" => Weather # explicit sandbox name
42
+ # tools forecast: Weather # same, as kwargs
43
+ #
44
+ # Without arguments it returns the hash of bound tools. The name is
45
+ # derived from the class-name leaf (`MyApp::Tools::Weather` binds as
46
+ # `weather`). Fails fast: every item must be a RubyLLM::Tool (class or
47
+ # instance), names must be unique and non-empty, and if anything goes
48
+ # wrong nothing is bound.
49
+ def tools(*args, **kwargs)
50
+ return @tools ||= {} if args.empty? && kwargs.empty?
51
+
52
+ Registry.register_tools(tools, args, kwargs)
53
+ end
54
+
55
+ # Connects MCP server(s) and binds all their tools into the sandbox:
56
+ #
57
+ # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
58
+ #
59
+ # class DocsResearch < RubyLLM::CodeMode
60
+ # mcps docs # instance
61
+ # mcps MicrosoftDocs # or the class (instantiated with .new)
62
+ # mcps Linear, user: current_user # class + declared inputs
63
+ # mcps Docs, Github # several servers / arrays at once
64
+ # end
65
+ #
66
+ # Without arguments it returns the array of connected servers. Server
67
+ # tools keep their names (`microsoft_docs_search`, ...); a tool whose
68
+ # name is already bound is re-bound as "<server>_tool". The servers'
69
+ # instructions (when the server sends them) are snapshotted at
70
+ # definition and listed in the description under "## Server notes".
71
+ # Fails fast: every argument must be a RubyLLM::MCP (class or
72
+ # instance), and if anything goes wrong — a server that cannot be
73
+ # reached, a name that stays colliding after prefixing — nothing is
74
+ # bound.
75
+ def mcps(*servers, **inputs)
76
+ return @mcps ||= [] if servers.empty? && inputs.empty?
77
+
78
+ Registry.register_mcps(tools, mcps, servers, inputs)
79
+ end
80
+
81
+ # The mounts declared so far, as Mount structs (read-only and
82
+ # read-write together, in declaration order). Subclasses start with a
83
+ # copy of the parent's list, so adding to a child never touches the
84
+ # parent.
85
+ def mounts
86
+ @mounts ||= []
87
+ end
88
+
89
+ # Builds and memoizes the SecurityBox::Configuration for this class:
90
+ # the default limits (timeout_ms: 30_000, fuel_ms: 10_000), the
91
+ # declared mounts and one RPC handler per bound tool. Memoized per
92
+ # class; subclasses build their own. Tool instances without
93
+ # per-instance additions share it (see #add_tools).
94
+ def configuration
95
+ @configuration ||= SecurityBox::Configuration.build(
96
+ timeout_ms: DEFAULT_TIMEOUT_MS,
97
+ fuel_ms: DEFAULT_FUEL_MS,
98
+ mounts: mounts.map(&:payload),
99
+ rpcs: Registry.rpc_handlers(tools)
100
+ )
101
+ end
102
+
103
+ # Pays the WebAssembly compilation cost up front (about 15 seconds on
104
+ # the first run, cached afterwards in ~/.cache/security_box/modules),
105
+ # so the first real execution takes a few hundred milliseconds
106
+ # instead. Call it once at boot: Analytics.warmup.
107
+ def warmup
108
+ SecurityBox.warmup(timeout_ms: DEFAULT_TIMEOUT_MS, fuel_ms: DEFAULT_FUEL_MS)
109
+ end
110
+
111
+ # Ruby hook: subclasses start with a copy of the parent's mounts,
112
+ # tools and MCP servers (entries and instances shared), and their own
113
+ # configuration memo so limits/rpcs are rebuilt per class.
114
+ def inherited(subclass)
115
+ super
116
+ subclass.instance_variable_set(:@mounts, mounts.dup)
117
+ subclass.instance_variable_set(:@tools, tools.dup)
118
+ subclass.instance_variable_set(:@mcps, mcps.dup)
119
+ subclass.instance_variable_set(:@configuration, nil)
120
+ end
121
+
122
+ private
123
+
124
+ def add_mount(source, dest, mode, description)
125
+ mounts << Mount.new(
126
+ host: File.expand_path(source.to_s),
127
+ guest: dest.to_s,
128
+ mode: mode,
129
+ description: description.to_s
130
+ )
131
+ SecurityBox::Mounts.normalize(mounts.map(&:payload))
132
+ rescue SecurityBox::InvalidConfiguration => e
133
+ mounts.pop
134
+ raise ArgumentError,
135
+ "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class CodeMode
5
+ parameter :code, description: "Complete Ruby script to execute in the sandbox"
6
+
7
+ # Binds more host tools onto this specific instance, on top of the
8
+ # class-level ones. Accepts the same forms as the class-level .tools:
9
+ # classes, ready instances, several at once, arrays, and
10
+ # "name" => tool pairs (hash or kwargs). Instances of the same class are
11
+ # unaffected, and the additions show up in this instance's description,
12
+ # so chats built after the call expose them to the model. Fails fast and
13
+ # rolls everything back; once the first execution happened the sandbox is
14
+ # already built and further additions raise.
15
+ def add_tools(*args, **kwargs)
16
+ ensure_extendable!
17
+
18
+ Registry.register_tools(instance_tools, args, kwargs, reserved: self.class.tools)
19
+ end
20
+
21
+ # Connects MCP server(s) onto this specific instance, on top of the
22
+ # class-level ones. Same forms and fail-fast semantics as .mcps
23
+ # (instances, classes with their declared inputs, arrays); the servers'
24
+ # tools bind into this instance only. Raises after the first execution.
25
+ def add_mcps(*servers, **inputs)
26
+ ensure_extendable!
27
+
28
+ Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
29
+ end
30
+
31
+ # The description this instance presents to the model: the class-level
32
+ # build plus the tools and MCP servers added with #add_tools/#add_mcps.
33
+ # RubyLLM reads it when the tool is registered with a chat, so call
34
+ # chat.with_tools after the additions.
35
+ def description
36
+ self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
37
+ end
38
+
39
+ # Runs the model-generated Ruby script inside the sandbox and returns
40
+ # the result as a hash for the model: `status` ("ok", "error",
41
+ # "timeout", "fuel_exhausted", "memory_limit" or "sandbox_error"),
42
+ # `value` on success, `error` otherwise, plus `stdout`/`stderr` when
43
+ # the guest produced output. Sandbox failures (missing image, invalid
44
+ # configuration) come back as a recoverable "sandbox_error" instead of
45
+ # raising.
46
+ def execute(code:)
47
+ format_result(sandbox.eval(code))
48
+ rescue SecurityBox::Error => e
49
+ { status: "sandbox_error", error: { class: e.class.name, message: e.message } }
50
+ end
51
+
52
+ private
53
+
54
+ def ensure_extendable!
55
+ return unless instance_variable_defined?(:@sandbox)
56
+
57
+ raise ArgumentError,
58
+ "cannot add tools or MCP servers after the first execution; " \
59
+ "create a new #{self.class.name} instance instead"
60
+ end
61
+
62
+ def instance_tools
63
+ @instance_tools ||= {}
64
+ end
65
+
66
+ def instance_mcps
67
+ @instance_mcps ||= []
68
+ end
69
+
70
+ # Instances without additions share the class configuration; instances
71
+ # with additions build their own, merging the class rpcs with the
72
+ # instance ones (names cannot collide — the registrations validate that).
73
+ def configuration
74
+ return self.class.configuration if instance_tools.empty? && instance_mcps.empty?
75
+
76
+ @configuration ||= SecurityBox::Configuration.build(
77
+ timeout_ms: DEFAULT_TIMEOUT_MS,
78
+ fuel_ms: DEFAULT_FUEL_MS,
79
+ mounts: self.class.mounts.map(&:payload),
80
+ rpcs: self.class.configuration.rpcs.merge(Registry.rpc_handlers(instance_tools))
81
+ )
82
+ end
83
+
84
+ def sandbox
85
+ @sandbox ||= SecurityBox::Sandbox.new(configuration)
86
+ end
87
+
88
+ def format_result(result)
89
+ payload = { status: result.status.to_s }
90
+ case result.status
91
+ when :ok
92
+ payload[:value] = result.value
93
+ when :error
94
+ payload[:error] = result.error || { "class" => "Unknown", "message" => "guest code failed" }
95
+ when :timeout
96
+ payload[:error] = "execution timed out before finishing"
97
+ when :fuel_exhausted
98
+ payload[:error] = "execution exceeded its CPU budget before finishing"
99
+ when :memory_limit
100
+ payload[:error] = "execution exceeded the sandbox memory limit"
101
+ when :sandbox_error
102
+ payload[:error] = "the sandbox failed to run the code"
103
+ end
104
+ payload[:stdout] = result.stdout if result.stdout && !result.stdout.empty?
105
+ payload[:stderr] = result.stderr if result.stderr && !result.stderr.empty?
106
+ payload
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class CodeMode
5
+ # Shared registration and rendering core, parameterized by the target
6
+ # collections: the class-level DSL (tools/mcps) and the instance-level API
7
+ # (add_tools/add_mcps) run the exact same validation and rollback logic,
8
+ # only over different hashes/arrays.
9
+ module Registry
10
+ EMPTY = {}.freeze
11
+
12
+ class << self
13
+ # Binds tools into `target` (a name => ToolEntry hash). Accepts tool
14
+ # classes/instances, arrays of them, and "name" => tool pairs (hash
15
+ # arguments or kwargs). All-or-nothing: on any failure `target` is
16
+ # restored and the error re-raised. `reserved` lists names already
17
+ # taken elsewhere (e.g. the class-level bindings for instance
18
+ # additions); duplicates against it raise.
19
+ def register_tools(target, args, kwargs, reserved: EMPTY)
20
+ snapshot = target.dup
21
+ begin
22
+ args.flatten(1).each do |item|
23
+ if item.is_a?(Hash)
24
+ item.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
25
+ else
26
+ add_tool(target, item, nil, reserved)
27
+ end
28
+ end
29
+ kwargs.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
30
+ rescue StandardError
31
+ target.replace(snapshot)
32
+ raise
33
+ end
34
+ target
35
+ end
36
+
37
+ # Connects MCP server(s) and binds all their tools into
38
+ # `target_tools`/`target_mcps`. All-or-nothing across both
39
+ # collections.
40
+ def register_mcps(target_tools, target_mcps, servers, inputs, reserved: EMPTY)
41
+ snapshot_tools = target_tools.dup
42
+ snapshot_mcps = target_mcps.dup
43
+ flat = servers.flatten(1)
44
+ raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
45
+ unless inputs.empty? || flat.all?(Class)
46
+ raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
47
+ end
48
+
49
+ normalize_mcp_servers(flat, inputs).each do |server|
50
+ bind_mcp_server(target_tools, target_mcps, server, reserved)
51
+ end
52
+ target_mcps
53
+ rescue StandardError
54
+ target_tools.replace(snapshot_tools)
55
+ target_mcps.replace(snapshot_mcps)
56
+ raise
57
+ end
58
+
59
+ # One RPC handler per bound tool, ready for
60
+ # SecurityBox::Configuration.build(rpcs:). Each handler validates
61
+ # that the guest passed a hash of arguments, forwards the call to
62
+ # the tool instance (string keys symbolized, schema mistakes coming
63
+ # back as RubyLLM's recoverable error hash) and normalizes MCP
64
+ # Result objects into plain data for the guest.
65
+ def rpc_handlers(tools)
66
+ tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
67
+ end
68
+
69
+ # The "## Host tools (call with SB.call)" description section: the
70
+ # shared intro plus one line per tool with its description and
71
+ # parameter docs. Returns nil when no tools are bound, so the
72
+ # section is omitted from the description entirely.
73
+ def tools_section(tools)
74
+ return nil if tools.empty?
75
+
76
+ [
77
+ "## Host tools (call with SB.call)",
78
+ HOST_TOOLS_INTRO.rstrip,
79
+ *tools.values.flat_map { |entry| tool_lines(entry) }
80
+ ].join("\n")
81
+ end
82
+
83
+ # The "## Server notes" description section: one line per connected
84
+ # MCP server that sent instructions. Returns nil when no server
85
+ # did, so the section is omitted from the description entirely.
86
+ def server_notes_section(mcps)
87
+ notes = mcps.filter_map do |entry|
88
+ instructions = entry.instructions.to_s
89
+ next if instructions.empty?
90
+
91
+ "- **#{entry.name}** — #{instructions}"
92
+ end
93
+ return nil if notes.empty?
94
+
95
+ ["## Server notes", *notes].join("\n")
96
+ end
97
+
98
+ private
99
+
100
+ def add_tool(target, bound, name, reserved)
101
+ instance = normalize_bound_tool(bound)
102
+ if instance.is_a?(RubyLLM::CodeMode)
103
+ raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
104
+ end
105
+
106
+ name = (name || tool_binding_name(instance)).to_s
107
+ raise ArgumentError, "tool name must be a non-empty String" if name.empty?
108
+ if target.key?(name) || reserved.key?(name)
109
+ raise ArgumentError, "tool name #{name.inspect} is already bound"
110
+ end
111
+ total = target.size + reserved.size
112
+ if total >= MAX_TOOLS
113
+ raise ArgumentError, "too many tools (#{total + 1}); the limit is #{MAX_TOOLS}"
114
+ end
115
+
116
+ target[name] = ToolEntry.new(name: name, tool: instance)
117
+ end
118
+
119
+ def normalize_bound_tool(bound)
120
+ if bound.is_a?(Class)
121
+ unless bound <= RubyLLM::Tool
122
+ raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
123
+ end
124
+
125
+ bound.new
126
+ elsif bound.is_a?(RubyLLM::Tool)
127
+ bound
128
+ else
129
+ raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
130
+ end
131
+ end
132
+
133
+ # Leaf-only name derivation, like .tool_name — the guest has no use
134
+ # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
135
+ def leaf_tool_name(klass)
136
+ leaf = klass.name.to_s.split('::').last
137
+ return "" unless leaf
138
+
139
+ RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
140
+ end
141
+
142
+ # Regular tools keep the leaf-only derivation; MCP tools carry their
143
+ # own (server) name, which is what they bind under inside the sandbox.
144
+ def tool_binding_name(instance)
145
+ return instance.name.to_s if instance.respond_to?(:server_name)
146
+
147
+ leaf_tool_name(instance.class)
148
+ end
149
+
150
+ def call_bound_tool(entry, args)
151
+ unless args.is_a?(Hash)
152
+ raise ArgumentError,
153
+ "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
154
+ end
155
+
156
+ normalize_result(entry.tool.call(**args))
157
+ end
158
+
159
+ # MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
160
+ # round-trips handler returns, so give the guest plain data instead
161
+ # of an inspect string.
162
+ def normalize_result(value)
163
+ return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
164
+
165
+ { text: value.text, structured: value.structured, error: value.error? }
166
+ end
167
+
168
+ def normalize_mcp_servers(servers, inputs)
169
+ unless defined?(RubyLLM::MCP)
170
+ raise ArgumentError,
171
+ "RubyLLM::MCP is not available; mcps needs a ruby_llm version with MCP support"
172
+ end
173
+
174
+ servers.map do |server|
175
+ case server
176
+ when Class
177
+ unless server <= RubyLLM::MCP
178
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
179
+ end
180
+
181
+ server.new(**inputs)
182
+ when RubyLLM::MCP
183
+ server
184
+ else
185
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
186
+ end
187
+ end
188
+ end
189
+
190
+ def bind_mcp_server(target_tools, target_mcps, server, reserved)
191
+ # Fetching .tools connects to the server; snapshot the instructions
192
+ # right after, so description builds never touch the network.
193
+ tools_list = server.tools
194
+ target_mcps << McpEntry.new(
195
+ name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
196
+ )
197
+ tools_list.each { |tool| bind_mcp_tool(target_tools, server, tool, reserved) }
198
+ end
199
+
200
+ def bind_mcp_tool(target_tools, server, tool, reserved)
201
+ name = tool_binding_name(tool)
202
+ if (target_tools.key?(name) || reserved.key?(name)) && !server.name.to_s.empty?
203
+ name = "#{server.name}_#{name}"
204
+ end
205
+ add_tool(target_tools, tool, name, reserved)
206
+ end
207
+
208
+ def tool_lines(entry)
209
+ line = "- `#{entry.name}`"
210
+ description = entry.tool.description.to_s
211
+ line += " — #{description}" unless description.empty?
212
+
213
+ [line, *param_lines(entry.tool).map { |param| " #{param}" }]
214
+ end
215
+
216
+ def param_lines(tool)
217
+ schema = tool.parameters_schema || {}
218
+ properties = schema["properties"] || {}
219
+ required = schema["required"] || []
220
+
221
+ properties.filter_map do |param, spec|
222
+ spec ||= {}
223
+ status = required.include?(param) ? "required" : "optional"
224
+ line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
225
+ description = spec["description"].to_s
226
+ line += " — #{description}" unless description.empty?
227
+ line
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -5,7 +5,7 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.3.0"
8
+ VERSION = "0.4.1"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
@@ -14,36 +14,9 @@ module RubyLLM
14
14
  TOOL_USAGE = "tools expects RubyLLM::Tool classes or instances, " \
15
15
  'or "name" => tool pairs'
16
16
 
17
- FIXED_DESCRIPTION = <<~DESC.freeze
18
- Executes Ruby code inside a secure sandbox and returns what it produced.
19
-
20
- ## How to use
21
-
22
- Pass a complete, self-contained Ruby script as the `code` argument. It runs
23
- as a fresh Ruby 4.0 process with:
24
- - no network, no threads, no subprocesses, and no access to the host
25
- filesystem outside the mounted folders listed below;
26
- - a private writable scratch directory at `/work`, wiped between executions;
27
- - no state carried over between executions, except the contents of
28
- read-write folders.
29
-
30
- Capture progress with `puts` (returned as `stdout`) and end with a value or
31
- a JSON-serializable data structure (returned as `value`; non-serializable
32
- objects come back as their inspect string). If the code raises, the error
33
- class, message and backtrace are returned so you can fix and retry.
34
- DESC
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
-
46
17
  Mount = Struct.new(:host, :guest, :mode, :description, keyword_init: true) do
18
+ # The hash shape SecurityBox::Configuration.build expects under
19
+ # `mounts:`.
47
20
  def payload
48
21
  { host: host, guest: guest, mode: mode }
49
22
  end
@@ -52,353 +25,10 @@ module RubyLLM
52
25
  ToolEntry = Struct.new(:name, :tool, keyword_init: true)
53
26
 
54
27
  McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
55
-
56
- class << self
57
- def tool_name
58
- leaf = name.to_s.split('::').last
59
- return '' unless leaf
60
-
61
- RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
62
- end
63
-
64
- def mount(source:, dest:, description: nil)
65
- add_mount(source, dest, :read_only, description)
66
- end
67
-
68
- def mount_rw(source:, dest:, description: nil)
69
- add_mount(source, dest, :read_write, description)
70
- end
71
-
72
- # Binds RubyLLM::Tool classes/instances as host tools callable from
73
- # inside the sandbox via SB.call:
74
- #
75
- # tools Weather # class (instantiated with .new)
76
- # tools Weather, Echo # several at once
77
- # tools [Weather, Echo] # or arrays
78
- # tools "forecast" => Weather # explicit sandbox name
79
- # tools forecast: Weather # same, as kwargs
80
- #
81
- # Without arguments it returns the hash of bound tools. The name is
82
- # derived from the class-name leaf (`MyApp::Tools::Weather` binds as
83
- # `weather`). Fails fast: every item must be a RubyLLM::Tool (class or
84
- # instance), names must be unique and non-empty, and if anything goes
85
- # wrong nothing is bound.
86
- def tools(*args, **kwargs)
87
- return @tools ||= {} if args.empty? && kwargs.empty?
88
-
89
- snapshot = @tools.dup
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
104
- end
105
-
106
- # Connects MCP server(s) and binds all their tools into the sandbox:
107
- #
108
- # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
109
- #
110
- # class DocsResearch < RubyLLM::CodeMode
111
- # mcps docs # instance
112
- # mcps MicrosoftDocs # or the class (instantiated with .new)
113
- # mcps Linear, user: current_user # class + declared inputs
114
- # mcps Docs, Github # several servers / arrays at once
115
- # end
116
- #
117
- # Without arguments it returns the array of connected servers. Server
118
- # tools keep their names (`microsoft_docs_search`, ...); a tool whose
119
- # name is already bound is re-bound as "<server>_tool". The servers'
120
- # instructions (when the server sends them) are snapshotted at
121
- # definition and listed in the description under "## Server notes".
122
- # Fails fast: every argument must be a RubyLLM::MCP (class or
123
- # instance), and if anything goes wrong — a server that cannot be
124
- # reached, a name that stays colliding after prefixing — nothing is
125
- # bound.
126
- def mcps(*servers, **inputs)
127
- return @mcps ||= [] if servers.empty? && inputs.empty?
128
-
129
- flat = servers.flatten(1)
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
143
- end
144
-
145
- def mounts
146
- @mounts ||= []
147
- end
148
-
149
- def configuration
150
- @configuration ||= SecurityBox::Configuration.build(
151
- timeout_ms: DEFAULT_TIMEOUT_MS,
152
- fuel_ms: DEFAULT_FUEL_MS,
153
- mounts: mounts.map(&:payload),
154
- rpcs: build_rpcs
155
- )
156
- end
157
-
158
- def build_description
159
- read_only, read_write = mounts.partition { |m| m.mode == :read_only }
160
- [
161
- FIXED_DESCRIPTION.rstrip,
162
- folder_section("## Read-only folders (readable, never writable)", read_only),
163
- folder_section("## Read-write folders (readable and writable)", read_write),
164
- tools_section,
165
- server_notes_section
166
- ].compact.join("\n\n")
167
- end
168
-
169
- def description(text = nil)
170
- raise ArgumentError,
171
- "RubyLLM::CodeMode builds the tool description from the declared mounts; " \
172
- "the description is fixed and cannot be set by hand" if text
173
-
174
- build_description
175
- end
176
-
177
- def warmup
178
- SecurityBox.warmup(timeout_ms: DEFAULT_TIMEOUT_MS, fuel_ms: DEFAULT_FUEL_MS)
179
- end
180
-
181
- def inherited(subclass)
182
- super
183
- subclass.instance_variable_set(:@mounts, mounts.dup)
184
- subclass.instance_variable_set(:@tools, tools.dup)
185
- subclass.instance_variable_set(:@mcps, mcps.dup)
186
- subclass.instance_variable_set(:@configuration, nil)
187
- end
188
-
189
- private
190
-
191
- def add_mount(source, dest, mode, description)
192
- mounts << Mount.new(
193
- host: File.expand_path(source.to_s),
194
- guest: dest.to_s,
195
- mode: mode,
196
- description: description.to_s
197
- )
198
- SecurityBox::Mounts.normalize(mounts.map(&:payload))
199
- rescue SecurityBox::InvalidConfiguration => e
200
- mounts.pop
201
- raise ArgumentError,
202
- "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
203
- end
204
-
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
- def folder_section(header, entries)
313
- return nil if entries.empty?
314
-
315
- lines = entries.map do |entry|
316
- line = "- `#{entry.guest}`"
317
- line += " — #{entry.description}" unless entry.description.empty?
318
- line
319
- end
320
- [header, *lines].join("\n")
321
- end
322
-
323
- def tools_section
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?
349
-
350
- [line, *param_lines(entry.tool).map { |param| " #{param}" }]
351
- end
352
-
353
- def param_lines(tool)
354
- schema = tool.parameters_schema || {}
355
- properties = schema["properties"] || {}
356
- required = schema["required"] || []
357
-
358
- properties.filter_map do |param, spec|
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
367
- end
368
-
369
- parameter :code, description: "Complete Ruby script to execute in the sandbox"
370
-
371
- def execute(code:)
372
- format_result(sandbox.eval(code))
373
- rescue SecurityBox::Error => e
374
- { status: "sandbox_error", error: { class: e.class.name, message: e.message } }
375
- end
376
-
377
- private
378
-
379
- def sandbox
380
- @sandbox ||= SecurityBox::Sandbox.new(self.class.configuration)
381
- end
382
-
383
- def format_result(result)
384
- payload = { status: result.status.to_s }
385
- case result.status
386
- when :ok
387
- payload[:value] = result.value
388
- when :error
389
- payload[:error] = result.error || { "class" => "Unknown", "message" => "guest code failed" }
390
- when :timeout
391
- payload[:error] = "execution timed out before finishing"
392
- when :fuel_exhausted
393
- payload[:error] = "execution exceeded its CPU budget before finishing"
394
- when :memory_limit
395
- payload[:error] = "execution exceeded the sandbox memory limit"
396
- when :sandbox_error
397
- payload[:error] = "the sandbox failed to run the code"
398
- end
399
- payload[:stdout] = result.stdout if result.stdout && !result.stdout.empty?
400
- payload[:stderr] = result.stderr if result.stderr && !result.stderr.empty?
401
- payload
402
- end
403
28
  end
404
29
  end
30
+
31
+ require_relative "code_mode/description"
32
+ require_relative "code_mode/registry"
33
+ require_relative "code_mode/dsl"
34
+ require_relative "code_mode/instance_methods"
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.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira
@@ -76,6 +76,10 @@ files:
76
76
  - LICENSE
77
77
  - README.md
78
78
  - lib/ruby_llm/code_mode.rb
79
+ - lib/ruby_llm/code_mode/description.rb
80
+ - lib/ruby_llm/code_mode/dsl.rb
81
+ - lib/ruby_llm/code_mode/instance_methods.rb
82
+ - lib/ruby_llm/code_mode/registry.rb
79
83
  licenses:
80
84
  - MIT
81
85
  metadata: