robot_lab 0.0.1 → 0.0.6

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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/deploy-github-pages.yml +9 -9
  3. data/.irbrc +6 -0
  4. data/CHANGELOG.md +140 -0
  5. data/README.md +263 -48
  6. data/Rakefile +71 -1
  7. data/docs/api/core/index.md +53 -46
  8. data/docs/api/core/memory.md +200 -154
  9. data/docs/api/core/network.md +13 -3
  10. data/docs/api/core/robot.md +490 -130
  11. data/docs/api/core/state.md +55 -73
  12. data/docs/api/core/tool.md +205 -209
  13. data/docs/api/index.md +7 -28
  14. data/docs/api/mcp/client.md +119 -48
  15. data/docs/api/mcp/index.md +75 -60
  16. data/docs/api/mcp/server.md +120 -136
  17. data/docs/api/mcp/transports.md +172 -184
  18. data/docs/api/messages/index.md +35 -20
  19. data/docs/api/messages/text-message.md +67 -21
  20. data/docs/api/messages/tool-call-message.md +80 -41
  21. data/docs/api/messages/tool-result-message.md +119 -50
  22. data/docs/api/messages/user-message.md +48 -24
  23. data/docs/api/streaming/context.md +157 -74
  24. data/docs/api/streaming/events.md +114 -166
  25. data/docs/api/streaming/index.md +74 -72
  26. data/docs/architecture/core-concepts.md +360 -116
  27. data/docs/architecture/index.md +97 -59
  28. data/docs/architecture/message-flow.md +138 -129
  29. data/docs/architecture/network-orchestration.md +197 -50
  30. data/docs/architecture/robot-execution.md +199 -146
  31. data/docs/architecture/state-management.md +255 -187
  32. data/docs/concepts.md +311 -49
  33. data/docs/examples/basic-chat.md +89 -77
  34. data/docs/examples/index.md +222 -47
  35. data/docs/examples/mcp-server.md +207 -203
  36. data/docs/examples/multi-robot-network.md +129 -35
  37. data/docs/examples/rails-application.md +159 -160
  38. data/docs/examples/tool-usage.md +295 -204
  39. data/docs/getting-started/configuration.md +347 -154
  40. data/docs/getting-started/index.md +1 -1
  41. data/docs/getting-started/installation.md +22 -13
  42. data/docs/getting-started/quick-start.md +166 -121
  43. data/docs/guides/building-robots.md +418 -212
  44. data/docs/guides/creating-networks.md +143 -24
  45. data/docs/guides/index.md +0 -5
  46. data/docs/guides/mcp-integration.md +152 -113
  47. data/docs/guides/memory.md +220 -164
  48. data/docs/guides/rails-integration.md +244 -162
  49. data/docs/guides/streaming.md +137 -187
  50. data/docs/guides/using-tools.md +259 -212
  51. data/docs/index.md +46 -41
  52. data/examples/01_simple_robot.rb +6 -9
  53. data/examples/02_tools.rb +6 -9
  54. data/examples/03_network.rb +19 -17
  55. data/examples/04_mcp.rb +5 -8
  56. data/examples/05_streaming.rb +5 -8
  57. data/examples/06_prompt_templates.rb +42 -37
  58. data/examples/07_network_memory.rb +13 -14
  59. data/examples/08_llm_config.rb +169 -0
  60. data/examples/09_chaining.rb +262 -0
  61. data/examples/10_memory.rb +331 -0
  62. data/examples/11_network_introspection.rb +253 -0
  63. data/examples/12_message_bus.rb +74 -0
  64. data/examples/13_spawn.rb +90 -0
  65. data/examples/14_rusty_circuit/comic.rb +143 -0
  66. data/examples/14_rusty_circuit/display.rb +203 -0
  67. data/examples/14_rusty_circuit/heckler.rb +63 -0
  68. data/examples/14_rusty_circuit/open_mic.rb +123 -0
  69. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
  70. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
  71. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
  72. data/examples/14_rusty_circuit/scout.rb +156 -0
  73. data/examples/14_rusty_circuit/scout_notes.md +89 -0
  74. data/examples/14_rusty_circuit/show.log +234 -0
  75. data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
  78. data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
  79. data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
  80. data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
  81. data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
  82. data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
  83. data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
  84. data/examples/15_memory_network_and_bus/output/memory.json +13 -0
  85. data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
  86. data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
  87. data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
  88. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
  89. data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
  90. data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
  91. data/examples/16_writers_room/display.rb +158 -0
  92. data/examples/16_writers_room/output/.gitignore +2 -0
  93. data/examples/16_writers_room/output/opus_001.md +263 -0
  94. data/examples/16_writers_room/output/opus_001_notes.log +470 -0
  95. data/examples/16_writers_room/prompts/writer.md +37 -0
  96. data/examples/16_writers_room/room.rb +150 -0
  97. data/examples/16_writers_room/tools.rb +162 -0
  98. data/examples/16_writers_room/writer.rb +121 -0
  99. data/examples/16_writers_room/writers_room.rb +162 -0
  100. data/examples/README.md +197 -0
  101. data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
  102. data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
  103. data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
  104. data/examples/prompts/comedian.md +6 -0
  105. data/examples/prompts/comedy_critic.md +10 -0
  106. data/examples/prompts/configurable.md +9 -0
  107. data/examples/prompts/dispatcher.md +12 -0
  108. data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
  109. data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
  110. data/examples/prompts/frontmatter_mcp_test.md +9 -0
  111. data/examples/prompts/frontmatter_named_test.md +5 -0
  112. data/examples/prompts/frontmatter_tools_test.md +6 -0
  113. data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
  114. data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
  115. data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
  116. data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
  117. data/examples/prompts/llm_config_demo.md +20 -0
  118. data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
  119. data/examples/prompts/os_advocate.md +13 -0
  120. data/examples/prompts/os_chief.md +13 -0
  121. data/examples/prompts/os_editor.md +13 -0
  122. data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
  123. data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
  124. data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
  125. data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
  126. data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
  127. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
  128. data/lib/robot_lab/ask_user.rb +75 -0
  129. data/lib/robot_lab/config/defaults.yml +121 -0
  130. data/lib/robot_lab/config.rb +183 -0
  131. data/lib/robot_lab/error.rb +6 -0
  132. data/lib/robot_lab/mcp/client.rb +1 -1
  133. data/lib/robot_lab/memory.rb +10 -34
  134. data/lib/robot_lab/network.rb +13 -20
  135. data/lib/robot_lab/robot/bus_messaging.rb +239 -0
  136. data/lib/robot_lab/robot/mcp_management.rb +88 -0
  137. data/lib/robot_lab/robot/template_rendering.rb +130 -0
  138. data/lib/robot_lab/robot.rb +240 -330
  139. data/lib/robot_lab/robot_message.rb +44 -0
  140. data/lib/robot_lab/robot_result.rb +1 -0
  141. data/lib/robot_lab/run_config.rb +184 -0
  142. data/lib/robot_lab/state_proxy.rb +2 -12
  143. data/lib/robot_lab/streaming/context.rb +1 -1
  144. data/lib/robot_lab/task.rb +8 -1
  145. data/lib/robot_lab/tool.rb +108 -172
  146. data/lib/robot_lab/tool_config.rb +1 -1
  147. data/lib/robot_lab/tool_manifest.rb +2 -18
  148. data/lib/robot_lab/utils.rb +39 -0
  149. data/lib/robot_lab/version.rb +1 -1
  150. data/lib/robot_lab.rb +89 -57
  151. data/mkdocs.yml +0 -11
  152. metadata +121 -135
  153. data/docs/api/adapters/anthropic.md +0 -121
  154. data/docs/api/adapters/gemini.md +0 -133
  155. data/docs/api/adapters/index.md +0 -104
  156. data/docs/api/adapters/openai.md +0 -134
  157. data/docs/api/history/active-record-adapter.md +0 -195
  158. data/docs/api/history/config.md +0 -191
  159. data/docs/api/history/index.md +0 -132
  160. data/docs/api/history/thread-manager.md +0 -144
  161. data/docs/guides/history.md +0 -359
  162. data/examples/prompts/assistant/user.txt.erb +0 -1
  163. data/examples/prompts/billing/user.txt.erb +0 -1
  164. data/examples/prompts/classifier/user.txt.erb +0 -1
  165. data/examples/prompts/entity_extractor/user.txt.erb +0 -3
  166. data/examples/prompts/escalation/user.txt.erb +0 -34
  167. data/examples/prompts/general/user.txt.erb +0 -1
  168. data/examples/prompts/github_assistant/user.txt.erb +0 -1
  169. data/examples/prompts/helper/user.txt.erb +0 -1
  170. data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
  171. data/examples/prompts/order_support/user.txt.erb +0 -22
  172. data/examples/prompts/product_support/user.txt.erb +0 -32
  173. data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
  174. data/examples/prompts/synthesizer/user.txt.erb +0 -15
  175. data/examples/prompts/technical/user.txt.erb +0 -1
  176. data/examples/prompts/triage/user.txt.erb +0 -17
  177. data/lib/robot_lab/adapters/anthropic.rb +0 -163
  178. data/lib/robot_lab/adapters/base.rb +0 -85
  179. data/lib/robot_lab/adapters/gemini.rb +0 -193
  180. data/lib/robot_lab/adapters/openai.rb +0 -159
  181. data/lib/robot_lab/adapters/registry.rb +0 -81
  182. data/lib/robot_lab/configuration.rb +0 -143
  183. data/lib/robot_lab/errors.rb +0 -70
  184. data/lib/robot_lab/history/active_record_adapter.rb +0 -146
  185. data/lib/robot_lab/history/config.rb +0 -115
  186. data/lib/robot_lab/history/thread_manager.rb +0 -93
  187. data/lib/robot_lab/robotic_model.rb +0 -324
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Typed message envelope for bus-based robot communication.
5
+ #
6
+ # RobotMessage is a Data class (immutable value object) that wraps
7
+ # content sent between robots via a TypedBus channel.
8
+ #
9
+ # @example Creating a new message
10
+ # msg = RobotMessage.build(id: 1, from: "alice", content: "Hello")
11
+ # msg.key #=> "alice:1"
12
+ # msg.reply? #=> false
13
+ #
14
+ # @example Creating a reply
15
+ # reply = RobotMessage.build(
16
+ # id: 2, from: "bob",
17
+ # content: "Hi back",
18
+ # in_reply_to: "alice:1"
19
+ # )
20
+ # reply.reply? #=> true
21
+ #
22
+ RobotMessage = Data.define(:id, :from, :content, :in_reply_to) do
23
+ # Build a RobotMessage with in_reply_to defaulting to nil.
24
+ #
25
+ # @param id [Integer] per-robot message counter
26
+ # @param from [String] sender's robot name (= channel name)
27
+ # @param content [String, Hash] message payload
28
+ # @param in_reply_to [String, nil] composite key of the message being replied to
29
+ # @return [RobotMessage]
30
+ def self.build(id:, from:, content:, in_reply_to: nil)
31
+ new(id: id, from: from, content: content, in_reply_to: in_reply_to)
32
+ end
33
+
34
+ # Composite identity key: "from:id"
35
+ #
36
+ # @return [String]
37
+ def key = "#{from}:#{id}"
38
+
39
+ # Whether this message is a reply to another message.
40
+ #
41
+ # @return [Boolean]
42
+ def reply? = !in_reply_to.nil?
43
+ end
44
+ end
@@ -135,6 +135,7 @@ module RobotLab
135
135
  def last_text_content
136
136
  output.reverse.find(&:text?)&.content
137
137
  end
138
+ alias_method :reply, :last_text_content
138
139
 
139
140
  # Check if result contains tool calls
140
141
  #
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Shared configuration object for LLM, tools, callbacks, and infrastructure.
5
+ #
6
+ # RunConfig provides a unified way to express operational defaults that flow
7
+ # through the configuration hierarchy:
8
+ #
9
+ # RobotLab.config -> Network -> Robot -> Template front matter -> Task -> Runtime
10
+ #
11
+ # Only explicitly set values are stored. Merge semantics: the more-specific
12
+ # config's non-nil values win over the less-specific config.
13
+ #
14
+ # @example Keyword construction
15
+ # config = RunConfig.new(model: "claude-sonnet-4", temperature: 0.7)
16
+ #
17
+ # @example Block DSL
18
+ # config = RunConfig.new do |c|
19
+ # c.model "claude-sonnet-4"
20
+ # c.temperature 0.7
21
+ # end
22
+ #
23
+ # @example Merge (more-specific wins)
24
+ # network_config = RunConfig.new(model: "claude-sonnet-4", temperature: 0.5)
25
+ # robot_config = RunConfig.new(temperature: 0.9)
26
+ # effective = network_config.merge(robot_config)
27
+ # effective.temperature #=> 0.9
28
+ # effective.model #=> "claude-sonnet-4"
29
+ #
30
+ class RunConfig
31
+ # LLM configuration fields (applied to chat via with_* methods)
32
+ LLM_FIELDS = %i[
33
+ model temperature top_p top_k max_tokens
34
+ presence_penalty frequency_penalty stop
35
+ ].freeze
36
+
37
+ # Tool-related fields
38
+ TOOL_FIELDS = %i[mcp tools].freeze
39
+
40
+ # Callback fields (Procs)
41
+ CALLBACK_FIELDS = %i[on_tool_call on_tool_result].freeze
42
+
43
+ # Infrastructure fields
44
+ INFRA_FIELDS = %i[bus enable_cache].freeze
45
+
46
+ # All recognized fields
47
+ FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
48
+
49
+ # Fields that cannot be serialized to JSON (Procs, IO objects, etc.)
50
+ NON_SERIALIZABLE_FIELDS = (CALLBACK_FIELDS + %i[bus]).freeze
51
+
52
+ # Creates a new RunConfig.
53
+ #
54
+ # @param kwargs [Hash] field values to set
55
+ # @yield [self] optional block for DSL-style configuration
56
+ def initialize(**kwargs)
57
+ @fields = {}
58
+
59
+ kwargs.each do |key, value|
60
+ set(key, value)
61
+ end
62
+
63
+ yield self if block_given?
64
+ end
65
+
66
+ # Define getter/setter DSL methods for each field.
67
+ #
68
+ # With no arguments: returns the current value (getter).
69
+ # With one argument: sets the value and returns self (chainable setter).
70
+ FIELDS.each do |field|
71
+ define_method(field) do |value = :__unset__|
72
+ if value == :__unset__
73
+ @fields[field]
74
+ else
75
+ set(field, value)
76
+ self
77
+ end
78
+ end
79
+ end
80
+
81
+ # Returns a duplicate of the internal fields hash.
82
+ #
83
+ # @return [Hash] only the fields that have been explicitly set
84
+ def to_h
85
+ @fields.dup
86
+ end
87
+
88
+
89
+ # Returns a JSON-safe hash (skips Procs, IO, and other non-serializable values).
90
+ #
91
+ # @return [Hash]
92
+ def to_json_hash
93
+ @fields.reject { |k, _| NON_SERIALIZABLE_FIELDS.include?(k) }
94
+ end
95
+
96
+
97
+ # Merges another RunConfig (or Hash) on top of this one.
98
+ # The other's non-nil values win. Returns a new RunConfig.
99
+ #
100
+ # @param other [RunConfig, Hash] the more-specific configuration
101
+ # @return [RunConfig] a new merged RunConfig
102
+ def merge(other)
103
+ other_hash = other.is_a?(RunConfig) ? other.to_h : other
104
+ merged = @fields.merge(other_hash) { |_k, old_v, new_v| new_v.nil? ? old_v : new_v }
105
+ self.class.new(**merged)
106
+ end
107
+
108
+
109
+ # Applies LLM fields to a chat object via its with_* methods.
110
+ #
111
+ # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
112
+ def apply_to(chat)
113
+ LLM_FIELDS.each do |field|
114
+ value = @fields[field]
115
+ next unless value
116
+
117
+ method = :"with_#{field}"
118
+ chat.public_send(method, value) if chat.respond_to?(method)
119
+ end
120
+ end
121
+
122
+
123
+ # Build a RunConfig from prompt_manager front matter metadata.
124
+ #
125
+ # @param metadata [Object] a PM::Metadata object (responds to field names)
126
+ # @return [RunConfig]
127
+ def self.from_front_matter(metadata)
128
+ fields = {}
129
+
130
+ LLM_FIELDS.each do |key|
131
+ value = metadata.respond_to?(key) ? metadata.send(key) : nil
132
+ fields[key] = value if value
133
+ end
134
+
135
+ # Extract tool-related fields
136
+ %i[mcp tools].each do |key|
137
+ value = metadata.respond_to?(key) ? metadata.send(key) : nil
138
+ fields[key] = value if value
139
+ end
140
+
141
+ new(**fields)
142
+ end
143
+
144
+
145
+ # @return [Boolean] true if no fields have been set
146
+ def empty?
147
+ @fields.empty?
148
+ end
149
+
150
+
151
+ # @param field [Symbol] the field name
152
+ # @return [Boolean] true if the field has been explicitly set
153
+ def key?(field)
154
+ @fields.key?(field)
155
+ end
156
+
157
+
158
+ # @param other [RunConfig] the other RunConfig to compare
159
+ # @return [Boolean]
160
+ def ==(other)
161
+ other.is_a?(RunConfig) && to_h == other.to_h
162
+ end
163
+
164
+
165
+ # @return [String]
166
+ def inspect
167
+ "#<#{self.class} #{@fields.inspect}>"
168
+ end
169
+
170
+ private
171
+
172
+ # Validates and stores a field value. Nil removes the key.
173
+ def set(field, value)
174
+ field = field.to_sym
175
+ raise ArgumentError, "Unknown RunConfig field: #{field}" unless FIELDS.include?(field)
176
+
177
+ if value.nil?
178
+ @fields.delete(field)
179
+ else
180
+ @fields[field] = value
181
+ end
182
+ end
183
+ end
184
+ end
@@ -16,6 +16,8 @@ module RobotLab
16
16
  # proxy.to_h # => { count: 1, name: "test" }
17
17
  #
18
18
  class StateProxy
19
+ include Utils
20
+
19
21
  # Creates a new StateProxy.
20
22
  #
21
23
  # @param data [Hash] the initial data
@@ -172,17 +174,5 @@ module RobotLab
172
174
  "#<RobotLab::StateProxy #{@data.inspect}>"
173
175
  end
174
176
 
175
- private
176
-
177
- def deep_dup(obj)
178
- case obj
179
- when Hash
180
- obj.transform_values { |v| deep_dup(v) }
181
- when Array
182
- obj.map { |v| deep_dup(v) }
183
- else
184
- obj.dup rescue obj
185
- end
186
- end
187
177
  end
188
178
  end
@@ -56,7 +56,7 @@ module RobotLab
56
56
  begin
57
57
  @publish.call(chunk)
58
58
  rescue StandardError => e
59
- RobotLab.configuration.logger.warn("Streaming error: #{e.message}")
59
+ RobotLab.config.logger&.warn("Streaming error: #{e.message}")
60
60
  end
61
61
 
62
62
  chunk
@@ -39,13 +39,14 @@ module RobotLab
39
39
  # @param tools [Symbol, Array] tools config (:none, :inherit, or array)
40
40
  # @param memory [Memory, Hash, nil] task-specific memory
41
41
  #
42
- def initialize(name:, robot:, context: {}, mcp: :none, tools: :none, memory: nil)
42
+ def initialize(name:, robot:, context: {}, mcp: :none, tools: :none, memory: nil, config: nil)
43
43
  @name = name.to_sym
44
44
  @robot = robot
45
45
  @context = context
46
46
  @mcp = mcp
47
47
  @tools = tools
48
48
  @memory = memory
49
+ @config = config
49
50
  end
50
51
 
51
52
  # SimpleFlow step interface
@@ -68,6 +69,12 @@ module RobotLab
68
69
  run_params[:tools] = @tools unless @tools == :none
69
70
  run_params[:memory] = @memory if @memory
70
71
 
72
+ # Merge task's config on top of network's config
73
+ if @config
74
+ network_rc = run_params[:network_config]
75
+ run_params[:network_config] = network_rc ? network_rc.merge(@config) : @config
76
+ end
77
+
71
78
  # Create enhanced result with merged params
72
79
  enhanced_result = result.with_context(:run_params, run_params)
73
80
 
@@ -1,222 +1,158 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- # Defines a tool/function that robots can use
4
+ # A tool that robots can use, built on RubyLLM::Tool.
5
5
  #
6
- # Tools are capabilities that robots can invoke during execution.
7
- # They have a name, description, parameter schema, and handler.
6
+ # Provides two patterns for defining tools:
8
7
  #
9
- # @example Simple tool
10
- # tool = Tool.new(
11
- # name: "get_time",
12
- # description: "Get the current time",
13
- # handler: -> (_input, **_opts) { Time.now.to_s }
14
- # )
8
+ # 1. **Subclass pattern** — for reusable, robot-aware tools:
9
+ #
10
+ # class GetWeather < RobotLab::Tool
11
+ # description "Get weather for a location"
12
+ # param :location, type: "string", desc: "City name"
15
13
  #
16
- # @example Tool with parameters (using ruby_llm-schema)
17
- # class WeatherParams < RubyLLM::Schema
18
- # string :location, description: "City name"
19
- # string :unit, enum: %w[celsius fahrenheit], required: false
14
+ # def execute(location:)
15
+ # WeatherService.fetch(location)
16
+ # end
20
17
  # end
21
18
  #
22
- # tool = Tool.new(
23
- # name: "get_weather",
24
- # description: "Get weather for a location",
25
- # parameters: WeatherParams,
26
- # handler: ->(input, **opts) {
27
- # # input[:location], input[:unit] are validated
28
- # fetch_weather(input[:location], input[:unit] || "celsius")
29
- # }
30
- # )
19
+ # 2. **Factory pattern** — for dynamic/inline tools:
20
+ #
21
+ # tool = RobotLab::Tool.create(
22
+ # name: "get_time",
23
+ # description: "Get the current time"
24
+ # ) { |args| Time.now.to_s }
31
25
  #
32
- class Tool
33
- # @!attribute [r] name
34
- # @return [String] the unique identifier for the tool
35
- # @!attribute [r] description
36
- # @return [String, nil] a description of what the tool does
37
- # @!attribute [r] parameters
38
- # @return [Class, Hash, nil] the parameter schema (RubyLLM::Schema or JSON Schema hash)
39
- # @!attribute [r] handler
40
- # @return [Proc, nil] the callable that executes the tool logic
26
+ # Subclasses have access to the owning +robot+ via an accessor,
27
+ # enabling tools that modify their robot's state (temperature,
28
+ # system prompt, spawning, etc.).
29
+ #
30
+ class Tool < RubyLLM::Tool
31
+ # @!attribute [rw] robot
32
+ # @return [Robot, nil] the robot that owns this tool
33
+ attr_accessor :robot
34
+
41
35
  # @!attribute [r] mcp
42
36
  # @return [String, nil] the MCP server name if this is an MCP-provided tool
43
- # @!attribute [r] strict
44
- # @return [Boolean, nil] whether strict mode is enabled
45
- attr_reader :name, :description, :parameters, :handler, :mcp, :strict
37
+ attr_reader :mcp
46
38
 
47
39
  # Creates a new Tool instance.
48
40
  #
49
- # @param name [String] the unique identifier for the tool
50
- # @param description [String, nil] a description of what the tool does
51
- # @param parameters [Class, Hash, nil] parameter schema (RubyLLM::Schema or JSON Schema)
52
- # @param handler [Proc, nil] the callable that executes the tool logic
53
- # @param mcp [String, nil] MCP server name if this is an MCP tool
54
- # @param strict [Boolean, nil] whether strict mode is enabled
55
- # @yield [input, **opts] optional block as handler
56
- #
57
- # @example Tool with block handler
58
- # Tool.new(name: "greet", description: "Greet user") do |input, **opts|
59
- # "Hello, #{input[:name]}!"
60
- # end
61
- def initialize(name:, description: nil, parameters: nil, handler: nil, mcp: nil, strict: nil, &block)
62
- @name = name.to_s
63
- @description = description
64
- @parameters = parameters
65
- @handler = handler || block
66
- @mcp = mcp
67
- @strict = strict
41
+ # @param robot [Robot, nil] the owning robot
42
+ def initialize(robot: nil)
43
+ super()
44
+ @robot = robot
68
45
  end
69
46
 
70
- # Execute the tool with input and context
47
+ # Override name to support explicit names for dynamic/MCP tools.
71
48
  #
72
- # Supports two calling conventions:
73
- # - Direct: tool.call(input, robot: robot, network: network)
74
- # - ruby_llm: tool.call(args) - called without keyword args
75
- #
76
- # @param input [Hash] The input parameters (validated against schema)
77
- # @param robot [Robot, nil] The robot invoking this tool
78
- # @param network [NetworkRun, nil] The network context if running in a network
79
- # @param step [Object, nil] Durable execution step context
80
- # @return [Object] The tool's output
49
+ # @return [String] the tool name
50
+ def name
51
+ defined?(@custom_name) && @custom_name ? @custom_name : super
52
+ end
53
+
54
+ # Check if this is an MCP-provided tool.
81
55
  #
82
- def call(input, robot: nil, network: nil, step: nil)
83
- raise Error, "Tool '#{name}' has no handler defined" unless handler
56
+ # @return [Boolean]
57
+ def mcp?
58
+ !@mcp.nil?
59
+ end
84
60
 
85
- validated_input = validate_input(input)
86
- handler.call(validated_input, robot: robot, network: network, step: step)
87
- rescue Error
88
- raise
89
- rescue StandardError => e
90
- { error: Errors.serialize(e) }
61
+ # Factory for dynamic tools (MCP wrappers, inline tools).
62
+ #
63
+ # @param name [String, Symbol] the tool name
64
+ # @param description [String, nil] what the tool does
65
+ # @param parameters [Hash, nil] JSON Schema parameter definition
66
+ # @param mcp [String, nil] MCP server name
67
+ # @param robot [Robot, nil] the owning robot
68
+ # @yield [args] block that executes the tool logic
69
+ # @return [Tool] a new tool instance
70
+ #
71
+ # @example Simple factory tool
72
+ # tool = RobotLab::Tool.create(
73
+ # name: "get_time",
74
+ # description: "Get the current time"
75
+ # ) { |args| Time.now.to_s }
76
+ #
77
+ # @example MCP tool wrapper
78
+ # tool = RobotLab::Tool.create(
79
+ # name: "search",
80
+ # description: "Search the web",
81
+ # parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
82
+ # mcp: "brave_search"
83
+ # ) { |args| mcp_client.call_tool("search", args) }
84
+ #
85
+ def self.create(name:, description: nil, parameters: nil, mcp: nil, robot: nil, &handler)
86
+ desc_text = description
87
+ params_hash = parameters
88
+ block = handler
89
+
90
+ tool_class = Class.new(self) do
91
+ description(desc_text) if desc_text
92
+
93
+ if params_hash.is_a?(Hash) && params_hash[:properties]
94
+ required_list = Array(params_hash[:required]).map(&:to_s)
95
+ params_hash[:properties].each do |pname, pdef|
96
+ param pname.to_sym,
97
+ type: pdef[:type] || "string",
98
+ desc: pdef[:description],
99
+ required: required_list.include?(pname.to_s)
100
+ end
101
+ end
102
+
103
+ define_method(:execute) do |**args|
104
+ block.call(args)
105
+ end
106
+ end
107
+
108
+ instance = tool_class.new(robot: robot)
109
+ instance.instance_variable_set(:@custom_name, name.to_s)
110
+ instance.instance_variable_set(:@mcp, mcp)
111
+ instance
91
112
  end
92
113
 
93
- # Convert to JSON Schema for LLM function calling
114
+ # Convert to JSON Schema for LLM function calling.
115
+ # Used by RobotLab adapters for provider-specific formatting.
94
116
  #
95
117
  # @return [Hash] JSON Schema representation
96
- #
97
118
  def to_json_schema
98
- schema = if parameters.respond_to?(:to_json_schema)
99
- # ruby_llm-schema class
100
- parameters.new.to_json_schema[:schema]
101
- elsif parameters.is_a?(Hash)
102
- # Raw JSON schema
103
- parameters
104
- else
105
- # No parameters
106
- { type: "object", properties: {}, required: [] }
107
- end
108
-
119
+ schema = params_schema || { "type" => "object", "properties" => {}, "required" => [] }
109
120
  {
110
121
  name: name,
111
122
  description: description,
112
- parameters: schema
123
+ parameters: deep_symbolize_keys(schema)
113
124
  }.compact
114
125
  end
115
126
 
116
- # Convert to ruby_llm Tool class for integration
117
- #
118
- # @return [Class] A RubyLLM::Tool subclass
119
- #
120
- def to_ruby_llm_tool
121
- tool = self
122
- Class.new(RubyLLM::Tool) do
123
- description tool.description
124
-
125
- # Define parameters from schema
126
- if tool.parameters.respond_to?(:to_json_schema)
127
- schema = tool.parameters.new.to_json_schema[:schema]
128
- schema[:properties]&.each do |prop_name, prop_def|
129
- param prop_name.to_sym,
130
- type: prop_def[:type],
131
- desc: prop_def[:description],
132
- required: schema[:required]&.include?(prop_name.to_s)
133
- end
134
- elsif tool.parameters.is_a?(Hash) && tool.parameters[:properties]
135
- tool.parameters[:properties].each do |prop_name, prop_def|
136
- param prop_name.to_sym,
137
- type: prop_def[:type] || "string",
138
- desc: prop_def[:description],
139
- required: tool.parameters[:required]&.include?(prop_name.to_s)
140
- end
141
- end
142
-
143
- define_method(:execute) do |**kwargs|
144
- # This will be overridden at runtime with proper context
145
- kwargs
146
- end
147
- end
148
- end
149
-
150
- # Converts the tool to a hash representation.
127
+ # Hash representation.
151
128
  #
152
- # @return [Hash] a hash containing the tool configuration
129
+ # @return [Hash]
153
130
  def to_h
154
131
  {
155
132
  name: name,
156
133
  description: description,
157
- parameters: parameters_to_hash,
158
- mcp: mcp,
159
- strict: strict
134
+ mcp: mcp
160
135
  }.compact
161
136
  end
162
137
 
163
- # Converts the tool to JSON.
138
+ # JSON representation.
164
139
  #
165
140
  # @param args [Array] arguments passed to to_json
166
- # @return [String] JSON representation of the tool
141
+ # @return [String]
167
142
  def to_json(*args)
168
143
  to_h.to_json(*args)
169
144
  end
170
145
 
171
- # Check if this is an MCP-provided tool
172
- #
173
- # @return [Boolean]
174
- #
175
- def mcp?
176
- !mcp.nil?
177
- end
178
-
179
- # Return parameters schema for ruby_llm compatibility
180
- #
181
- # @return [Hash, nil] JSON Schema for tool parameters
182
- #
183
- def params_schema
184
- if parameters.respond_to?(:to_json_schema)
185
- parameters.new.to_json_schema[:schema]
186
- elsif parameters.is_a?(Hash)
187
- parameters
188
- end
189
- end
190
-
191
- # Provider-specific parameters for ruby_llm compatibility
192
- #
193
- # @return [Hash] Empty hash (no provider-specific params)
194
- #
195
- def provider_params
196
- {}
197
- end
198
-
199
146
  private
200
147
 
201
- def validate_input(input)
202
- return input unless parameters
203
-
204
- input = input.transform_keys(&:to_sym) if input.is_a?(Hash)
205
-
206
- if parameters.respond_to?(:new) && parameters.ancestors.include?(defined?(RubyLLM::Schema) ? RubyLLM::Schema : Object)
207
- # Validate with ruby_llm-schema (if available)
208
- # For now, just pass through
209
- input
148
+ def deep_symbolize_keys(obj)
149
+ case obj
150
+ when Hash
151
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) }
152
+ when Array
153
+ obj.map { |v| deep_symbolize_keys(v) }
210
154
  else
211
- input
212
- end
213
- end
214
-
215
- def parameters_to_hash
216
- if parameters.respond_to?(:to_json_schema)
217
- parameters.new.to_json_schema
218
- elsif parameters.is_a?(Hash)
219
- parameters
155
+ obj
220
156
  end
221
157
  end
222
158
  end
@@ -4,7 +4,7 @@ module RobotLab
4
4
  # Handles hierarchical MCP and tools configuration resolution
5
5
  #
6
6
  # Configuration hierarchy (each level overrides the previous):
7
- # 1. RobotLab.configuration (global)
7
+ # 1. RobotLab.config (global)
8
8
  # 2. Network.new (network scope)
9
9
  # 3. Robot.new (robot definition scope)
10
10
  # 4. robot.run (runtime scope)
@@ -183,22 +183,6 @@ module RobotLab
183
183
  self
184
184
  end
185
185
 
186
- # Convert to hash for JSON Schema
187
- #
188
- # @return [Hash] Map of tool names to their JSON schemas
189
- #
190
- def to_json_schema
191
- @tools.transform_values(&:to_json_schema)
192
- end
193
-
194
- # Convert to array of ruby_llm Tool classes
195
- #
196
- # @return [Array<Class>]
197
- #
198
- def to_ruby_llm_tools
199
- @tools.values.map(&:to_ruby_llm_tool)
200
- end
201
-
202
186
  # Converts the manifest to a hash representation.
203
187
  #
204
188
  # @return [Hash<String, Hash>] map of tool names to their hash representations
@@ -221,11 +205,11 @@ module RobotLab
221
205
  #
222
206
  def self.from_hash(hash)
223
207
  tools = hash.map do |name, config|
224
- Tool.new(
208
+ Tool.create(
225
209
  name: name,
226
210
  description: config[:description],
227
211
  parameters: config[:parameters],
228
- handler: config[:handler]
212
+ &config[:handler]
229
213
  )
230
214
  end
231
215
  new(tools)