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 +4 -4
- data/README.md +2 -2
- data/lib/ruby_llm/code_mode/description.rb +79 -0
- data/lib/ruby_llm/code_mode/dsl.rb +139 -0
- data/lib/ruby_llm/code_mode/instance_methods.rb +109 -0
- data/lib/ruby_llm/code_mode/registry.rb +233 -0
- data/lib/ruby_llm/code_mode.rb +8 -475
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71758c99ee2449675bbfb17951f4f12e6d9a4e35b7bd414cf9a5ff2a5219cddc
|
|
4
|
+
data.tar.gz: 0ebbe47c2d6b8267583edee716be3f6039cdf27c67debc699dbdf960e8c4e0b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a95977801ea3cf1fe0c057e2e69e3f965145306e6d2edc588d66c0cc953f0f219ee22da193bed054695ad550e4d921fa29bb36c9d9eaa200a3866953fb94f667
|
|
7
|
+
data.tar.gz: 0a394a1b1c7aafa6d44240b13afb28096dc31a311d22cb6713c2847893c3e1d879c1b0a99732140f695fe65165d62110b8c3ebb982155267bac4c25eeb628b5d
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
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.4.
|
|
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.
|
|
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:
|