ruby_llm-code_mode 0.4.3 → 0.5.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 +41 -0
- data/lib/ruby_llm/code_mode/catalog.rb +151 -0
- data/lib/ruby_llm/code_mode/description.rb +10 -6
- data/lib/ruby_llm/code_mode/dsl.rb +83 -8
- data/lib/ruby_llm/code_mode/instance_methods.rb +22 -4
- data/lib/ruby_llm/code_mode/registry.rb +46 -25
- data/lib/ruby_llm/code_mode.rb +7 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2971c55a0ddb64f36fac74369b9f591a457d7f790fef69629c840105ee44a41f
|
|
4
|
+
data.tar.gz: ce4cb05af563703b265ac6cf39e4dcfd726cfc4fcd34d3c346e2e2a9783d6613
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5cc54963c906914bcc93c8ce4bc07ca92aa069993746d1b471bbd7572687b9aea89b6b241cb09cbc0323ecb42318b0cc6f921c89b4ab2fe81f7b65d60868201
|
|
7
|
+
data.tar.gz: 036fdc62c104e9c3afe3309459cc1da6d5a8693ce28078f358d6af8f51e33f59d67b6a8f29f2e0504354692bee1c944d0917599423e14edf7317fd3e8b33eb15
|
data/README.md
CHANGED
|
@@ -229,6 +229,47 @@ tool.execute(code: 'SB.call("user_search", q: "...")')
|
|
|
229
229
|
`add_tools`/`add_mcps` raise — create a new instance instead. Instances
|
|
230
230
|
without additions share the class configuration.
|
|
231
231
|
|
|
232
|
+
### Tool catalog
|
|
233
|
+
|
|
234
|
+
An MCP server can bind dozens of tools, and listing every one — with its full
|
|
235
|
+
description and parameters — in the tool description can drown the system
|
|
236
|
+
prompt. The `tool_catalog` DSL swaps the full listing for a compact index and
|
|
237
|
+
lets the model find tools on demand from inside the sandbox:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class DocsResearch < RubyLLM::CodeMode
|
|
241
|
+
mcps docs
|
|
242
|
+
tool_catalog # defaults: search_limit: 10, preview_chars: 20
|
|
243
|
+
# tool_catalog search_limit: 5, preview_chars: 40
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
With the catalog enabled the description lists each tool as a single line —
|
|
248
|
+
name plus the first `preview_chars` characters of its description — plus the
|
|
249
|
+
instructions for two internal RPCs the sandboxed code calls like any host
|
|
250
|
+
tool:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
matches = SB.call("search_tools", query: "azure blob upload")
|
|
254
|
+
# => { query: ..., count: ..., tools: [{ name:, description: }, ...] }
|
|
255
|
+
usage = SB.call("tool_details", name: "azure_storage_blob_upload")
|
|
256
|
+
# => { name:, description:, parameters: [{ name:, type:, required:, description: }] }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- The search matches every term of the query (case-insensitive) against the
|
|
260
|
+
tool **name and description**, ranks name matches first and returns at most
|
|
261
|
+
`search_limit` results.
|
|
262
|
+
- `tool_details` raises a rescuable `SB::ToolError` for unknown names,
|
|
263
|
+
suggesting a few similar tools when there are any.
|
|
264
|
+
- Without `tool_catalog` nothing changes: every tool stays listed in full.
|
|
265
|
+
- `search_tools` and `tool_details` are reserved names — binding a tool with
|
|
266
|
+
either raises at definition time. The two catalog RPCs also count against
|
|
267
|
+
the 64-slot RPC budget, so at most 62 tools can be bound with the catalog
|
|
268
|
+
on (enforced at definition time).
|
|
269
|
+
- Subclasses inherit the catalog settings, and per-instance additions
|
|
270
|
+
(`add_tools`/`add_mcps`) are searchable on that instance too. Read the
|
|
271
|
+
current settings with `MyTool.catalog_settings` (nil when off).
|
|
272
|
+
|
|
232
273
|
### Sandbox limits
|
|
233
274
|
|
|
234
275
|
Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class CodeMode
|
|
5
|
+
# On-demand tool catalog (opt-in via the .tool_catalog DSL): instead of
|
|
6
|
+
# listing every bound tool in full in the description, the model gets a
|
|
7
|
+
# compact index (name plus a truncated description) and two internal RPC
|
|
8
|
+
# handlers — `search_tools` (find tools by name/description) and
|
|
9
|
+
# `tool_details` (full usage of one tool). The handlers are built per
|
|
10
|
+
# tools hash: the class configuration searches the class tools, and an
|
|
11
|
+
# instance configuration rebinds them over the class tools plus its own
|
|
12
|
+
# additions (see #add_tools).
|
|
13
|
+
module Catalog
|
|
14
|
+
DEFAULT_SEARCH_LIMIT = 10
|
|
15
|
+
DEFAULT_PREVIEW_CHARS = 20
|
|
16
|
+
|
|
17
|
+
SEARCH_NAME = "search_tools"
|
|
18
|
+
DETAILS_NAME = "tool_details"
|
|
19
|
+
|
|
20
|
+
# Names no regular tool may bind, catalog enabled or not: the guest
|
|
21
|
+
# calls them unqualified, so a colliding bound tool would become
|
|
22
|
+
# unreachable as soon as the catalog is turned on.
|
|
23
|
+
RESERVED_NAMES = [SEARCH_NAME, DETAILS_NAME].freeze
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# The two guest-facing handlers over `tools` (a name => ToolEntry
|
|
27
|
+
# hash) with `catalog` settings. Handler failures raise, which the
|
|
28
|
+
# guest sees as rescuable SB::ToolError (class + message only).
|
|
29
|
+
def rpc_handlers(tools, catalog)
|
|
30
|
+
{
|
|
31
|
+
SEARCH_NAME => ->(args) { search_call(tools, catalog, args) },
|
|
32
|
+
DETAILS_NAME => ->(args) { details_call(tools, args) }
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The compact "## Host tools" description section: the search/details
|
|
37
|
+
# instructions plus one line per tool — its name and at most
|
|
38
|
+
# `preview_chars` characters of its description.
|
|
39
|
+
def section(tools, catalog)
|
|
40
|
+
return nil if tools.empty?
|
|
41
|
+
|
|
42
|
+
intro = <<~INTRO
|
|
43
|
+
Search before calling: `SB.call('#{SEARCH_NAME}', query: 'terms')` finds tools
|
|
44
|
+
by name or description (at most #{catalog.search_limit} matches), and
|
|
45
|
+
`SB.call('#{DETAILS_NAME}', name: 'name')` returns one tool's full usage —
|
|
46
|
+
call it before using a tool for the first time.
|
|
47
|
+
INTRO
|
|
48
|
+
|
|
49
|
+
[
|
|
50
|
+
"## Host tools (#{tools.size} available)",
|
|
51
|
+
intro.rstrip,
|
|
52
|
+
*tools.values.map { |entry| preview_line(entry, catalog.preview_chars) }
|
|
53
|
+
].join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Tools whose name or description contains every term of `query`
|
|
57
|
+
# (case-insensitive; terms are sequences of word characters). Tools
|
|
58
|
+
# matched by name rank before description-only matches, each group
|
|
59
|
+
# alphabetical. At most `limit` results: `{ name:, description: }`.
|
|
60
|
+
def search(tools, query, limit)
|
|
61
|
+
terms = terms_of(query)
|
|
62
|
+
return [] if terms.empty?
|
|
63
|
+
|
|
64
|
+
tools.values.filter_map { |entry| match(entry, terms) }
|
|
65
|
+
.sort_by { |score, name, _| [score, name] }
|
|
66
|
+
.first(limit)
|
|
67
|
+
.map { |_, _, entry| { name: entry.name, description: entry.tool.description.to_s } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Full usage of one tool — name, description and the declared
|
|
71
|
+
# parameters — or nil when the name is not bound.
|
|
72
|
+
def details(tools, name)
|
|
73
|
+
entry = tools[name.to_s]
|
|
74
|
+
return nil unless entry
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
name: entry.name,
|
|
78
|
+
description: entry.tool.description.to_s,
|
|
79
|
+
parameters: Registry.parameter_specs(entry.tool)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A few bound names similar to `name` (sharing any of its terms),
|
|
84
|
+
# used in the unknown-tool error message.
|
|
85
|
+
def suggestions(tools, name)
|
|
86
|
+
terms = terms_of(name)
|
|
87
|
+
return [] if terms.empty?
|
|
88
|
+
|
|
89
|
+
tools.values.filter_map do |entry|
|
|
90
|
+
haystack = "#{entry.name} #{entry.tool.description}".downcase
|
|
91
|
+
entry.name if terms.any? { |term| haystack.include?(term) }
|
|
92
|
+
end.first(3)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def search_call(tools, catalog, args)
|
|
98
|
+
query = string_arg(args, SEARCH_NAME, "query")
|
|
99
|
+
results = search(tools, query, catalog.search_limit)
|
|
100
|
+
{ query: query, count: results.size, tools: results }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def details_call(tools, args)
|
|
104
|
+
name = string_arg(args, DETAILS_NAME, "name")
|
|
105
|
+
found = details(tools, name)
|
|
106
|
+
return found if found
|
|
107
|
+
|
|
108
|
+
hints = suggestions(tools, name)
|
|
109
|
+
hint = hints.empty? ? "" : " — similar tools: #{hints.join(', ')}"
|
|
110
|
+
raise ArgumentError, "unknown tool #{name.inspect}#{hint}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Both catalog RPCs take exactly one non-empty string argument,
|
|
114
|
+
# passed by the guest as a JSON object (string keys).
|
|
115
|
+
def string_arg(args, rpc, key)
|
|
116
|
+
unless args.is_a?(Hash)
|
|
117
|
+
raise ArgumentError, "#{rpc} expects a hash of arguments, got #{args.class}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
value = args[key]
|
|
121
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "#{rpc} requires a non-empty '#{key}' string"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def terms_of(text)
|
|
127
|
+
text.to_s.split(/[\W_]+/).reject(&:empty?).map(&:downcase).uniq
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A [score, name, entry] triple for a tool matching every term —
|
|
131
|
+
# score 0 when at least one term hits the name, 1 otherwise — or
|
|
132
|
+
# nil when the tool does not match.
|
|
133
|
+
def match(entry, terms)
|
|
134
|
+
name = entry.name.to_s.downcase
|
|
135
|
+
description = entry.tool.description.to_s.downcase
|
|
136
|
+
return unless terms.all? { |term| name.include?(term) || description.include?(term) }
|
|
137
|
+
|
|
138
|
+
[terms.any? { |term| name.include?(term) } ? 0 : 1, entry.name, entry]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def preview_line(entry, preview_chars)
|
|
142
|
+
line = "- `#{entry.name}`"
|
|
143
|
+
preview = entry.tool.description.to_s.lines.first.to_s.strip
|
|
144
|
+
preview = "#{preview[0, preview_chars]}…" if preview.length > preview_chars
|
|
145
|
+
line += " — #{preview}" unless preview.empty?
|
|
146
|
+
line
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -34,18 +34,22 @@ module RubyLLM
|
|
|
34
34
|
class << self
|
|
35
35
|
# Builds the description the model sees for this tool: the fixed
|
|
36
36
|
# "How to use" text, one section per mount kind listing each mounted
|
|
37
|
-
# folder, the "Host tools
|
|
38
|
-
# bound tool and its parameters,
|
|
39
|
-
#
|
|
40
|
-
# the
|
|
41
|
-
#
|
|
37
|
+
# folder, the "Host tools" section — the full listing with every
|
|
38
|
+
# bound tool and its parameters, or the compact catalog index with
|
|
39
|
+
# the search/details instructions when the tool catalog is on — and
|
|
40
|
+
# the "Server notes" section with each MCP server's instructions.
|
|
41
|
+
# `extra_tools`/`extra_mcps` carry the per-instance additions (see
|
|
42
|
+
# #add_tools/#add_mcps); with no extras the output is identical to
|
|
43
|
+
# the class-level description.
|
|
42
44
|
def build_description(extra_tools: {}, extra_mcps: [])
|
|
43
45
|
read_only, read_write = mounts.partition { |m| m.mode == :read_only }
|
|
46
|
+
all_tools = tools.merge(extra_tools)
|
|
47
|
+
catalog = catalog_settings
|
|
44
48
|
[
|
|
45
49
|
FIXED_DESCRIPTION.rstrip,
|
|
46
50
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
47
51
|
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
48
|
-
Registry.tools_section(
|
|
52
|
+
catalog ? Catalog.section(all_tools, catalog) : Registry.tools_section(all_tools),
|
|
49
53
|
Registry.server_notes_section(mcps + extra_mcps)
|
|
50
54
|
].compact.join("\n\n")
|
|
51
55
|
end
|
|
@@ -49,7 +49,7 @@ module RubyLLM
|
|
|
49
49
|
def tools(*args, **kwargs)
|
|
50
50
|
return @tools ||= {} if args.empty? && kwargs.empty?
|
|
51
51
|
|
|
52
|
-
Registry.register_tools(tools, args, kwargs)
|
|
52
|
+
Registry.register_tools(tools, args, kwargs, reservation: catalog_reservation)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Connects MCP server(s) and binds all their tools into the sandbox:
|
|
@@ -75,7 +75,48 @@ module RubyLLM
|
|
|
75
75
|
def mcps(*servers, **inputs)
|
|
76
76
|
return @mcps ||= [] if servers.empty? && inputs.empty?
|
|
77
77
|
|
|
78
|
-
Registry.register_mcps(tools, mcps, servers, inputs)
|
|
78
|
+
Registry.register_mcps(tools, mcps, servers, inputs, reservation: catalog_reservation)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Opts the class into the on-demand tool catalog: the description
|
|
82
|
+
# stops listing every bound tool in full and shows a compact index
|
|
83
|
+
# (name plus a short description preview) instead, and the guest gets
|
|
84
|
+
# two internal RPCs to find and inspect tools:
|
|
85
|
+
#
|
|
86
|
+
# class Research < RubyLLM::CodeMode
|
|
87
|
+
# mcps DocsServer
|
|
88
|
+
# tool_catalog # defaults
|
|
89
|
+
# tool_catalog search_limit: 5, preview_chars: 40
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
# A bare call enables the catalog with the defaults; passing any
|
|
93
|
+
# option (re)sets it: `search_limit:` (matches per search, default
|
|
94
|
+
# 10) and `preview_chars:` (description preview length in the
|
|
95
|
+
# listing, default 20). Called on an already-enabled class without
|
|
96
|
+
# options it returns the current settings. Use .catalog_settings to
|
|
97
|
+
# read the state without side effects (nil when the catalog is off).
|
|
98
|
+
# The two catalog RPCs count against the 64-slot RPC budget, so with
|
|
99
|
+
# the catalog on at most 62 tools can be bound (enforced at
|
|
100
|
+
# definition time, like the other limits).
|
|
101
|
+
def tool_catalog(search_limit: nil, preview_chars: nil)
|
|
102
|
+
if search_limit.nil? && preview_chars.nil?
|
|
103
|
+
return catalog_settings if catalog_settings
|
|
104
|
+
# A bare first call falls through and enables with the defaults.
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
catalog = ToolCatalog.new(
|
|
108
|
+
search_limit: search_limit || Catalog::DEFAULT_SEARCH_LIMIT,
|
|
109
|
+
preview_chars: preview_chars || Catalog::DEFAULT_PREVIEW_CHARS
|
|
110
|
+
)
|
|
111
|
+
validate_tool_catalog!(catalog)
|
|
112
|
+
@tool_catalog = catalog.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The tool-catalog settings, or nil when the catalog is off. Unlike
|
|
116
|
+
# .tool_catalog, reading never enables anything: this is what
|
|
117
|
+
# description builds, configurations and instances consult.
|
|
118
|
+
def catalog_settings
|
|
119
|
+
instance_variable_defined?(:@tool_catalog) ? @tool_catalog : nil
|
|
79
120
|
end
|
|
80
121
|
|
|
81
122
|
# The mounts declared so far, as Mount structs (read-only and
|
|
@@ -88,15 +129,16 @@ module RubyLLM
|
|
|
88
129
|
|
|
89
130
|
# Builds and memoizes the SecurityBox::Configuration for this class:
|
|
90
131
|
# the default limits (timeout_ms: 30_000, fuel_ms: 10_000), the
|
|
91
|
-
# declared mounts and one RPC handler per bound tool
|
|
92
|
-
#
|
|
93
|
-
#
|
|
132
|
+
# declared mounts and one RPC handler per bound tool (plus the two
|
|
133
|
+
# catalog handlers when the tool catalog is on). Memoized per class;
|
|
134
|
+
# subclasses build their own. Tool instances without per-instance
|
|
135
|
+
# additions share it (see #add_tools).
|
|
94
136
|
def configuration
|
|
95
137
|
@configuration ||= SecurityBox::Configuration.build(
|
|
96
138
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
97
139
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
98
140
|
mounts: mounts.map(&:payload),
|
|
99
|
-
rpcs:
|
|
141
|
+
rpcs: rpc_handlers
|
|
100
142
|
)
|
|
101
143
|
end
|
|
102
144
|
|
|
@@ -109,18 +151,51 @@ module RubyLLM
|
|
|
109
151
|
end
|
|
110
152
|
|
|
111
153
|
# Ruby hook: subclasses start with a copy of the parent's mounts,
|
|
112
|
-
# tools
|
|
113
|
-
# configuration memo so limits/rpcs are
|
|
154
|
+
# tools, MCP servers and tool-catalog settings (entries and instances
|
|
155
|
+
# shared), and their own configuration memo so limits/rpcs are
|
|
156
|
+
# rebuilt per class.
|
|
114
157
|
def inherited(subclass)
|
|
115
158
|
super
|
|
116
159
|
subclass.instance_variable_set(:@mounts, mounts.dup)
|
|
117
160
|
subclass.instance_variable_set(:@tools, tools.dup)
|
|
118
161
|
subclass.instance_variable_set(:@mcps, mcps.dup)
|
|
162
|
+
subclass.instance_variable_set(:@tool_catalog, catalog_settings)
|
|
119
163
|
subclass.instance_variable_set(:@configuration, nil)
|
|
120
164
|
end
|
|
121
165
|
|
|
122
166
|
private
|
|
123
167
|
|
|
168
|
+
# One RPC handler per bound tool, plus the two catalog handlers when
|
|
169
|
+
# the tool catalog is on (they search the class tools only; instances
|
|
170
|
+
# with additions rebind them over the merged set).
|
|
171
|
+
def rpc_handlers
|
|
172
|
+
handlers = Registry.rpc_handlers(tools)
|
|
173
|
+
return handlers unless catalog_settings
|
|
174
|
+
|
|
175
|
+
handlers.merge(Catalog.rpc_handlers(tools, catalog_settings))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# RPC slots the tool catalog takes beyond the tools themselves.
|
|
179
|
+
def catalog_reservation
|
|
180
|
+
catalog_settings ? Catalog::RESERVED_NAMES.size : 0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_tool_catalog!(catalog)
|
|
184
|
+
%i[search_limit preview_chars].each do |option|
|
|
185
|
+
value = catalog.send(option)
|
|
186
|
+
unless value.is_a?(Integer) && value.positive?
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"tool_catalog #{option} must be a positive Integer, got #{value.inspect}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
slots = tools.size + Catalog::RESERVED_NAMES.size
|
|
192
|
+
return if slots <= MAX_TOOLS
|
|
193
|
+
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
"tool_catalog needs #{Catalog::RESERVED_NAMES.size} extra RPC slots but " \
|
|
196
|
+
"#{tools.size} tools are already bound (the limit is #{MAX_TOOLS})"
|
|
197
|
+
end
|
|
198
|
+
|
|
124
199
|
def add_mount(source, dest, mode, description)
|
|
125
200
|
mounts << Mount.new(
|
|
126
201
|
host: File.expand_path(source.to_s),
|
|
@@ -15,7 +15,8 @@ module RubyLLM
|
|
|
15
15
|
def add_tools(*args, **kwargs)
|
|
16
16
|
ensure_extendable!
|
|
17
17
|
|
|
18
|
-
Registry.register_tools(instance_tools, args, kwargs,
|
|
18
|
+
Registry.register_tools(instance_tools, args, kwargs,
|
|
19
|
+
reserved: self.class.tools, reservation: catalog_reservation)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# Connects MCP server(s) onto this specific instance, on top of the
|
|
@@ -25,7 +26,8 @@ module RubyLLM
|
|
|
25
26
|
def add_mcps(*servers, **inputs)
|
|
26
27
|
ensure_extendable!
|
|
27
28
|
|
|
28
|
-
Registry.register_mcps(instance_tools, instance_mcps, servers, inputs,
|
|
29
|
+
Registry.register_mcps(instance_tools, instance_mcps, servers, inputs,
|
|
30
|
+
reserved: self.class.tools, reservation: catalog_reservation)
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# The description this instance presents to the model: the class-level
|
|
@@ -67,9 +69,17 @@ module RubyLLM
|
|
|
67
69
|
@instance_mcps ||= []
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
# RPC slots the class-level tool catalog takes beyond the tools.
|
|
73
|
+
def catalog_reservation
|
|
74
|
+
self.class.catalog_settings ? Catalog::RESERVED_NAMES.size : 0
|
|
75
|
+
end
|
|
76
|
+
|
|
70
77
|
# Instances without additions share the class configuration; instances
|
|
71
78
|
# with additions build their own, merging the class rpcs with the
|
|
72
|
-
# instance ones (names cannot collide — the registrations validate
|
|
79
|
+
# instance ones (names cannot collide — the registrations validate
|
|
80
|
+
# that). With the tool catalog on, the two catalog handlers are rebound
|
|
81
|
+
# over the merged tools so the guest can search the instance additions
|
|
82
|
+
# too.
|
|
73
83
|
def configuration
|
|
74
84
|
return self.class.configuration if instance_tools.empty? && instance_mcps.empty?
|
|
75
85
|
|
|
@@ -77,10 +87,18 @@ module RubyLLM
|
|
|
77
87
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
78
88
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
79
89
|
mounts: self.class.mounts.map(&:payload),
|
|
80
|
-
rpcs:
|
|
90
|
+
rpcs: instance_rpcs
|
|
81
91
|
)
|
|
82
92
|
end
|
|
83
93
|
|
|
94
|
+
def instance_rpcs
|
|
95
|
+
rpcs = self.class.configuration.rpcs.merge(Registry.rpc_handlers(instance_tools))
|
|
96
|
+
return rpcs unless self.class.catalog_settings
|
|
97
|
+
|
|
98
|
+
merged = self.class.tools.merge(instance_tools)
|
|
99
|
+
rpcs.merge(Catalog.rpc_handlers(merged, self.class.catalog_settings))
|
|
100
|
+
end
|
|
101
|
+
|
|
84
102
|
def sandbox
|
|
85
103
|
@sandbox ||= SecurityBox::Sandbox.new(configuration)
|
|
86
104
|
end
|
|
@@ -15,18 +15,20 @@ module RubyLLM
|
|
|
15
15
|
# arguments or kwargs). All-or-nothing: on any failure `target` is
|
|
16
16
|
# restored and the error re-raised. `reserved` lists names already
|
|
17
17
|
# taken elsewhere (e.g. the class-level bindings for instance
|
|
18
|
-
# additions); duplicates against it raise.
|
|
19
|
-
|
|
18
|
+
# additions); duplicates against it raise. `reservation` counts RPC
|
|
19
|
+
# slots taken by extras beyond the tools themselves (the tool
|
|
20
|
+
# catalog's two handlers) against the MAX_TOOLS budget.
|
|
21
|
+
def register_tools(target, args, kwargs, reserved: EMPTY, reservation: 0)
|
|
20
22
|
snapshot = target.dup
|
|
21
23
|
begin
|
|
22
24
|
args.flatten(1).each do |item|
|
|
23
25
|
if item.is_a?(Hash)
|
|
24
|
-
item.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
|
|
26
|
+
item.each { |name, bound| add_tool(target, bound, name.to_s, reserved, reservation) }
|
|
25
27
|
else
|
|
26
|
-
add_tool(target, item, nil, reserved)
|
|
28
|
+
add_tool(target, item, nil, reserved, reservation)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
|
-
kwargs.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
|
|
31
|
+
kwargs.each { |name, bound| add_tool(target, bound, name.to_s, reserved, reservation) }
|
|
30
32
|
rescue StandardError
|
|
31
33
|
target.replace(snapshot)
|
|
32
34
|
raise
|
|
@@ -37,7 +39,7 @@ module RubyLLM
|
|
|
37
39
|
# Connects MCP server(s) and binds all their tools into
|
|
38
40
|
# `target_tools`/`target_mcps`. All-or-nothing across both
|
|
39
41
|
# collections.
|
|
40
|
-
def register_mcps(target_tools, target_mcps, servers, inputs, reserved: EMPTY)
|
|
42
|
+
def register_mcps(target_tools, target_mcps, servers, inputs, reserved: EMPTY, reservation: 0)
|
|
41
43
|
snapshot_tools = target_tools.dup
|
|
42
44
|
snapshot_mcps = target_mcps.dup
|
|
43
45
|
flat = servers.flatten(1)
|
|
@@ -47,7 +49,7 @@ module RubyLLM
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
normalize_mcp_servers(flat, inputs).each do |server|
|
|
50
|
-
bind_mcp_server(target_tools, target_mcps, server, reserved)
|
|
52
|
+
bind_mcp_server(target_tools, target_mcps, server, reserved, reservation)
|
|
51
53
|
end
|
|
52
54
|
target_mcps
|
|
53
55
|
rescue StandardError
|
|
@@ -56,7 +58,7 @@ module RubyLLM
|
|
|
56
58
|
raise
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
#
|
|
61
|
+
# The RPC handler per bound tool, ready for
|
|
60
62
|
# SecurityBox::Configuration.build(rpcs:). Each handler validates
|
|
61
63
|
# that the guest passed a hash of arguments, forwards the call to
|
|
62
64
|
# the tool instance (string keys symbolized, schema mistakes coming
|
|
@@ -66,6 +68,26 @@ module RubyLLM
|
|
|
66
68
|
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
67
69
|
end
|
|
68
70
|
|
|
71
|
+
# One hash per declared parameter — name, type, required flag and
|
|
72
|
+
# description — from the tool's JSON schema. Shared by the rendered
|
|
73
|
+
# description lines (param_lines) and the tool catalog's
|
|
74
|
+
# tool_details handler.
|
|
75
|
+
def parameter_specs(tool)
|
|
76
|
+
schema = tool.parameters_schema || {}
|
|
77
|
+
properties = schema["properties"] || {}
|
|
78
|
+
required = schema["required"] || []
|
|
79
|
+
|
|
80
|
+
properties.filter_map do |param, spec|
|
|
81
|
+
spec ||= {}
|
|
82
|
+
{
|
|
83
|
+
name: param.to_s,
|
|
84
|
+
type: spec["type"] || "string",
|
|
85
|
+
required: required.include?(param),
|
|
86
|
+
description: spec["description"].to_s
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
69
91
|
# The "## Host tools (call with SB.call)" description section: the
|
|
70
92
|
# shared intro plus one line per tool with its description and
|
|
71
93
|
# parameter docs. Returns nil when no tools are bound, so the
|
|
@@ -97,7 +119,7 @@ module RubyLLM
|
|
|
97
119
|
|
|
98
120
|
private
|
|
99
121
|
|
|
100
|
-
def add_tool(target, bound, name, reserved)
|
|
122
|
+
def add_tool(target, bound, name, reserved, reservation = 0)
|
|
101
123
|
instance = normalize_bound_tool(bound)
|
|
102
124
|
if instance.is_a?(RubyLLM::CodeMode)
|
|
103
125
|
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
@@ -108,9 +130,15 @@ module RubyLLM
|
|
|
108
130
|
if target.key?(name) || reserved.key?(name)
|
|
109
131
|
raise ArgumentError, "tool name #{name.inspect} is already bound"
|
|
110
132
|
end
|
|
111
|
-
|
|
133
|
+
if Catalog::RESERVED_NAMES.include?(name)
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"tool name #{name.inspect} is reserved for the tool catalog (see .tool_catalog)"
|
|
136
|
+
end
|
|
137
|
+
total = target.size + reserved.size + reservation
|
|
112
138
|
if total >= MAX_TOOLS
|
|
113
|
-
raise ArgumentError,
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"too many tools (#{total + 1}); the limit is #{MAX_TOOLS - reservation}" \
|
|
141
|
+
"#{reservation.positive? ? ' with the tool catalog enabled' : ''}"
|
|
114
142
|
end
|
|
115
143
|
|
|
116
144
|
target[name] = ToolEntry.new(name: name, tool: instance)
|
|
@@ -187,22 +215,22 @@ module RubyLLM
|
|
|
187
215
|
end
|
|
188
216
|
end
|
|
189
217
|
|
|
190
|
-
def bind_mcp_server(target_tools, target_mcps, server, reserved)
|
|
218
|
+
def bind_mcp_server(target_tools, target_mcps, server, reserved, reservation)
|
|
191
219
|
# Fetching .tools connects to the server; snapshot the instructions
|
|
192
220
|
# right after, so description builds never touch the network.
|
|
193
221
|
tools_list = server.tools
|
|
194
222
|
target_mcps << McpEntry.new(
|
|
195
223
|
name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
|
|
196
224
|
)
|
|
197
|
-
tools_list.each { |tool| bind_mcp_tool(target_tools, server, tool, reserved) }
|
|
225
|
+
tools_list.each { |tool| bind_mcp_tool(target_tools, server, tool, reserved, reservation) }
|
|
198
226
|
end
|
|
199
227
|
|
|
200
|
-
def bind_mcp_tool(target_tools, server, tool, reserved)
|
|
228
|
+
def bind_mcp_tool(target_tools, server, tool, reserved, reservation)
|
|
201
229
|
name = tool_binding_name(tool)
|
|
202
230
|
if (target_tools.key?(name) || reserved.key?(name)) && !server.name.to_s.empty?
|
|
203
231
|
name = "#{server.name}_#{name}"
|
|
204
232
|
end
|
|
205
|
-
add_tool(target_tools, tool, name, reserved)
|
|
233
|
+
add_tool(target_tools, tool, name, reserved, reservation)
|
|
206
234
|
end
|
|
207
235
|
|
|
208
236
|
def tool_lines(entry)
|
|
@@ -214,16 +242,9 @@ module RubyLLM
|
|
|
214
242
|
end
|
|
215
243
|
|
|
216
244
|
def param_lines(tool)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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?
|
|
245
|
+
parameter_specs(tool).map do |spec|
|
|
246
|
+
line = "- `#{spec[:name]}` (#{spec[:type]}, #{spec[:required] ? 'required' : 'optional'})"
|
|
247
|
+
line += " — #{spec[:description]}" unless spec[:description].empty?
|
|
227
248
|
line
|
|
228
249
|
end
|
|
229
250
|
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.
|
|
8
|
+
VERSION = "0.5.1"
|
|
9
9
|
|
|
10
10
|
DEFAULT_TIMEOUT_MS = 30_000
|
|
11
11
|
DEFAULT_FUEL_MS = 10_000
|
|
@@ -25,10 +25,16 @@ module RubyLLM
|
|
|
25
25
|
ToolEntry = Struct.new(:name, :tool, keyword_init: true)
|
|
26
26
|
|
|
27
27
|
McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Settings of the on-demand tool catalog (see .tool_catalog): how many
|
|
30
|
+
# results one search returns and how many characters of each tool's
|
|
31
|
+
# description the compact listing shows.
|
|
32
|
+
ToolCatalog = Struct.new(:search_limit, :preview_chars, keyword_init: true)
|
|
28
33
|
end
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
require_relative "code_mode/description"
|
|
37
|
+
require_relative "code_mode/catalog"
|
|
32
38
|
require_relative "code_mode/registry"
|
|
33
39
|
require_relative "code_mode/dsl"
|
|
34
40
|
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
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juneira
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 2.1.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: 2.1.1
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: security_box
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -78,6 +78,7 @@ files:
|
|
|
78
78
|
- LICENSE
|
|
79
79
|
- README.md
|
|
80
80
|
- lib/ruby_llm/code_mode.rb
|
|
81
|
+
- lib/ruby_llm/code_mode/catalog.rb
|
|
81
82
|
- lib/ruby_llm/code_mode/description.rb
|
|
82
83
|
- lib/ruby_llm/code_mode/dsl.rb
|
|
83
84
|
- lib/ruby_llm/code_mode/instance_methods.rb
|