robot_lab 0.5.1 → 0.6.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: 7830657dbfe2508c38884f5a117e3b1eb6db6223c0c5336ba8fdbdd3c8718732
4
- data.tar.gz: 3df367453aef2881e24e7e718c7778416e932e4f265deca27961bbe1952db483
3
+ metadata.gz: c0a38d5eaca5ddd1d558581dd57db81c94c461b0d9bf7bffbbf325f529e5a8ef
4
+ data.tar.gz: 7541f4d1fd133333d667a528f8f96da5287a76a4c6642a3677334496740934b6
5
5
  SHA512:
6
- metadata.gz: 5f12198c217fa3465274de33a0dfac869eecc51e679f5f65d6d0ee3f0ad18ce9cb14e346b12f35fff6f0fc948ec8dc19e2dfc4eae66afd94a6e4f469a9d2458b
7
- data.tar.gz: 7e91d8448bc45512216c1e53ba4dee007c0f40f225a6a34b1b8a51238962b99fd6b26757a6dc842d332ed34ccc73f7a381e404d622ad1e847bfeff5da29c1df0
6
+ metadata.gz: 6501bd24004fc28a105555a74ca5ae40d4e713d4485d1c581bfb6a77e810119b8e5a9c939c29fd19ec770e7b51eb10b43825c6ebddb7ef34fd60c4915e93639d
7
+ data.tar.gz: 70b65d1afb7fa35c48c278455ed7dd33fb7ff3c506eaf259ae885bb2b681efbeb0f4d9d8dfa7f99676817f07328c40ca7428ddf0ffe2110503bdf290524bf65c
data/CHANGELOG.md CHANGED
@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ### Fixed
12
12
 
13
+ - **Tool classes in `local_tools:`.** A `RubyLLM::Tool` subclass passed as a class (`local_tools: [SharedTools::Tools::DnsTool]`) reached the chat as `dns`, but robot_lab's own checks used the Ruby class name `"SharedTools::Tools::DnsTool"`. As a result, a `tools: %w[dns]` allowlist dropped the tool without an error. The robot now instantiates tool classes on construction, so every check uses the tool's name. The new `ToolConfig.instantiate` does this here and for template `tools:` front matter, and `ToolConfig.tool_name` (now public) reads `tool_name` from a tool class.
14
+
15
+ ### Added
16
+
17
+ - **MCP server classes by constant name.** `RobotLab::MCP.build`, `name_of`, `Registry#add` and a robot's per-turn server selection accept a String naming a `RubyLLM::MCP` subclass. Template front matter can now list `mcp: ["SharedTools::MCP::GitHub"]` in the same way `tools:` lists tool classes. The new `RobotLab::MCP.resolve` does the lookup and raises `MCPError` for an undefined constant or a class that is not a `RubyLLM::MCP`.
18
+ - **Class-level descriptions in `mcp_discovery`.** `ServerDiscovery.description_for` now falls back to a server's class when the instance has no `description`. Plain `RubyLLM::MCP` subclasses that declare a description at class level, such as the shared_tools 0.7.1 MCP clients, are scored instead of looking blank.
19
+
20
+ ## [0.5.1] - 2026-11-01
21
+
22
+ ### Fixed
23
+
13
24
  - **OAuth `grant:` from YAML.** An MCP server config such as `oauth: { grant: client_credentials }` raised `ArgumentError: Unknown OAuth grant`, because ruby_llm accepts the grant only as a Symbol. `RobotLab::MCP.define` now converts a String grant to a Symbol.
14
25
 
15
26
  ## [0.5.0] - 2026-11-01
@@ -24,9 +24,10 @@ module RobotLab
24
24
 
25
25
  # Register a server (or return the one already registered for +spec+).
26
26
  #
27
- # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
27
+ # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>, String]
28
28
  # @return [RubyLLM::MCP]
29
29
  def add(spec)
30
+ spec = MCP.resolve(spec)
30
31
  key = MCP.name_of(spec).to_sym
31
32
  existing = @servers[key]
32
33
  return existing if existing && same_spec?(key, spec, existing)
@@ -101,12 +101,18 @@ module RobotLab
101
101
  name.to_s.strip
102
102
  end
103
103
 
104
+ # An instance without its own +description+ falls back to its class's,
105
+ # so plain +RubyLLM::MCP+ subclasses that declare one at class level
106
+ # (SharedTools::MCP::GitHub, ...) are scored too.
107
+ #
104
108
  # @param server [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
105
109
  # @return [String] description or empty string
106
110
  def self.description_for(server)
107
111
  desc = case server
108
112
  when Hash then server[:description] || server['description']
109
- else (server.description if server.respond_to?(:description))
113
+ else
114
+ owner = [server, server.class].find { it.respond_to?(:description) }
115
+ owner&.description
110
116
  end
111
117
  desc.to_s.strip
112
118
  end
data/lib/robot_lab/mcp.rb CHANGED
@@ -54,12 +54,14 @@ module RobotLab
54
54
  module_function
55
55
 
56
56
  # Build a server from a config Hash, or pass through an instance or class.
57
+ # A String names a +RubyLLM::MCP+ subclass constant, the form template
58
+ # front matter uses (+mcp: ["SharedTools::MCP::GitHub"]+).
57
59
  #
58
- # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
60
+ # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>, String]
59
61
  # @return [RubyLLM::MCP]
60
62
  # @raise [MCPError] for an unusable spec
61
63
  def build(spec)
62
- case spec
64
+ case (spec = resolve(spec))
63
65
  when RubyLLM::MCP then spec
64
66
  when Class
65
67
  raise MCPError, "#{spec} is not a RubyLLM::MCP subclass" unless spec <= RubyLLM::MCP
@@ -68,10 +70,29 @@ module RobotLab
68
70
  when Hash then define(spec).new
69
71
  else
70
72
  raise MCPError, "Cannot build an MCP server from #{spec.class}; " \
71
- 'pass a Hash, a RubyLLM::MCP, or a RubyLLM::MCP subclass'
73
+ 'pass a Hash, a RubyLLM::MCP, a RubyLLM::MCP subclass, or its constant name'
72
74
  end
73
75
  end
74
76
 
77
+ # Turn a String spec into the +RubyLLM::MCP+ subclass it names; any other
78
+ # spec is returned unchanged.
79
+ #
80
+ # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>, String]
81
+ # @return [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
82
+ # @raise [MCPError] when the String does not name a RubyLLM::MCP subclass
83
+ def resolve(spec)
84
+ return spec unless spec.is_a?(String)
85
+
86
+ klass = begin
87
+ Object.const_get(spec)
88
+ rescue NameError
89
+ raise MCPError, "MCP server class '#{spec}' is not defined; require the file that defines it"
90
+ end
91
+ raise MCPError, "#{spec} is not a RubyLLM::MCP subclass" unless klass.is_a?(Class) && klass <= RubyLLM::MCP
92
+
93
+ klass
94
+ end
95
+
75
96
  # Turn a config Hash into an anonymous {Server} subclass with ruby_llm's
76
97
  # class-level DSL applied. Public so hosts can build classes once and
77
98
  # instantiate them per request (ruby_llm's recommended pattern).
@@ -88,11 +109,11 @@ module RobotLab
88
109
 
89
110
  # The name a spec will be registered under.
90
111
  #
91
- # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
112
+ # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>, String]
92
113
  # @return [String]
93
114
  # @raise [MCPError] when the spec has no name
94
115
  def name_of(spec)
95
- name = case spec
116
+ name = case (spec = resolve(spec))
96
117
  when Hash then spec[:name] || spec['name']
97
118
  when Class then spec.default_name
98
119
  when RubyLLM::MCP then spec.name
@@ -136,7 +136,7 @@ module RobotLab
136
136
  # @param message [String, RubyLLM::MCP::Prompt, nil] this run's request
137
137
  # @return [Array<RubyLLM::MCP>]
138
138
  def servers_for(resolved, message: nil)
139
- specs = Array(resolved)
139
+ specs = Array(resolved).map { MCP.resolve(it) }
140
140
  specs = MCP::ServerDiscovery.select(discovery_query(message), from: specs) if @mcp_discovery && message
141
141
  specs.map { |spec| @mcp_registry.add(spec) }
142
142
  end
@@ -323,8 +323,7 @@ module RobotLab
323
323
  case name
324
324
  when String
325
325
  begin
326
- const = Object.const_get(name)
327
- const.is_a?(Class) && const < RubyLLM::Tool ? const.new : const
326
+ ToolConfig.instantiate(Object.const_get(name))
328
327
  rescue NameError
329
328
  RobotLab.config.logger.warn("Robot '#{@name}': tool '#{name}' not found, skipping")
330
329
  nil
@@ -565,7 +565,7 @@ module RobotLab
565
565
  @system_prompt = system_prompt
566
566
  @build_context = context
567
567
  @description = description
568
- @local_tools = Array(local_tools)
568
+ @local_tools = Array(local_tools).map { ToolConfig.instantiate(it) }
569
569
  @skills = skills ? Array(skills).map(&:to_sym) : nil
570
570
  @expanded_skills = nil
571
571
  @pending_agent_skills = []
@@ -98,12 +98,28 @@ module RobotLab
98
98
  tools.select { |tool| allowed_set.include?(tool_name(tool)) }
99
99
  end
100
100
 
101
- private
101
+ # Turn a RubyLLM::Tool subclass into an instance; anything else is
102
+ # returned unchanged. Robots hold tool instances so every name check
103
+ # sees the tool's name (+current_date_time+), not its Ruby class name
104
+ # (+SharedTools::Tools::CurrentDateTimeTool+).
105
+ #
106
+ # @param tool [Class<RubyLLM::Tool>, RubyLLM::Tool, Object]
107
+ # @return [Object]
108
+ #
109
+ def instantiate(tool)
110
+ tool.is_a?(Class) && tool < RubyLLM::Tool ? tool.new : tool
111
+ end
102
112
 
113
+ # The name a tool is offered to the LLM under.
114
+ #
115
+ # @param tool [String, Symbol, Class<RubyLLM::Tool>, #name]
116
+ # @return [String]
117
+ #
103
118
  def tool_name(tool)
104
119
  case tool
105
120
  when String then tool
106
121
  when Symbol then tool.to_s
122
+ when Class then (tool.respond_to?(:tool_name) ? tool.tool_name : tool.name).to_s
107
123
  else tool.respond_to?(:name) ? tool.name.to_s : tool.to_s
108
124
  end
109
125
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer