ruby_llm-code_mode 0.1.1 → 0.1.2
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 +56 -7
- data/lib/ruby_llm/code_mode.rb +129 -3
- 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: 99df9f45b17d0bfb00c9251a705350b87e17bc0d35388284b7d9968afe0945e9
|
|
4
|
+
data.tar.gz: bad8594e60eff854ad3d891a12714e1cc879f7015ea57077b28afc9920239715
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b32708303afcbe27574ee5bf9504168e0a93021cf06f62ce0065bd44ee20a06199c21825791946bd27c394279f9bcf9e20f53f62367750942e760b14ddace7f
|
|
7
|
+
data.tar.gz: d5efdf5b03124ba97ff9ba07f0f1db87c7b5aeb2de3edd99619e294207ded741ed562635db4a2224f39acb1cd601d8cdbcd43751d7a4f6210a96cb4784dbd95e
|
data/README.md
CHANGED
|
@@ -48,7 +48,9 @@ a fixed description built from the declared mounts:
|
|
|
48
48
|
from the host, with a private `/work` scratch directory wiped between
|
|
49
49
|
executions and no state carried over between runs;
|
|
50
50
|
- a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
|
|
51
|
-
- a **Read-write folders** section listing each `mount_rw
|
|
51
|
+
- a **Read-write folders** section listing each `mount_rw`;
|
|
52
|
+
- a **Host tools** section listing every bound `RubyLLM::Tool` with its
|
|
53
|
+
parameters, when any are bound with `tool`.
|
|
52
54
|
|
|
53
55
|
The single parameter is `code` — a complete, self-contained Ruby script.
|
|
54
56
|
|
|
@@ -68,6 +70,8 @@ A JSON object sent back to the model:
|
|
|
68
70
|
```ruby
|
|
69
71
|
mount source: host_path, dest: guest_path, description: "..." # read-only
|
|
70
72
|
mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
73
|
+
tool SomeRubyLLMTool # host tool
|
|
74
|
+
tool "name" => SomeRubyLLMTool # explicit name
|
|
71
75
|
```
|
|
72
76
|
|
|
73
77
|
- `source:` — folder on the host machine, relative to the process working
|
|
@@ -75,9 +79,56 @@ mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
|
75
79
|
- `dest:` — absolute, normalized path inside the sandbox (may not overlap
|
|
76
80
|
the reserved `/work`, `/usr` or `/src` trees, and must be unique).
|
|
77
81
|
- `description:` — shown to the model in the tool description.
|
|
82
|
+
- `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
|
|
83
|
+
is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
|
|
84
|
+
or the explicit one from the one-pair form.
|
|
78
85
|
|
|
79
86
|
Invalid declarations raise at class-definition time, so a misconfigured tool
|
|
80
|
-
never reaches a live chat. Subclasses inherit their parent's mounts.
|
|
87
|
+
never reaches a live chat. Subclasses inherit their parent's mounts and tools.
|
|
88
|
+
|
|
89
|
+
### Host tools
|
|
90
|
+
|
|
91
|
+
Bind existing `RubyLLM::Tool`s so the sandboxed code can call them through
|
|
92
|
+
SecurityBox's host RPC channel:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Weather < RubyLLM::Tool
|
|
96
|
+
description "Current weather for a city"
|
|
97
|
+
parameter :city, type: "string", description: "City name"
|
|
98
|
+
|
|
99
|
+
def execute(city:)
|
|
100
|
+
# host-side HTTP call, database query, ...
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Assistant < RubyLLM::CodeMode
|
|
105
|
+
mount source: "data", dest: "/data", description: "Project data"
|
|
106
|
+
tool Weather # binds as `weather`
|
|
107
|
+
tool "forecast" => Weather # or bind it under an explicit name
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The description tells the model what is available, and inside the sandbox it
|
|
112
|
+
writes Ruby like:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
report = SB.call("weather", city: "Porto Alegre")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- Bound classes are instantiated once at definition time; every call shares
|
|
119
|
+
that instance, so state persists across calls (like the sandbox itself).
|
|
120
|
+
- Arguments must be JSON-serializable; they arrive as the tool's keyword
|
|
121
|
+
arguments (top-level string keys become symbols, nested data keeps string
|
|
122
|
+
keys). Bad arguments come back as an `error` hash — RubyLLM's
|
|
123
|
+
recoverable-failure convention.
|
|
124
|
+
- The return value is JSON round-tripped: hashes, arrays and scalars pass
|
|
125
|
+
through; non-serializable objects surface as their `inspect` string.
|
|
126
|
+
- A tool that raises surfaces as `SB::ToolError` (class name and message
|
|
127
|
+
only — no backtrace), which the sandboxed code can rescue. Unbound names
|
|
128
|
+
raise `SB::UnknownTool`.
|
|
129
|
+
- Limits per execution: 1000 tool calls and 1 MiB per result. At most 64
|
|
130
|
+
tools can be bound to one class.
|
|
131
|
+
- A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
|
|
81
132
|
|
|
82
133
|
### Sandbox limits
|
|
83
134
|
|
|
@@ -96,11 +147,9 @@ every subsequent evaluation then takes a few hundred milliseconds.
|
|
|
96
147
|
- Guest code is sandboxed by [SecurityBox](https://rubygems.org/gems/security_box):
|
|
97
148
|
no network, no threads, no processes, no host filesystem beyond the mounts,
|
|
98
149
|
deterministic CPU/memory/time limits, and forged results are rejected.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
- v2: a **Tools** section in the description backed by SecurityBox host RPC
|
|
103
|
-
handlers, so guest code can call registered host functions via `SB.call`.
|
|
150
|
+
- Bound host tools execute on the host, behind the sandbox's call boundary:
|
|
151
|
+
their arguments come from model-generated code, so treat them as untrusted
|
|
152
|
+
input, and only bind tools whose effects you are willing to grant the model.
|
|
104
153
|
|
|
105
154
|
## Development
|
|
106
155
|
|
data/lib/ruby_llm/code_mode.rb
CHANGED
|
@@ -5,10 +5,14 @@ require "security_box"
|
|
|
5
5
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
class CodeMode < Tool
|
|
8
|
-
VERSION = "0.1.
|
|
8
|
+
VERSION = "0.1.2"
|
|
9
9
|
|
|
10
10
|
DEFAULT_TIMEOUT_MS = 30_000
|
|
11
11
|
DEFAULT_FUEL_MS = 10_000
|
|
12
|
+
MAX_TOOLS = SecurityBox::Rpcs::MAX_RPCS
|
|
13
|
+
|
|
14
|
+
TOOL_USAGE = "tool expects a RubyLLM::Tool class or instance, " \
|
|
15
|
+
'or exactly one "name" => tool pair'
|
|
12
16
|
|
|
13
17
|
FIXED_DESCRIPTION = <<~DESC.freeze
|
|
14
18
|
Executes Ruby code inside a secure sandbox and returns what it produced.
|
|
@@ -29,12 +33,24 @@ module RubyLLM
|
|
|
29
33
|
class, message and backtrace are returned so you can fix and retry.
|
|
30
34
|
DESC
|
|
31
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
|
+
|
|
32
46
|
Mount = Struct.new(:host, :guest, :mode, :description, keyword_init: true) do
|
|
33
47
|
def payload
|
|
34
48
|
{ host: host, guest: guest, mode: mode }
|
|
35
49
|
end
|
|
36
50
|
end
|
|
37
51
|
|
|
52
|
+
ToolEntry = Struct.new(:name, :tool, keyword_init: true)
|
|
53
|
+
|
|
38
54
|
class << self
|
|
39
55
|
def tool_name
|
|
40
56
|
leaf = name.to_s.split('::').last
|
|
@@ -51,15 +67,38 @@ module RubyLLM
|
|
|
51
67
|
add_mount(source, dest, :read_write, description)
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
def tool(mapping = nil, **kwargs)
|
|
71
|
+
if !kwargs.empty?
|
|
72
|
+
raise ArgumentError, TOOL_USAGE unless kwargs.size == 1 && mapping.nil?
|
|
73
|
+
|
|
74
|
+
name, bound = kwargs.first
|
|
75
|
+
mapping = { name.to_s => bound }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if mapping.is_a?(Hash)
|
|
79
|
+
raise ArgumentError, TOOL_USAGE unless mapping.size == 1
|
|
80
|
+
|
|
81
|
+
name, bound = mapping.first
|
|
82
|
+
add_tool(bound, name.to_s)
|
|
83
|
+
else
|
|
84
|
+
add_tool(mapping, nil)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
54
88
|
def mounts
|
|
55
89
|
@mounts ||= []
|
|
56
90
|
end
|
|
57
91
|
|
|
92
|
+
def tools
|
|
93
|
+
@tools ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
58
96
|
def configuration
|
|
59
97
|
@configuration ||= SecurityBox::Configuration.build(
|
|
60
98
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
61
99
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
62
|
-
mounts: mounts.map(&:payload)
|
|
100
|
+
mounts: mounts.map(&:payload),
|
|
101
|
+
rpcs: build_rpcs
|
|
63
102
|
)
|
|
64
103
|
end
|
|
65
104
|
|
|
@@ -68,7 +107,8 @@ module RubyLLM
|
|
|
68
107
|
[
|
|
69
108
|
FIXED_DESCRIPTION.rstrip,
|
|
70
109
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
71
|
-
folder_section("## Read-write folders (readable and writable)", read_write)
|
|
110
|
+
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
111
|
+
tools_section
|
|
72
112
|
].compact.join("\n\n")
|
|
73
113
|
end
|
|
74
114
|
|
|
@@ -87,6 +127,7 @@ module RubyLLM
|
|
|
87
127
|
def inherited(subclass)
|
|
88
128
|
super
|
|
89
129
|
subclass.instance_variable_set(:@mounts, mounts.dup)
|
|
130
|
+
subclass.instance_variable_set(:@tools, tools.dup)
|
|
90
131
|
subclass.instance_variable_set(:@configuration, nil)
|
|
91
132
|
end
|
|
92
133
|
|
|
@@ -106,6 +147,58 @@ module RubyLLM
|
|
|
106
147
|
"Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
|
|
107
148
|
end
|
|
108
149
|
|
|
150
|
+
def add_tool(bound, name)
|
|
151
|
+
instance = normalize_bound_tool(bound)
|
|
152
|
+
if instance.is_a?(RubyLLM::CodeMode)
|
|
153
|
+
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
name = (name || leaf_tool_name(instance.class)).to_s
|
|
157
|
+
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
158
|
+
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
159
|
+
if tools.size >= MAX_TOOLS
|
|
160
|
+
raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
tools[name] = ToolEntry.new(name: name, tool: instance)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def normalize_bound_tool(bound)
|
|
167
|
+
if bound.is_a?(Class)
|
|
168
|
+
unless bound <= RubyLLM::Tool
|
|
169
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
bound.new
|
|
173
|
+
elsif bound.is_a?(RubyLLM::Tool)
|
|
174
|
+
bound
|
|
175
|
+
else
|
|
176
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Leaf-only name derivation, like .tool_name — the guest has no use for
|
|
181
|
+
# the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
182
|
+
def leaf_tool_name(klass)
|
|
183
|
+
leaf = klass.name.to_s.split('::').last
|
|
184
|
+
return "" unless leaf
|
|
185
|
+
|
|
186
|
+
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_rpcs
|
|
190
|
+
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def call_bound_tool(entry, args)
|
|
194
|
+
unless args.is_a?(Hash)
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
entry.tool.call(**args)
|
|
200
|
+
end
|
|
201
|
+
|
|
109
202
|
def folder_section(header, entries)
|
|
110
203
|
return nil if entries.empty?
|
|
111
204
|
|
|
@@ -116,6 +209,39 @@ module RubyLLM
|
|
|
116
209
|
end
|
|
117
210
|
[header, *lines].join("\n")
|
|
118
211
|
end
|
|
212
|
+
|
|
213
|
+
def tools_section
|
|
214
|
+
return nil if tools.empty?
|
|
215
|
+
|
|
216
|
+
[
|
|
217
|
+
"## Host tools (call with SB.call)",
|
|
218
|
+
HOST_TOOLS_INTRO.rstrip,
|
|
219
|
+
*tools.values.flat_map { |entry| tool_lines(entry) }
|
|
220
|
+
].join("\n")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def tool_lines(entry)
|
|
224
|
+
line = "- `#{entry.name}`"
|
|
225
|
+
description = entry.tool.description.to_s
|
|
226
|
+
line += " — #{description}" unless description.empty?
|
|
227
|
+
|
|
228
|
+
[line, *param_lines(entry.tool).map { |param| " #{param}" }]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def param_lines(tool)
|
|
232
|
+
schema = tool.parameters_schema || {}
|
|
233
|
+
properties = schema["properties"] || {}
|
|
234
|
+
required = schema["required"] || []
|
|
235
|
+
|
|
236
|
+
properties.filter_map do |param, spec|
|
|
237
|
+
spec ||= {}
|
|
238
|
+
status = required.include?(param) ? "required" : "optional"
|
|
239
|
+
line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
|
|
240
|
+
description = spec["description"].to_s
|
|
241
|
+
line += " — #{description}" unless description.empty?
|
|
242
|
+
line
|
|
243
|
+
end
|
|
244
|
+
end
|
|
119
245
|
end
|
|
120
246
|
|
|
121
247
|
parameter :code, description: "Complete Ruby script to execute in the sandbox"
|