ruby_llm-code_mode 0.1.2 → 0.3.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 +57 -8
- data/lib/ruby_llm/code_mode.rb +147 -25
- 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: 65329e122014fe96a542cab62f9eb0bb002f3c1ac9f53fae0f5d7b384183433e
|
|
4
|
+
data.tar.gz: c9f4272b1b65da90128fb4a62d211a47606814091ee07095fe55535126aea154
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f31a40ab23606bfd6f2c5799ff90c780c514eac95bd3f63d4e4ef933c97adf38ae7d61ca65fe4dc53ba472f8ed38a8a64ae3fcf286656c7694ac2c8c9842817
|
|
7
|
+
data.tar.gz: 1316c738fba0e23e1dc0db07ea63d926cb535bd082cf649a0511c68639e86e777e9077cbe7f1a9973b05e110f023ba9d4d21df3638602956525ca33a2d8e3602
|
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 `
|
|
53
|
+
parameters, when any are bound with `tools` or `mcps`.
|
|
54
54
|
|
|
55
55
|
The single parameter is `code` — a complete, self-contained Ruby script.
|
|
56
56
|
|
|
@@ -70,8 +70,10 @@ A JSON object sent back to the model:
|
|
|
70
70
|
```ruby
|
|
71
71
|
mount source: host_path, dest: guest_path, description: "..." # read-only
|
|
72
72
|
mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
tools SomeRubyLLMTool # host tool
|
|
74
|
+
tools SomeTool, OtherTool # several (or arrays)
|
|
75
|
+
tools "name" => SomeRubyLLMTool # explicit name(s)
|
|
76
|
+
mcps SomeMcpServer # MCP server(s) (instance or class)
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
- `source:` — folder on the host machine, relative to the process working
|
|
@@ -79,9 +81,15 @@ tool "name" => SomeRubyLLMTool # explicit nam
|
|
|
79
81
|
- `dest:` — absolute, normalized path inside the sandbox (may not overlap
|
|
80
82
|
the reserved `/work`, `/usr` or `/src` trees, and must be unique).
|
|
81
83
|
- `description:` — shown to the model in the tool description.
|
|
82
|
-
- `
|
|
83
|
-
is derived from the class-name leaf
|
|
84
|
-
or the explicit one from the
|
|
84
|
+
- `tools` — one or more `RubyLLM::Tool` classes/instances (or arrays of
|
|
85
|
+
them); the name inside the sandbox is derived from the class-name leaf
|
|
86
|
+
(`MyApp::Tools::Weather` → `weather`), or the explicit one from the
|
|
87
|
+
`"name" => tool` pairs. Called without arguments, it returns the bound
|
|
88
|
+
tools.
|
|
89
|
+
- `mcps` — one or more `RubyLLM::MCP` instances or classes
|
|
90
|
+
(auto-instantiated, with the server's declared inputs as keywords); all of
|
|
91
|
+
each server's tools are bound under their own names. Called without
|
|
92
|
+
arguments, it returns the connected servers.
|
|
85
93
|
|
|
86
94
|
Invalid declarations raise at class-definition time, so a misconfigured tool
|
|
87
95
|
never reaches a live chat. Subclasses inherit their parent's mounts and tools.
|
|
@@ -103,8 +111,8 @@ end
|
|
|
103
111
|
|
|
104
112
|
class Assistant < RubyLLM::CodeMode
|
|
105
113
|
mount source: "data", dest: "/data", description: "Project data"
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
tools Weather # binds as `weather`
|
|
115
|
+
tools "forecast" => Weather # or bind it under an explicit name
|
|
108
116
|
end
|
|
109
117
|
```
|
|
110
118
|
|
|
@@ -130,6 +138,47 @@ report = SB.call("weather", city: "Porto Alegre")
|
|
|
130
138
|
tools can be bound to one class.
|
|
131
139
|
- A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
|
|
132
140
|
|
|
141
|
+
### MCP servers
|
|
142
|
+
|
|
143
|
+
The `mcps` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
|
|
144
|
+
and binds all their tools in one call, letting the sandboxed code reach the
|
|
145
|
+
server through the host RPC channel:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
149
|
+
|
|
150
|
+
class DocsResearch < RubyLLM::CodeMode
|
|
151
|
+
mcps docs # instance
|
|
152
|
+
# mcps MicrosoftDocs # or the class (instantiated with .new)
|
|
153
|
+
# mcps Linear, user: current_user # class + the server's declared inputs
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Each server tool keeps its server name and its JSON schema is rendered into
|
|
158
|
+
the tool description, so inside the sandbox the model writes:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
result = SB.call("microsoft_docs_search", query: "Azure Blob Storage")
|
|
162
|
+
result[:text] # => the tool's text output
|
|
163
|
+
result[:structured] # => parsed structured content, or nil
|
|
164
|
+
result[:error] # => true when the tool reported a failure
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- A tool whose name is already bound is re-bound as `<server>_tool`
|
|
168
|
+
(`search` on server `github` → `github_search`); a name that still collides
|
|
169
|
+
after prefixing raises at definition time.
|
|
170
|
+
- Servers that send `instructions` have them listed in the description under
|
|
171
|
+
a "## Server notes" section.
|
|
172
|
+
- Everything fails fast: an argument that is not a `RubyLLM::MCP`, a server
|
|
173
|
+
that cannot be reached, or a stuck name collision roll the whole call back
|
|
174
|
+
and raise at definition time.
|
|
175
|
+
- The class form binds the tools of one instance built at definition time —
|
|
176
|
+
for servers that act per-user, pass a ready instance instead
|
|
177
|
+
(`mcps Linear.new(user: current_user)`) where the user is available.
|
|
178
|
+
|
|
179
|
+
The sandbox itself never sees the network — every `SB.call` performs the MCP
|
|
180
|
+
request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
|
|
181
|
+
|
|
133
182
|
### Sandbox limits
|
|
134
183
|
|
|
135
184
|
Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
|
data/lib/ruby_llm/code_mode.rb
CHANGED
|
@@ -5,14 +5,14 @@ require "security_box"
|
|
|
5
5
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
class CodeMode < Tool
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.3.0"
|
|
9
9
|
|
|
10
10
|
DEFAULT_TIMEOUT_MS = 30_000
|
|
11
11
|
DEFAULT_FUEL_MS = 10_000
|
|
12
12
|
MAX_TOOLS = SecurityBox::Rpcs::MAX_RPCS
|
|
13
13
|
|
|
14
|
-
TOOL_USAGE = "
|
|
15
|
-
'or
|
|
14
|
+
TOOL_USAGE = "tools expects RubyLLM::Tool classes or instances, " \
|
|
15
|
+
'or "name" => tool pairs'
|
|
16
16
|
|
|
17
17
|
FIXED_DESCRIPTION = <<~DESC.freeze
|
|
18
18
|
Executes Ruby code inside a secure sandbox and returns what it produced.
|
|
@@ -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
|
|
@@ -67,32 +69,83 @@ module RubyLLM
|
|
|
67
69
|
add_mount(source, dest, :read_write, description)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
76
102
|
end
|
|
103
|
+
tools
|
|
104
|
+
end
|
|
77
105
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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"
|
|
85
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
|
|
86
143
|
end
|
|
87
144
|
|
|
88
145
|
def mounts
|
|
89
146
|
@mounts ||= []
|
|
90
147
|
end
|
|
91
148
|
|
|
92
|
-
def tools
|
|
93
|
-
@tools ||= {}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
149
|
def configuration
|
|
97
150
|
@configuration ||= SecurityBox::Configuration.build(
|
|
98
151
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
@@ -108,7 +161,8 @@ module RubyLLM
|
|
|
108
161
|
FIXED_DESCRIPTION.rstrip,
|
|
109
162
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
110
163
|
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
111
|
-
tools_section
|
|
164
|
+
tools_section,
|
|
165
|
+
server_notes_section
|
|
112
166
|
].compact.join("\n\n")
|
|
113
167
|
end
|
|
114
168
|
|
|
@@ -128,6 +182,7 @@ module RubyLLM
|
|
|
128
182
|
super
|
|
129
183
|
subclass.instance_variable_set(:@mounts, mounts.dup)
|
|
130
184
|
subclass.instance_variable_set(:@tools, tools.dup)
|
|
185
|
+
subclass.instance_variable_set(:@mcps, mcps.dup)
|
|
131
186
|
subclass.instance_variable_set(:@configuration, nil)
|
|
132
187
|
end
|
|
133
188
|
|
|
@@ -153,7 +208,7 @@ module RubyLLM
|
|
|
153
208
|
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
154
209
|
end
|
|
155
210
|
|
|
156
|
-
name = (name ||
|
|
211
|
+
name = (name || tool_binding_name(instance)).to_s
|
|
157
212
|
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
158
213
|
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
159
214
|
if tools.size >= MAX_TOOLS
|
|
@@ -177,8 +232,8 @@ module RubyLLM
|
|
|
177
232
|
end
|
|
178
233
|
end
|
|
179
234
|
|
|
180
|
-
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
181
|
-
# the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
235
|
+
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
236
|
+
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
182
237
|
def leaf_tool_name(klass)
|
|
183
238
|
leaf = klass.name.to_s.split('::').last
|
|
184
239
|
return "" unless leaf
|
|
@@ -186,6 +241,14 @@ module RubyLLM
|
|
|
186
241
|
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
187
242
|
end
|
|
188
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
|
+
|
|
189
252
|
def build_rpcs
|
|
190
253
|
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
191
254
|
end
|
|
@@ -196,7 +259,54 @@ module RubyLLM
|
|
|
196
259
|
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
197
260
|
end
|
|
198
261
|
|
|
199
|
-
entry.tool.call(**args)
|
|
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)
|
|
200
310
|
end
|
|
201
311
|
|
|
202
312
|
def folder_section(header, entries)
|
|
@@ -220,6 +330,18 @@ module RubyLLM
|
|
|
220
330
|
].join("\n")
|
|
221
331
|
end
|
|
222
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
|
+
|
|
223
345
|
def tool_lines(entry)
|
|
224
346
|
line = "- `#{entry.name}`"
|
|
225
347
|
description = entry.tool.description.to_s
|