ruby_llm-code_mode 0.1.1 → 0.2.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 +4 -4
- data/LICENSE +2 -2
- data/README.md +101 -7
- data/lib/ruby_llm/code_mode.rb +240 -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: b92ccf0470dc8d3563eafa55662e7aa0db058095d8ac12a0d60a062547474f81
|
|
4
|
+
data.tar.gz: c2955740ee6b04b1a38279b25f63703194df752fa51f3d4f49166bab3f318ac3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fb1029e393ec6c0041d91d5a45fbef2acdc253fe28427986d0832a73eb33b43becfa42d97983212ac7a3312a771e9506765c271bd4bd2ebcf68968b7adf467e
|
|
7
|
+
data.tar.gz: 52e9aa1bf43f9dcedf0a6e50deb9578c86716bdd5ae353da6717a6553b7f5170f3773f5c40a4229779589be9686e0a3274788d3dc658cdb084fcc5ff862c206a
|
data/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 Marcelo Junior
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
21
|
+
SOFTWARE.
|
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` or `mcp`.
|
|
52
54
|
|
|
53
55
|
The single parameter is `code` — a complete, self-contained Ruby script.
|
|
54
56
|
|
|
@@ -68,6 +70,9 @@ 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
|
|
75
|
+
mcp SomeMcpServer # MCP server (instance or class)
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
- `source:` — folder on the host machine, relative to the process working
|
|
@@ -75,9 +80,100 @@ mount_rw source: host_path, dest: guest_path, description: "..." # read-write
|
|
|
75
80
|
- `dest:` — absolute, normalized path inside the sandbox (may not overlap
|
|
76
81
|
the reserved `/work`, `/usr` or `/src` trees, and must be unique).
|
|
77
82
|
- `description:` — shown to the model in the tool description.
|
|
83
|
+
- `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
|
|
84
|
+
is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
|
|
85
|
+
or the explicit one from the one-pair form.
|
|
86
|
+
- `mcp` — a `RubyLLM::MCP` instance or class (auto-instantiated, with the
|
|
87
|
+
server's declared inputs as keywords); all of the server's tools are bound
|
|
88
|
+
under their own names.
|
|
78
89
|
|
|
79
90
|
Invalid declarations raise at class-definition time, so a misconfigured tool
|
|
80
|
-
never reaches a live chat. Subclasses inherit their parent's mounts.
|
|
91
|
+
never reaches a live chat. Subclasses inherit their parent's mounts and tools.
|
|
92
|
+
|
|
93
|
+
### Host tools
|
|
94
|
+
|
|
95
|
+
Bind existing `RubyLLM::Tool`s so the sandboxed code can call them through
|
|
96
|
+
SecurityBox's host RPC channel:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class Weather < RubyLLM::Tool
|
|
100
|
+
description "Current weather for a city"
|
|
101
|
+
parameter :city, type: "string", description: "City name"
|
|
102
|
+
|
|
103
|
+
def execute(city:)
|
|
104
|
+
# host-side HTTP call, database query, ...
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class Assistant < RubyLLM::CodeMode
|
|
109
|
+
mount source: "data", dest: "/data", description: "Project data"
|
|
110
|
+
tool Weather # binds as `weather`
|
|
111
|
+
tool "forecast" => Weather # or bind it under an explicit name
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The description tells the model what is available, and inside the sandbox it
|
|
116
|
+
writes Ruby like:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
report = SB.call("weather", city: "Porto Alegre")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Bound classes are instantiated once at definition time; every call shares
|
|
123
|
+
that instance, so state persists across calls (like the sandbox itself).
|
|
124
|
+
- Arguments must be JSON-serializable; they arrive as the tool's keyword
|
|
125
|
+
arguments (top-level string keys become symbols, nested data keeps string
|
|
126
|
+
keys). Bad arguments come back as an `error` hash — RubyLLM's
|
|
127
|
+
recoverable-failure convention.
|
|
128
|
+
- The return value is JSON round-tripped: hashes, arrays and scalars pass
|
|
129
|
+
through; non-serializable objects surface as their `inspect` string.
|
|
130
|
+
- A tool that raises surfaces as `SB::ToolError` (class name and message
|
|
131
|
+
only — no backtrace), which the sandboxed code can rescue. Unbound names
|
|
132
|
+
raise `SB::UnknownTool`.
|
|
133
|
+
- Limits per execution: 1000 tool calls and 1 MiB per result. At most 64
|
|
134
|
+
tools can be bound to one class.
|
|
135
|
+
- A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
|
|
136
|
+
|
|
137
|
+
### MCP servers
|
|
138
|
+
|
|
139
|
+
The `mcp` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
|
|
140
|
+
and binds all their tools in one call, letting the sandboxed code reach the
|
|
141
|
+
server through the host RPC channel:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
145
|
+
|
|
146
|
+
class DocsResearch < RubyLLM::CodeMode
|
|
147
|
+
mcp docs # instance
|
|
148
|
+
# mcp MicrosoftDocs # or the class (instantiated with .new)
|
|
149
|
+
# mcp Linear, user: current_user # class + the server's declared inputs
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Each server tool keeps its server name and its JSON schema is rendered into
|
|
154
|
+
the tool description, so inside the sandbox the model writes:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
result = SB.call("microsoft_docs_search", query: "Azure Blob Storage")
|
|
158
|
+
result[:text] # => the tool's text output
|
|
159
|
+
result[:structured] # => parsed structured content, or nil
|
|
160
|
+
result[:error] # => true when the tool reported a failure
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- A tool whose name is already bound is re-bound as `<server>_tool`
|
|
164
|
+
(`search` on server `github` → `github_search`); a name that still collides
|
|
165
|
+
after prefixing raises at definition time.
|
|
166
|
+
- Servers that send `instructions` have them listed in the description under
|
|
167
|
+
a "## Server notes" section.
|
|
168
|
+
- Everything fails fast: an argument that is not a `RubyLLM::MCP`, a server
|
|
169
|
+
that cannot be reached, or a stuck name collision roll the whole call back
|
|
170
|
+
and raise at definition time.
|
|
171
|
+
- The class form binds the tools of one instance built at definition time —
|
|
172
|
+
for servers that act per-user, pass a ready instance instead
|
|
173
|
+
(`mcp Linear.new(user: current_user)`) where the user is available.
|
|
174
|
+
|
|
175
|
+
The sandbox itself never sees the network — every `SB.call` performs the MCP
|
|
176
|
+
request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
|
|
81
177
|
|
|
82
178
|
### Sandbox limits
|
|
83
179
|
|
|
@@ -96,11 +192,9 @@ every subsequent evaluation then takes a few hundred milliseconds.
|
|
|
96
192
|
- Guest code is sandboxed by [SecurityBox](https://rubygems.org/gems/security_box):
|
|
97
193
|
no network, no threads, no processes, no host filesystem beyond the mounts,
|
|
98
194
|
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`.
|
|
195
|
+
- Bound host tools execute on the host, behind the sandbox's call boundary:
|
|
196
|
+
their arguments come from model-generated code, so treat them as untrusted
|
|
197
|
+
input, and only bind tools whose effects you are willing to grant the model.
|
|
104
198
|
|
|
105
199
|
## Development
|
|
106
200
|
|
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.
|
|
8
|
+
VERSION = "0.2.0"
|
|
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,26 @@ 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
|
+
|
|
54
|
+
McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
|
|
55
|
+
|
|
38
56
|
class << self
|
|
39
57
|
def tool_name
|
|
40
58
|
leaf = name.to_s.split('::').last
|
|
@@ -51,15 +69,78 @@ module RubyLLM
|
|
|
51
69
|
add_mount(source, dest, :read_write, description)
|
|
52
70
|
end
|
|
53
71
|
|
|
72
|
+
def tool(mapping = nil, **kwargs)
|
|
73
|
+
if !kwargs.empty?
|
|
74
|
+
raise ArgumentError, TOOL_USAGE unless kwargs.size == 1 && mapping.nil?
|
|
75
|
+
|
|
76
|
+
name, bound = kwargs.first
|
|
77
|
+
mapping = { name.to_s => bound }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if mapping.is_a?(Hash)
|
|
81
|
+
raise ArgumentError, TOOL_USAGE unless mapping.size == 1
|
|
82
|
+
|
|
83
|
+
name, bound = mapping.first
|
|
84
|
+
add_tool(bound, name.to_s)
|
|
85
|
+
else
|
|
86
|
+
add_tool(mapping, nil)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Connects MCP server(s) and binds all their tools into the sandbox:
|
|
91
|
+
#
|
|
92
|
+
# docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
|
|
93
|
+
#
|
|
94
|
+
# class DocsResearch < RubyLLM::CodeMode
|
|
95
|
+
# mcp docs # instance
|
|
96
|
+
# mcp MicrosoftDocs # or the class (instantiated with .new)
|
|
97
|
+
# mcp Linear, user: current_user # class + declared inputs
|
|
98
|
+
# mcp Docs, Github # several servers / arrays at once
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# Server tools keep their names (`microsoft_docs_search`, ...); a tool
|
|
102
|
+
# whose name is already bound is re-bound as "<server>_tool". The
|
|
103
|
+
# servers' instructions (when the server sends them) are snapshotted
|
|
104
|
+
# at definition and listed in the description under "## Server notes".
|
|
105
|
+
# Fails fast: every argument must be a RubyLLM::MCP (class or
|
|
106
|
+
# instance), and if anything goes wrong — a server that cannot be
|
|
107
|
+
# reached, a name that stays colliding after prefixing — nothing is
|
|
108
|
+
# bound.
|
|
109
|
+
def mcp(*servers, **inputs)
|
|
110
|
+
flat = servers.flatten(1)
|
|
111
|
+
raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
|
|
112
|
+
unless inputs.empty? || flat.all?(Class)
|
|
113
|
+
raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
snapshot_tools = tools.dup
|
|
117
|
+
snapshot_mcps = mcps.dup
|
|
118
|
+
normalize_mcp_servers(flat, inputs).each { |server| bind_mcp_server(server) }
|
|
119
|
+
mcps
|
|
120
|
+
rescue StandardError
|
|
121
|
+
instance_variable_set(:@tools, snapshot_tools)
|
|
122
|
+
instance_variable_set(:@mcps, snapshot_mcps)
|
|
123
|
+
raise
|
|
124
|
+
end
|
|
125
|
+
|
|
54
126
|
def mounts
|
|
55
127
|
@mounts ||= []
|
|
56
128
|
end
|
|
57
129
|
|
|
130
|
+
def tools
|
|
131
|
+
@tools ||= {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mcps
|
|
135
|
+
@mcps ||= []
|
|
136
|
+
end
|
|
137
|
+
|
|
58
138
|
def configuration
|
|
59
139
|
@configuration ||= SecurityBox::Configuration.build(
|
|
60
140
|
timeout_ms: DEFAULT_TIMEOUT_MS,
|
|
61
141
|
fuel_ms: DEFAULT_FUEL_MS,
|
|
62
|
-
mounts: mounts.map(&:payload)
|
|
142
|
+
mounts: mounts.map(&:payload),
|
|
143
|
+
rpcs: build_rpcs
|
|
63
144
|
)
|
|
64
145
|
end
|
|
65
146
|
|
|
@@ -68,7 +149,9 @@ module RubyLLM
|
|
|
68
149
|
[
|
|
69
150
|
FIXED_DESCRIPTION.rstrip,
|
|
70
151
|
folder_section("## Read-only folders (readable, never writable)", read_only),
|
|
71
|
-
folder_section("## Read-write folders (readable and writable)", read_write)
|
|
152
|
+
folder_section("## Read-write folders (readable and writable)", read_write),
|
|
153
|
+
tools_section,
|
|
154
|
+
server_notes_section
|
|
72
155
|
].compact.join("\n\n")
|
|
73
156
|
end
|
|
74
157
|
|
|
@@ -87,6 +170,8 @@ module RubyLLM
|
|
|
87
170
|
def inherited(subclass)
|
|
88
171
|
super
|
|
89
172
|
subclass.instance_variable_set(:@mounts, mounts.dup)
|
|
173
|
+
subclass.instance_variable_set(:@tools, tools.dup)
|
|
174
|
+
subclass.instance_variable_set(:@mcps, mcps.dup)
|
|
90
175
|
subclass.instance_variable_set(:@configuration, nil)
|
|
91
176
|
end
|
|
92
177
|
|
|
@@ -106,6 +191,113 @@ module RubyLLM
|
|
|
106
191
|
"Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
|
|
107
192
|
end
|
|
108
193
|
|
|
194
|
+
def add_tool(bound, name)
|
|
195
|
+
instance = normalize_bound_tool(bound)
|
|
196
|
+
if instance.is_a?(RubyLLM::CodeMode)
|
|
197
|
+
raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
name = (name || tool_binding_name(instance)).to_s
|
|
201
|
+
raise ArgumentError, "tool name must be a non-empty String" if name.empty?
|
|
202
|
+
raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
|
|
203
|
+
if tools.size >= MAX_TOOLS
|
|
204
|
+
raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
tools[name] = ToolEntry.new(name: name, tool: instance)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def normalize_bound_tool(bound)
|
|
211
|
+
if bound.is_a?(Class)
|
|
212
|
+
unless bound <= RubyLLM::Tool
|
|
213
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
bound.new
|
|
217
|
+
elsif bound.is_a?(RubyLLM::Tool)
|
|
218
|
+
bound
|
|
219
|
+
else
|
|
220
|
+
raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Leaf-only name derivation, like .tool_name — the guest has no use
|
|
225
|
+
# for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
|
|
226
|
+
def leaf_tool_name(klass)
|
|
227
|
+
leaf = klass.name.to_s.split('::').last
|
|
228
|
+
return "" unless leaf
|
|
229
|
+
|
|
230
|
+
RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Regular tools keep the leaf-only derivation; MCP tools carry their
|
|
234
|
+
# own (server) name, which is what they bind under inside the sandbox.
|
|
235
|
+
def tool_binding_name(instance)
|
|
236
|
+
return instance.name.to_s if instance.respond_to?(:server_name)
|
|
237
|
+
|
|
238
|
+
leaf_tool_name(instance.class)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_rpcs
|
|
242
|
+
tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def call_bound_tool(entry, args)
|
|
246
|
+
unless args.is_a?(Hash)
|
|
247
|
+
raise ArgumentError,
|
|
248
|
+
"tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
normalize_result(entry.tool.call(**args))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
|
|
255
|
+
# round-trips handler returns, so give the guest plain data instead
|
|
256
|
+
# of an inspect string.
|
|
257
|
+
def normalize_result(value)
|
|
258
|
+
return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
|
|
259
|
+
|
|
260
|
+
{ text: value.text, structured: value.structured, error: value.error? }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def normalize_mcp_servers(servers, inputs)
|
|
264
|
+
unless defined?(RubyLLM::MCP)
|
|
265
|
+
raise ArgumentError,
|
|
266
|
+
"RubyLLM::MCP is not available; mcp needs a ruby_llm version with MCP support"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
servers.map do |server|
|
|
270
|
+
case server
|
|
271
|
+
when Class
|
|
272
|
+
unless server <= RubyLLM::MCP
|
|
273
|
+
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
server.new(**inputs)
|
|
277
|
+
when RubyLLM::MCP
|
|
278
|
+
server
|
|
279
|
+
else
|
|
280
|
+
raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def bind_mcp_server(server)
|
|
286
|
+
# Fetching .tools connects to the server; snapshot the instructions
|
|
287
|
+
# right after, so description builds never touch the network.
|
|
288
|
+
tools_list = server.tools
|
|
289
|
+
mcps << McpEntry.new(
|
|
290
|
+
name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
|
|
291
|
+
)
|
|
292
|
+
tools_list.each { |tool| bind_mcp_tool(server, tool) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def bind_mcp_tool(server, tool)
|
|
296
|
+
name = tool_binding_name(tool)
|
|
297
|
+
name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
|
|
298
|
+
add_tool(tool, name)
|
|
299
|
+
end
|
|
300
|
+
|
|
109
301
|
def folder_section(header, entries)
|
|
110
302
|
return nil if entries.empty?
|
|
111
303
|
|
|
@@ -116,6 +308,51 @@ module RubyLLM
|
|
|
116
308
|
end
|
|
117
309
|
[header, *lines].join("\n")
|
|
118
310
|
end
|
|
311
|
+
|
|
312
|
+
def tools_section
|
|
313
|
+
return nil if tools.empty?
|
|
314
|
+
|
|
315
|
+
[
|
|
316
|
+
"## Host tools (call with SB.call)",
|
|
317
|
+
HOST_TOOLS_INTRO.rstrip,
|
|
318
|
+
*tools.values.flat_map { |entry| tool_lines(entry) }
|
|
319
|
+
].join("\n")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def server_notes_section
|
|
323
|
+
notes = mcps.filter_map do |entry|
|
|
324
|
+
instructions = entry.instructions.to_s
|
|
325
|
+
next if instructions.empty?
|
|
326
|
+
|
|
327
|
+
"- **#{entry.name}** — #{instructions}"
|
|
328
|
+
end
|
|
329
|
+
return nil if notes.empty?
|
|
330
|
+
|
|
331
|
+
["## Server notes", *notes].join("\n")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def tool_lines(entry)
|
|
335
|
+
line = "- `#{entry.name}`"
|
|
336
|
+
description = entry.tool.description.to_s
|
|
337
|
+
line += " — #{description}" unless description.empty?
|
|
338
|
+
|
|
339
|
+
[line, *param_lines(entry.tool).map { |param| " #{param}" }]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def param_lines(tool)
|
|
343
|
+
schema = tool.parameters_schema || {}
|
|
344
|
+
properties = schema["properties"] || {}
|
|
345
|
+
required = schema["required"] || []
|
|
346
|
+
|
|
347
|
+
properties.filter_map do |param, spec|
|
|
348
|
+
spec ||= {}
|
|
349
|
+
status = required.include?(param) ? "required" : "optional"
|
|
350
|
+
line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
|
|
351
|
+
description = spec["description"].to_s
|
|
352
|
+
line += " — #{description}" unless description.empty?
|
|
353
|
+
line
|
|
354
|
+
end
|
|
355
|
+
end
|
|
119
356
|
end
|
|
120
357
|
|
|
121
358
|
parameter :code, description: "Complete Ruby script to execute in the sandbox"
|