ruby_llm-code_mode 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2a3805466e08fb043ab80695cd7dd7d234bc6cb1266ed2c0e2af790d3154ca7
4
- data.tar.gz: 54784bff8beecd426ce7d9ec9cdbd632463a88c51060891c966d475a65fa1798
3
+ metadata.gz: db9c56784205c376a44ee5fd50fd37ec0391cb6d376b4c0d3f64e12aa1e0692a
4
+ data.tar.gz: e6c7fe1c88bb4cb1a7095d0071567e1c4f920df32910ccfba189347a9cd21511
5
5
  SHA512:
6
- metadata.gz: ce78787e62249f947b126dd31f13f30ae76160b85de5373e0679ea259e7f31f162df811f55372a67ba7ec2bee97f44ec7d9af372a7df67d098eb7361a41de3e6
7
- data.tar.gz: 93d477c68ed0ae001f068c18e729791c1eb302687a82eafc97bc6013306eb9d5998896d11d9c172fe3fc77e55dfad9018b36968809a0559ef256804055c16ee8
6
+ metadata.gz: 6155c17980dc6c608ca43d079fb7a7340a074afc470f507724535b625570c5ade07e85756a392ff16f8a1218022feca7ac9af19c37becaf5c80f811f3cd07517
7
+ data.tar.gz: 82e5ed5e0fef8d8fb36bd977a0cdd49cab5c61a01200cbc7df4756b65ce814593f04f27afb9493b05c57ad3270da346b8916695eee7378e458c22b92191f7713
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 (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.
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(tools.merge(extra_tools)),
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. Memoized per
92
- # class; subclasses build their own. Tool instances without
93
- # per-instance additions share it (see #add_tools).
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: Registry.rpc_handlers(tools)
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 and MCP servers (entries and instances shared), and their own
113
- # configuration memo so limits/rpcs are rebuilt per class.
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, reserved: self.class.tools)
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, reserved: self.class.tools)
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 that).
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: self.class.configuration.rpcs.merge(Registry.rpc_handlers(instance_tools))
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
- def register_tools(target, args, kwargs, reserved: EMPTY)
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
- # One RPC handler per bound tool, ready for
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
- total = target.size + reserved.size
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, "too many tools (#{total + 1}); the limit is #{MAX_TOOLS}"
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
- 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?
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
@@ -5,7 +5,7 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.4.3"
8
+ VERSION = "0.5.0"
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.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira
@@ -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