ruby_llm-code_mode 0.1.2 → 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.
- checksums.yaml +4 -4
- data/LICENSE +2 -2
- data/README.md +46 -1
- data/lib/ruby_llm/code_mode.rb +117 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b92ccf0470dc8d3563eafa55662e7aa0db058095d8ac12a0d60a062547474f81
|
|
4
|
+
data.tar.gz: c2955740ee6b04b1a38279b25f63703194df752fa51f3d4f49166bab3f318ac3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
@@ -50,7 +50,7 @@ a fixed description built from the declared mounts:
|
|
|
50
50
|
- a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
|
|
51
51
|
- a **Read-write folders** section listing each `mount_rw`;
|
|
52
52
|
- a **Host tools** section listing every bound `RubyLLM::Tool` with its
|
|
53
|
-
parameters, when any are bound with `tool`.
|
|
53
|
+
parameters, when any are bound with `tool` or `mcp`.
|
|
54
54
|
|
|
55
55
|
The single parameter is `code` — a complete, self-contained Ruby script.
|
|
56
56
|
|
|
@@ -72,6 +72,7 @@ mount source: host_path, dest: guest_path, description: "..." # read-only
|
|
|
72
72
|
mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
73
73
|
tool SomeRubyLLMTool # host tool
|
|
74
74
|
tool "name" => SomeRubyLLMTool # explicit name
|
|
75
|
+
mcp SomeMcpServer # MCP server (instance or class)
|
|
75
76
|
```
|
|
76
77
|
|
|
77
78
|
- `source:` — folder on the host machine, relative to the process working
|
|
@@ -82,6 +83,9 @@ tool "name" => SomeRubyLLMTool # explicit nam
|
|
|
82
83
|
- `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
|
|
83
84
|
is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
|
|
84
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.
|
|
85
89
|
|
|
86
90
|
Invalid declarations raise at class-definition time, so a misconfigured tool
|
|
87
91
|
never reaches a live chat. Subclasses inherit their parent's mounts and tools.
|
|
@@ -130,6 +134,47 @@ report = SB.call("weather", city: "Porto Alegre")
|
|
|
130
134
|
tools can be bound to one class.
|
|
131
135
|
- A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
|
|
132
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/`.
|
|
177
|
+
|
|
133
178
|
### Sandbox limits
|
|
134
179
|
|
|
135
180
|
Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
|
data/lib/ruby_llm/code_mode.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "security_box"
|
|
|
5
5
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
class CodeMode < Tool
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.2.0"
|
|
9
9
|
|
|
10
10
|
DEFAULT_TIMEOUT_MS = 30_000
|
|
11
11
|
DEFAULT_FUEL_MS = 10_000
|
|
@@ -51,6 +51,8 @@ module RubyLLM
|
|
|
51
51
|
|
|
52
52
|
ToolEntry = Struct.new(:name, :tool, keyword_init: true)
|
|
53
53
|
|
|
54
|
+
McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
|
|
55
|
+
|
|
54
56
|
class << self
|
|
55
57
|
def tool_name
|
|
56
58
|
leaf = name.to_s.split('::').last
|
|
@@ -85,6 +87,42 @@ module RubyLLM
|
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
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
|
+
|
|
88
126
|
def mounts
|
|
89
127
|
@mounts ||= []
|
|
90
128
|
end
|
|
@@ -93,6 +131,10 @@ module RubyLLM
|
|
|
93
131
|
@tools ||= {}
|
|
94
132
|
end
|
|
95
133
|
|
|
134
|
+
def mcps
|
|
135
|
+
@mcps ||= []
|
|
136
|
+
end
|
|
137
|
+
|
|
96
138
|
def configuration
|
|
97
139
|
@configuration ||= SecurityBox::Configuration.build(
|
|
98
140
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
@@ -108,7 +150,8 @@ module RubyLLM
|
|
|
108
150
|
FIXED_DESCRIPTION.rstrip,
|
|
109
151
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
110
152
|
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
111
|
-
tools_section
|
|
153
|
+
tools_section,
|
|
154
|
+
server_notes_section
|
|
112
155
|
].compact.join("\n\n")
|
|
113
156
|
end
|
|
114
157
|
|
|
@@ -128,6 +171,7 @@ module RubyLLM
|
|
|
128
171
|
super
|
|
129
172
|
subclass.instance_variable_set(:@mounts, mounts.dup)
|
|
130
173
|
subclass.instance_variable_set(:@tools, tools.dup)
|
|
174
|
+
subclass.instance_variable_set(:@mcps, mcps.dup)
|
|
131
175
|
subclass.instance_variable_set(:@configuration, nil)
|
|
132
176
|
end
|
|
133
177
|
|
|
@@ -153,7 +197,7 @@ module RubyLLM
|
|
|
153
197
|
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
154
198
|
end
|
|
155
199
|
|
|
156
|
-
name = (name ||
|
|
200
|
+
name = (name || tool_binding_name(instance)).to_s
|
|
157
201
|
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
158
202
|
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
159
203
|
if tools.size >= MAX_TOOLS
|
|
@@ -177,8 +221,8 @@ module RubyLLM
|
|
|
177
221
|
end
|
|
178
222
|
end
|
|
179
223
|
|
|
180
|
-
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
181
|
-
# the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
224
|
+
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
225
|
+
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
182
226
|
def leaf_tool_name(klass)
|
|
183
227
|
leaf = klass.name.to_s.split('::').last
|
|
184
228
|
return "" unless leaf
|
|
@@ -186,6 +230,14 @@ module RubyLLM
|
|
|
186
230
|
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
187
231
|
end
|
|
188
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
|
+
|
|
189
241
|
def build_rpcs
|
|
190
242
|
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
191
243
|
end
|
|
@@ -196,7 +248,54 @@ module RubyLLM
|
|
|
196
248
|
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
197
249
|
end
|
|
198
250
|
|
|
199
|
-
entry.tool.call(**args)
|
|
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)
|
|
200
299
|
end
|
|
201
300
|
|
|
202
301
|
def folder_section(header, entries)
|
|
@@ -220,6 +319,18 @@ module RubyLLM
|
|
|
220
319
|
].join("\n")
|
|
221
320
|
end
|
|
222
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
|
+
|
|
223
334
|
def tool_lines(entry)
|
|
224
335
|
line = "- `#{entry.name}`"
|
|
225
336
|
description = entry.tool.description.to_s
|