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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -7
  3. data/lib/ruby_llm/code_mode.rb +129 -3
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d933b7782ef9b21eb6c9c704630a456b1aa96ffecc52e4a258aa50fd99e09ebf
4
- data.tar.gz: d006d9fbac9d85b569a0978f5bf74cca9430ffe8bf7ba47c526a83ecdb52b7ac
3
+ metadata.gz: 99df9f45b17d0bfb00c9251a705350b87e17bc0d35388284b7d9968afe0945e9
4
+ data.tar.gz: bad8594e60eff854ad3d891a12714e1cc879f7015ea57077b28afc9920239715
5
5
  SHA512:
6
- metadata.gz: 0ce747225815a3dd6a9aeccc8ccbb74ac64d9a24e7716797941d75afb009e55c7a56a899abafaeac5a490ad7a921ce0c771955ced03d72861f9472c42f3a2c13
7
- data.tar.gz: e62fdd4dbe81c6dd7034ea4e27d1ff18c0aa2bf22d83ab58a94d35e1b51fc045db31e73fd4a560c13ffc5615d8e800cdb83be2f0c38c704c1a8e9c767fec97cd
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
- ## Roadmap
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
 
@@ -5,10 +5,14 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.1.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"
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.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira