ruby_llm-code_mode 0.4.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: fb23ad192879e9ce6bcac0204c62c44ad068943dadd5e46a3a2e82bd177411c7
4
- data.tar.gz: 39b52ca9cf5f733224a1a8a999ae50cbe091467819d86f63a7c38610be665233
3
+ metadata.gz: 71758c99ee2449675bbfb17951f4f12e6d9a4e35b7bd414cf9a5ff2a5219cddc
4
+ data.tar.gz: 0ebbe47c2d6b8267583edee716be3f6039cdf27c67debc699dbdf960e8c4e0b5
5
5
  SHA512:
6
- metadata.gz: 6e41645fd4404b2a70e3a3606ab6b1aba047e20e1e610278ddaac8ae85b8512d0c0af134a897fface831e7a1547b77610444bbb8dffb6a80b468bf4a79f09af2
7
- data.tar.gz: 60adf578f5deafbd36822e14179e8bcd74856ba6d4b598910cc2be40520ef577f8fd534ffde0ecad2d5ab5c512ff2ac2b98ae60f8c66b0cc0dfa8253bde5b609
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).
@@ -269,4 +269,4 @@ rake release # specs + git tag vX.Y.Z + push to GitHub + pu
269
269
  ```
270
270
 
271
271
  `rake release` refuses a dirty working tree or an existing tag and uses the
272
- 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.4.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,450 +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
- # 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
-
271
- class << self
272
- def tool_name
273
- leaf = name.to_s.split('::').last
274
- return '' unless leaf
275
-
276
- RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
277
- end
278
-
279
- def mount(source:, dest:, description: nil)
280
- add_mount(source, dest, :read_only, description)
281
- end
282
-
283
- def mount_rw(source:, dest:, description: nil)
284
- add_mount(source, dest, :read_write, description)
285
- end
286
-
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)
305
- end
306
-
307
- # Connects MCP server(s) and binds all their tools into the sandbox:
308
- #
309
- # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
310
- #
311
- # class DocsResearch < RubyLLM::CodeMode
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
316
- # end
317
- #
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".
323
- # Fails fast: every argument must be a RubyLLM::MCP (class or
324
- # instance), and if anything goes wrong — a server that cannot be
325
- # reached, a name that stays colliding after prefixing — nothing is
326
- # bound.
327
- def mcps(*servers, **inputs)
328
- return @mcps ||= [] if servers.empty? && inputs.empty?
329
-
330
- Registry.register_mcps(tools, mcps, servers, inputs)
331
- end
332
-
333
- def mounts
334
- @mounts ||= []
335
- end
336
-
337
- def configuration
338
- @configuration ||= SecurityBox::Configuration.build(
339
- timeout_ms: DEFAULT_TIMEOUT_MS,
340
- fuel_ms: DEFAULT_FUEL_MS,
341
- mounts: mounts.map(&:payload),
342
- rpcs: Registry.rpc_handlers(tools)
343
- )
344
- end
345
-
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: [])
350
- read_only, read_write = mounts.partition { |m| m.mode == :read_only }
351
- [
352
- FIXED_DESCRIPTION.rstrip,
353
- folder_section("## Read-only folders (readable, never writable)", read_only),
354
- folder_section("## Read-write folders (readable and writable)", read_write),
355
- Registry.tools_section(tools.merge(extra_tools)),
356
- Registry.server_notes_section(mcps + extra_mcps)
357
- ].compact.join("\n\n")
358
- end
359
-
360
- def description(text = nil)
361
- raise ArgumentError,
362
- "RubyLLM::CodeMode builds the tool description from the declared mounts; " \
363
- "the description is fixed and cannot be set by hand" if text
364
-
365
- build_description
366
- end
367
-
368
- def warmup
369
- SecurityBox.warmup(timeout_ms: DEFAULT_TIMEOUT_MS, fuel_ms: DEFAULT_FUEL_MS)
370
- end
371
-
372
- def inherited(subclass)
373
- super
374
- subclass.instance_variable_set(:@mounts, mounts.dup)
375
- subclass.instance_variable_set(:@tools, tools.dup)
376
- subclass.instance_variable_set(:@mcps, mcps.dup)
377
- subclass.instance_variable_set(:@configuration, nil)
378
- end
379
-
380
- private
381
-
382
- def add_mount(source, dest, mode, description)
383
- mounts << Mount.new(
384
- host: File.expand_path(source.to_s),
385
- guest: dest.to_s,
386
- mode: mode,
387
- description: description.to_s
388
- )
389
- SecurityBox::Mounts.normalize(mounts.map(&:payload))
390
- rescue SecurityBox::InvalidConfiguration => e
391
- mounts.pop
392
- raise ArgumentError,
393
- "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
394
- end
395
-
396
- def folder_section(header, entries)
397
- return nil if entries.empty?
398
-
399
- lines = entries.map do |entry|
400
- line = "- `#{entry.guest}`"
401
- line += " — #{entry.description}" unless entry.description.empty?
402
- line
403
- end
404
- [header, *lines].join("\n")
405
- end
406
- end
407
-
408
- parameter :code, description: "Complete Ruby script to execute in the sandbox"
409
-
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
423
-
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!
430
-
431
- Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
432
- end
433
-
434
- def description
435
- self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
436
- end
437
-
438
- def execute(code:)
439
- format_result(sandbox.eval(code))
440
- rescue SecurityBox::Error => e
441
- { status: "sandbox_error", error: { class: e.class.name, message: e.message } }
442
- end
443
-
444
- private
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
-
476
- def sandbox
477
- @sandbox ||= SecurityBox::Sandbox.new(configuration)
478
- end
479
-
480
- def format_result(result)
481
- payload = { status: result.status.to_s }
482
- case result.status
483
- when :ok
484
- payload[:value] = result.value
485
- when :error
486
- payload[:error] = result.error || { "class" => "Unknown", "message" => "guest code failed" }
487
- when :timeout
488
- payload[:error] = "execution timed out before finishing"
489
- when :fuel_exhausted
490
- payload[:error] = "execution exceeded its CPU budget before finishing"
491
- when :memory_limit
492
- payload[:error] = "execution exceeded the sandbox memory limit"
493
- when :sandbox_error
494
- payload[:error] = "the sandbox failed to run the code"
495
- end
496
- payload[:stdout] = result.stdout if result.stdout && !result.stdout.empty?
497
- payload[:stderr] = result.stderr if result.stderr && !result.stderr.empty?
498
- payload
499
- end
500
28
  end
501
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.4.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: