ruby_llm-code_mode 0.2.0 → 0.4.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/README.md +74 -20
- data/lib/ruby_llm/code_mode.rb +311 -203
- 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: fb23ad192879e9ce6bcac0204c62c44ad068943dadd5e46a3a2e82bd177411c7
|
|
4
|
+
data.tar.gz: 39b52ca9cf5f733224a1a8a999ae50cbe091467819d86f63a7c38610be665233
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e41645fd4404b2a70e3a3606ab6b1aba047e20e1e610278ddaac8ae85b8512d0c0af134a897fface831e7a1547b77610444bbb8dffb6a80b468bf4a79f09af2
|
|
7
|
+
data.tar.gz: 60adf578f5deafbd36822e14179e8bcd74856ba6d4b598910cc2be40520ef577f8fd534ffde0ecad2d5ab5c512ff2ac2b98ae60f8c66b0cc0dfa8253bde5b609
|
data/README.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
@@ -50,7 +70,7 @@ a fixed description built from the declared mounts:
|
|
|
50
70
|
- a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
|
|
51
71
|
- a **Read-write folders** section listing each `mount_rw`;
|
|
52
72
|
- a **Host tools** section listing every bound `RubyLLM::Tool` with its
|
|
53
|
-
parameters, when any are bound with `
|
|
73
|
+
parameters, when any are bound with `tools` or `mcps`.
|
|
54
74
|
|
|
55
75
|
The single parameter is `code` — a complete, self-contained Ruby script.
|
|
56
76
|
|
|
@@ -70,9 +90,10 @@ A JSON object sent back to the model:
|
|
|
70
90
|
```ruby
|
|
71
91
|
mount source: host_path, dest: guest_path, description: "..." # read-only
|
|
72
92
|
mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
tools SomeRubyLLMTool # host tool
|
|
94
|
+
tools SomeTool, OtherTool # several (or arrays)
|
|
95
|
+
tools "name" => SomeRubyLLMTool # explicit name(s)
|
|
96
|
+
mcps SomeMcpServer # MCP server(s) (instance or class)
|
|
76
97
|
```
|
|
77
98
|
|
|
78
99
|
- `source:` — folder on the host machine, relative to the process working
|
|
@@ -80,12 +101,15 @@ mcp SomeMcpServer # MCP server (
|
|
|
80
101
|
- `dest:` — absolute, normalized path inside the sandbox (may not overlap
|
|
81
102
|
the reserved `/work`, `/usr` or `/src` trees, and must be unique).
|
|
82
103
|
- `description:` — shown to the model in the tool description.
|
|
83
|
-
- `
|
|
84
|
-
is derived from the class-name leaf
|
|
85
|
-
or the explicit one from the
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
- `tools` — one or more `RubyLLM::Tool` classes/instances (or arrays of
|
|
105
|
+
them); the name inside the sandbox is derived from the class-name leaf
|
|
106
|
+
(`MyApp::Tools::Weather` → `weather`), or the explicit one from the
|
|
107
|
+
`"name" => tool` pairs. Called without arguments, it returns the bound
|
|
108
|
+
tools.
|
|
109
|
+
- `mcps` — one or more `RubyLLM::MCP` instances or classes
|
|
110
|
+
(auto-instantiated, with the server's declared inputs as keywords); all of
|
|
111
|
+
each server's tools are bound under their own names. Called without
|
|
112
|
+
arguments, it returns the connected servers.
|
|
89
113
|
|
|
90
114
|
Invalid declarations raise at class-definition time, so a misconfigured tool
|
|
91
115
|
never reaches a live chat. Subclasses inherit their parent's mounts and tools.
|
|
@@ -107,8 +131,8 @@ end
|
|
|
107
131
|
|
|
108
132
|
class Assistant < RubyLLM::CodeMode
|
|
109
133
|
mount source: "data", dest: "/data", description: "Project data"
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
tools Weather # binds as `weather`
|
|
135
|
+
tools "forecast" => Weather # or bind it under an explicit name
|
|
112
136
|
end
|
|
113
137
|
```
|
|
114
138
|
|
|
@@ -136,7 +160,7 @@ report = SB.call("weather", city: "Porto Alegre")
|
|
|
136
160
|
|
|
137
161
|
### MCP servers
|
|
138
162
|
|
|
139
|
-
The `
|
|
163
|
+
The `mcps` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
|
|
140
164
|
and binds all their tools in one call, letting the sandboxed code reach the
|
|
141
165
|
server through the host RPC channel:
|
|
142
166
|
|
|
@@ -144,9 +168,9 @@ server through the host RPC channel:
|
|
|
144
168
|
docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
145
169
|
|
|
146
170
|
class DocsResearch < RubyLLM::CodeMode
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
#
|
|
171
|
+
mcps docs # instance
|
|
172
|
+
# mcps MicrosoftDocs # or the class (instantiated with .new)
|
|
173
|
+
# mcps Linear, user: current_user # class + the server's declared inputs
|
|
150
174
|
end
|
|
151
175
|
```
|
|
152
176
|
|
|
@@ -170,11 +194,41 @@ result[:error] # => true when the tool reported a failure
|
|
|
170
194
|
and raise at definition time.
|
|
171
195
|
- The class form binds the tools of one instance built at definition time —
|
|
172
196
|
for servers that act per-user, pass a ready instance instead
|
|
173
|
-
(`
|
|
197
|
+
(`mcps Linear.new(user: current_user)`) where the user is available.
|
|
174
198
|
|
|
175
199
|
The sandbox itself never sees the network — every `SB.call` performs the MCP
|
|
176
200
|
request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
|
|
177
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
|
+
|
|
178
232
|
### Sandbox limits
|
|
179
233
|
|
|
180
234
|
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.4.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.
|
|
@@ -53,6 +53,221 @@ module RubyLLM
|
|
|
53
53
|
|
|
54
54
|
McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
|
|
55
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
|
+
|
|
56
271
|
class << self
|
|
57
272
|
def tool_name
|
|
58
273
|
leaf = name.to_s.split('::').last
|
|
@@ -69,22 +284,24 @@ module RubyLLM
|
|
|
69
284
|
add_mount(source, dest, :read_write, description)
|
|
70
285
|
end
|
|
71
286
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
88
305
|
end
|
|
89
306
|
|
|
90
307
|
# Connects MCP server(s) and binds all their tools into the sandbox:
|
|
@@ -92,66 +309,51 @@ module RubyLLM
|
|
|
92
309
|
# docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
93
310
|
#
|
|
94
311
|
# class DocsResearch < RubyLLM::CodeMode
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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
|
|
99
316
|
# end
|
|
100
317
|
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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".
|
|
105
323
|
# Fails fast: every argument must be a RubyLLM::MCP (class or
|
|
106
324
|
# instance), and if anything goes wrong — a server that cannot be
|
|
107
325
|
# reached, a name that stays colliding after prefixing — nothing is
|
|
108
326
|
# bound.
|
|
109
|
-
def
|
|
110
|
-
|
|
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
|
|
327
|
+
def mcps(*servers, **inputs)
|
|
328
|
+
return @mcps ||= [] if servers.empty? && inputs.empty?
|
|
115
329
|
|
|
116
|
-
|
|
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
|
|
330
|
+
Registry.register_mcps(tools, mcps, servers, inputs)
|
|
124
331
|
end
|
|
125
332
|
|
|
126
333
|
def mounts
|
|
127
334
|
@mounts ||= []
|
|
128
335
|
end
|
|
129
336
|
|
|
130
|
-
def tools
|
|
131
|
-
@tools ||= {}
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def mcps
|
|
135
|
-
@mcps ||= []
|
|
136
|
-
end
|
|
137
|
-
|
|
138
337
|
def configuration
|
|
139
338
|
@configuration ||= SecurityBox::Configuration.build(
|
|
140
339
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
141
340
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
142
341
|
mounts: mounts.map(&:payload),
|
|
143
|
-
rpcs:
|
|
342
|
+
rpcs: Registry.rpc_handlers(tools)
|
|
144
343
|
)
|
|
145
344
|
end
|
|
146
345
|
|
|
147
|
-
|
|
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: [])
|
|
148
350
|
read_only, read_write = mounts.partition { |m| m.mode == :read_only }
|
|
149
351
|
[
|
|
150
352
|
FIXED_DESCRIPTION.rstrip,
|
|
151
353
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
152
354
|
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
153
|
-
tools_section,
|
|
154
|
-
server_notes_section
|
|
355
|
+
Registry.tools_section(tools.merge(extra_tools)),
|
|
356
|
+
Registry.server_notes_section(mcps + extra_mcps)
|
|
155
357
|
].compact.join("\n\n")
|
|
156
358
|
end
|
|
157
359
|
|
|
@@ -191,113 +393,6 @@ module RubyLLM
|
|
|
191
393
|
"Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
|
|
192
394
|
end
|
|
193
395
|
|
|
194
|
-
def add_tool(bound, name)
|
|
195
|
-
instance = normalize_bound_tool(bound)
|
|
196
|
-
if instance.is_a?(RubyLLM::CodeMode)
|
|
197
|
-
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
name = (name || tool_binding_name(instance)).to_s
|
|
201
|
-
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
202
|
-
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
203
|
-
if tools.size >= MAX_TOOLS
|
|
204
|
-
raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
tools[name] = ToolEntry.new(name: name, tool: instance)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def normalize_bound_tool(bound)
|
|
211
|
-
if bound.is_a?(Class)
|
|
212
|
-
unless bound <= RubyLLM::Tool
|
|
213
|
-
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
bound.new
|
|
217
|
-
elsif bound.is_a?(RubyLLM::Tool)
|
|
218
|
-
bound
|
|
219
|
-
else
|
|
220
|
-
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
225
|
-
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
226
|
-
def leaf_tool_name(klass)
|
|
227
|
-
leaf = klass.name.to_s.split('::').last
|
|
228
|
-
return "" unless leaf
|
|
229
|
-
|
|
230
|
-
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# Regular tools keep the leaf-only derivation; MCP tools carry their
|
|
234
|
-
# own (server) name, which is what they bind under inside the sandbox.
|
|
235
|
-
def tool_binding_name(instance)
|
|
236
|
-
return instance.name.to_s if instance.respond_to?(:server_name)
|
|
237
|
-
|
|
238
|
-
leaf_tool_name(instance.class)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def build_rpcs
|
|
242
|
-
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def call_bound_tool(entry, args)
|
|
246
|
-
unless args.is_a?(Hash)
|
|
247
|
-
raise ArgumentError,
|
|
248
|
-
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
normalize_result(entry.tool.call(**args))
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
|
|
255
|
-
# round-trips handler returns, so give the guest plain data instead
|
|
256
|
-
# of an inspect string.
|
|
257
|
-
def normalize_result(value)
|
|
258
|
-
return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
|
|
259
|
-
|
|
260
|
-
{ text: value.text, structured: value.structured, error: value.error? }
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def normalize_mcp_servers(servers, inputs)
|
|
264
|
-
unless defined?(RubyLLM::MCP)
|
|
265
|
-
raise ArgumentError,
|
|
266
|
-
"RubyLLM::MCP is not available; mcp needs a ruby_llm version with MCP support"
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
servers.map do |server|
|
|
270
|
-
case server
|
|
271
|
-
when Class
|
|
272
|
-
unless server <= RubyLLM::MCP
|
|
273
|
-
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
server.new(**inputs)
|
|
277
|
-
when RubyLLM::MCP
|
|
278
|
-
server
|
|
279
|
-
else
|
|
280
|
-
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def bind_mcp_server(server)
|
|
286
|
-
# Fetching .tools connects to the server; snapshot the instructions
|
|
287
|
-
# right after, so description builds never touch the network.
|
|
288
|
-
tools_list = server.tools
|
|
289
|
-
mcps << McpEntry.new(
|
|
290
|
-
name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
|
|
291
|
-
)
|
|
292
|
-
tools_list.each { |tool| bind_mcp_tool(server, tool) }
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def bind_mcp_tool(server, tool)
|
|
296
|
-
name = tool_binding_name(tool)
|
|
297
|
-
name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
|
|
298
|
-
add_tool(tool, name)
|
|
299
|
-
end
|
|
300
|
-
|
|
301
396
|
def folder_section(header, entries)
|
|
302
397
|
return nil if entries.empty?
|
|
303
398
|
|
|
@@ -308,54 +403,37 @@ module RubyLLM
|
|
|
308
403
|
end
|
|
309
404
|
[header, *lines].join("\n")
|
|
310
405
|
end
|
|
406
|
+
end
|
|
311
407
|
|
|
312
|
-
|
|
313
|
-
return nil if tools.empty?
|
|
314
|
-
|
|
315
|
-
[
|
|
316
|
-
"## Host tools (call with SB.call)",
|
|
317
|
-
HOST_TOOLS_INTRO.rstrip,
|
|
318
|
-
*tools.values.flat_map { |entry| tool_lines(entry) }
|
|
319
|
-
].join("\n")
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
def server_notes_section
|
|
323
|
-
notes = mcps.filter_map do |entry|
|
|
324
|
-
instructions = entry.instructions.to_s
|
|
325
|
-
next if instructions.empty?
|
|
326
|
-
|
|
327
|
-
"- **#{entry.name}** — #{instructions}"
|
|
328
|
-
end
|
|
329
|
-
return nil if notes.empty?
|
|
330
|
-
|
|
331
|
-
["## Server notes", *notes].join("\n")
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def tool_lines(entry)
|
|
335
|
-
line = "- `#{entry.name}`"
|
|
336
|
-
description = entry.tool.description.to_s
|
|
337
|
-
line += " — #{description}" unless description.empty?
|
|
408
|
+
parameter :code, description: "Complete Ruby script to execute in the sandbox"
|
|
338
409
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
341
423
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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!
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
spec ||= {}
|
|
349
|
-
status = required.include?(param) ? "required" : "optional"
|
|
350
|
-
line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
|
|
351
|
-
description = spec["description"].to_s
|
|
352
|
-
line += " — #{description}" unless description.empty?
|
|
353
|
-
line
|
|
354
|
-
end
|
|
355
|
-
end
|
|
431
|
+
Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
|
|
356
432
|
end
|
|
357
433
|
|
|
358
|
-
|
|
434
|
+
def description
|
|
435
|
+
self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
|
|
436
|
+
end
|
|
359
437
|
|
|
360
438
|
def execute(code:)
|
|
361
439
|
format_result(sandbox.eval(code))
|
|
@@ -365,8 +443,38 @@ module RubyLLM
|
|
|
365
443
|
|
|
366
444
|
private
|
|
367
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
|
+
|
|
368
476
|
def sandbox
|
|
369
|
-
@sandbox ||= SecurityBox::Sandbox.new(
|
|
477
|
+
@sandbox ||= SecurityBox::Sandbox.new(configuration)
|
|
370
478
|
end
|
|
371
479
|
|
|
372
480
|
def format_result(result)
|