robot_lab 0.3.0 → 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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +29 -19
  4. data/lib/robot_lab/errors.rb +4 -0
  5. data/lib/robot_lab/hook_context.rb +9 -2
  6. data/lib/robot_lab/mcp/registry.rb +99 -0
  7. data/lib/robot_lab/mcp/server.rb +47 -83
  8. data/lib/robot_lab/mcp/server_discovery.rb +22 -9
  9. data/lib/robot_lab/mcp.rb +259 -0
  10. data/lib/robot_lab/robot/mcp_management.rb +159 -105
  11. data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
  12. data/lib/robot_lab/robot.rb +72 -99
  13. data/lib/robot_lab/run_config.rb +3 -2
  14. data/lib/robot_lab/tool.rb +8 -23
  15. data/lib/robot_lab/version.rb +1 -1
  16. data/lib/robot_lab.rb +1 -4
  17. metadata +16 -292
  18. data/.architecture/AGENTS.md +0 -32
  19. data/.architecture/config.yml +0 -8
  20. data/.architecture/members.yml +0 -60
  21. data/.architecture/reviews/feature-free-will.md +0 -490
  22. data/.architecture/reviews/overall-codebase.md +0 -427
  23. data/.claude/settings.local.json +0 -57
  24. data/.codex/config.toml +0 -2
  25. data/.envrc +0 -6
  26. data/.github/workflows/deploy-github-pages.yml +0 -52
  27. data/.irbrc +0 -6
  28. data/.loki +0 -65
  29. data/.quality/reek_baseline.txt +0 -43
  30. data/.rubocop.yml +0 -10
  31. data/Archspec.rb +0 -44
  32. data/CLAUDE.md +0 -141
  33. data/COMMITS.md +0 -196
  34. data/Rakefile +0 -193
  35. data/_typos.toml +0 -21
  36. data/agent2agent_review.md +0 -192
  37. data/agentf_improvements.md +0 -253
  38. data/agents.md +0 -14
  39. data/docs/api/core/index.md +0 -147
  40. data/docs/api/core/memory.md +0 -578
  41. data/docs/api/core/network.md +0 -553
  42. data/docs/api/core/result.md +0 -211
  43. data/docs/api/core/robot.md +0 -1667
  44. data/docs/api/core/state.md +0 -145
  45. data/docs/api/core/tool.md +0 -520
  46. data/docs/api/errors.md +0 -323
  47. data/docs/api/hooks.md +0 -469
  48. data/docs/api/index.md +0 -137
  49. data/docs/api/mcp/client.md +0 -387
  50. data/docs/api/mcp/index.md +0 -267
  51. data/docs/api/mcp/server.md +0 -252
  52. data/docs/api/mcp/transports.md +0 -339
  53. data/docs/api/messages/index.md +0 -126
  54. data/docs/api/messages/text-message.md +0 -170
  55. data/docs/api/messages/tool-call-message.md +0 -206
  56. data/docs/api/messages/tool-result-message.md +0 -242
  57. data/docs/api/messages/user-message.md +0 -232
  58. data/docs/api/skills.md +0 -423
  59. data/docs/api/streaming/context.md +0 -280
  60. data/docs/api/streaming/events.md +0 -185
  61. data/docs/api/streaming/index.md +0 -156
  62. data/docs/api/support.md +0 -846
  63. data/docs/architecture/core-concepts.md +0 -575
  64. data/docs/architecture/index.md +0 -252
  65. data/docs/architecture/message-flow.md +0 -366
  66. data/docs/architecture/network-orchestration.md +0 -478
  67. data/docs/architecture/robot-execution.md +0 -396
  68. data/docs/architecture/state-management.md +0 -410
  69. data/docs/assets/css/custom.css +0 -56
  70. data/docs/assets/images/robot_lab.jpg +0 -0
  71. data/docs/concepts.md +0 -641
  72. data/docs/examples/basic-chat.md +0 -258
  73. data/docs/examples/index.md +0 -425
  74. data/docs/examples/mcp-server.md +0 -403
  75. data/docs/examples/multi-robot-network.md +0 -476
  76. data/docs/examples/tool-usage.md +0 -468
  77. data/docs/getting-started/configuration.md +0 -645
  78. data/docs/getting-started/index.md +0 -56
  79. data/docs/getting-started/installation.md +0 -197
  80. data/docs/getting-started/quick-start.md +0 -293
  81. data/docs/guides/building-robots.md +0 -1187
  82. data/docs/guides/creating-networks.md +0 -736
  83. data/docs/guides/hooks.md +0 -1206
  84. data/docs/guides/index.md +0 -78
  85. data/docs/guides/knowledge.md +0 -219
  86. data/docs/guides/mcp-integration.md +0 -740
  87. data/docs/guides/memory.md +0 -458
  88. data/docs/guides/observability.md +0 -733
  89. data/docs/guides/streaming.md +0 -323
  90. data/docs/guides/using-tools.md +0 -780
  91. data/docs/index.md +0 -280
  92. data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
  93. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
  94. data/examples/.envrc +0 -3
  95. data/examples/01_simple_robot.rb +0 -31
  96. data/examples/02_tools.rb +0 -109
  97. data/examples/03_network.rb +0 -118
  98. data/examples/04_mcp.rb +0 -217
  99. data/examples/05_streaming.rb +0 -137
  100. data/examples/06_prompt_templates.rb +0 -325
  101. data/examples/07_network_memory.rb +0 -313
  102. data/examples/08_llm_config.rb +0 -162
  103. data/examples/09_chaining.rb +0 -251
  104. data/examples/10_memory.rb +0 -315
  105. data/examples/11_network_introspection.rb +0 -239
  106. data/examples/12_message_bus.rb +0 -67
  107. data/examples/13_spawn.rb +0 -85
  108. data/examples/14_rusty_circuit/.envrc +0 -1
  109. data/examples/14_rusty_circuit/.gitignore +0 -1
  110. data/examples/14_rusty_circuit/README.md +0 -1
  111. data/examples/14_rusty_circuit/comic.rb +0 -149
  112. data/examples/14_rusty_circuit/display.rb +0 -217
  113. data/examples/14_rusty_circuit/heckler.rb +0 -65
  114. data/examples/14_rusty_circuit/open_mic.rb +0 -132
  115. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
  116. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
  117. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
  118. data/examples/14_rusty_circuit/scout.rb +0 -165
  119. data/examples/14_rusty_circuit/show.log +0 -234
  120. data/examples/15_memory_network_and_bus/.envrc +0 -1
  121. data/examples/15_memory_network_and_bus/README.md +0 -66
  122. data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
  123. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
  124. data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
  125. data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
  126. data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
  127. data/examples/15_memory_network_and_bus/output/combined_article.md +0 -11
  128. data/examples/15_memory_network_and_bus/output/final_article.md +0 -8
  129. data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
  130. data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
  131. data/examples/15_memory_network_and_bus/output/memory.json +0 -13
  132. data/examples/15_memory_network_and_bus/output/revision_1.md +0 -29
  133. data/examples/15_memory_network_and_bus/output/revision_2.md +0 -50
  134. data/examples/15_memory_network_and_bus/output/revision_3.md +0 -8
  135. data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
  136. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
  137. data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
  138. data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
  139. data/examples/16_writers_room/.envrc +0 -1
  140. data/examples/16_writers_room/display.rb +0 -158
  141. data/examples/16_writers_room/output/.gitignore +0 -4
  142. data/examples/16_writers_room/output/README.md +0 -69
  143. data/examples/16_writers_room/output/opus_001.md +0 -263
  144. data/examples/16_writers_room/output/opus_001_notes.log +0 -470
  145. data/examples/16_writers_room/output/opus_002.md +0 -245
  146. data/examples/16_writers_room/output/opus_002_notes.log +0 -546
  147. data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
  148. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
  149. data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
  150. data/examples/16_writers_room/prompts/writer.md +0 -37
  151. data/examples/16_writers_room/room.rb +0 -186
  152. data/examples/16_writers_room/tools.rb +0 -173
  153. data/examples/16_writers_room/writer.rb +0 -121
  154. data/examples/16_writers_room/writers_room.rb +0 -256
  155. data/examples/17_skills.rb +0 -234
  156. data/examples/18_rails/.envrc +0 -3
  157. data/examples/18_rails/.gitignore +0 -5
  158. data/examples/18_rails/Gemfile +0 -11
  159. data/examples/18_rails/README.md +0 -67
  160. data/examples/18_rails/Rakefile +0 -4
  161. data/examples/18_rails/app/controllers/application_controller.rb +0 -4
  162. data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
  163. data/examples/18_rails/app/jobs/application_job.rb +0 -4
  164. data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
  165. data/examples/18_rails/app/models/application_record.rb +0 -5
  166. data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
  167. data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
  168. data/examples/18_rails/app/robots/chat_robot.rb +0 -25
  169. data/examples/18_rails/app/tools/time_tool.rb +0 -9
  170. data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
  171. data/examples/18_rails/app/views/chat/index.html.erb +0 -67
  172. data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
  173. data/examples/18_rails/bin/dev +0 -7
  174. data/examples/18_rails/bin/rails +0 -6
  175. data/examples/18_rails/bin/setup +0 -15
  176. data/examples/18_rails/config/application.rb +0 -33
  177. data/examples/18_rails/config/cable.yml +0 -2
  178. data/examples/18_rails/config/database.yml +0 -5
  179. data/examples/18_rails/config/environment.rb +0 -4
  180. data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
  181. data/examples/18_rails/config/routes.rb +0 -6
  182. data/examples/18_rails/config.ru +0 -4
  183. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
  184. data/examples/19_token_tracking.rb +0 -138
  185. data/examples/20_circuit_breaker.rb +0 -147
  186. data/examples/21_learning_loop.rb +0 -181
  187. data/examples/22_context_compression.rb +0 -172
  188. data/examples/23_convergence.rb +0 -133
  189. data/examples/24_structured_delegation.rb +0 -151
  190. data/examples/25_history_search/conversation.jsonl +0 -30
  191. data/examples/25_history_search.rb +0 -133
  192. data/examples/26_document_store/api_versioning_adr.md +0 -52
  193. data/examples/26_document_store/incident_postmortem.md +0 -46
  194. data/examples/26_document_store/postgres_runbook.md +0 -49
  195. data/examples/26_document_store/redis_caching_guide.md +0 -48
  196. data/examples/26_document_store/sidekiq_guide.md +0 -51
  197. data/examples/26_document_store.rb +0 -140
  198. data/examples/27_incident_response/README.md +0 -65
  199. data/examples/27_incident_response/incident_response.rb +0 -261
  200. data/examples/28_mcp_discovery.rb +0 -113
  201. data/examples/29_ractor_tools.rb +0 -240
  202. data/examples/30_ractor_network.rb +0 -252
  203. data/examples/31_launch_assessment.rb +0 -246
  204. data/examples/32_newsletter_reader.rb +0 -193
  205. data/examples/33_stock_generator.rb +0 -80
  206. data/examples/33_stock_predictor.rb +0 -327
  207. data/examples/34_agentskills.rb +0 -76
  208. data/examples/35_hooks.rb +0 -266
  209. data/examples/README.md +0 -550
  210. data/examples/common.rb +0 -180
  211. data/examples/prompts/assistant.md +0 -5
  212. data/examples/prompts/audit_trail.md +0 -8
  213. data/examples/prompts/billing.md +0 -10
  214. data/examples/prompts/classifier.md +0 -7
  215. data/examples/prompts/comedian.md +0 -6
  216. data/examples/prompts/comedy_critic.md +0 -10
  217. data/examples/prompts/configurable.md +0 -9
  218. data/examples/prompts/dispatcher.md +0 -12
  219. data/examples/prompts/entity_extractor.md +0 -14
  220. data/examples/prompts/escalation.md +0 -42
  221. data/examples/prompts/frontmatter_mcp_test.md +0 -9
  222. data/examples/prompts/frontmatter_named_test.md +0 -5
  223. data/examples/prompts/frontmatter_tools_test.md +0 -6
  224. data/examples/prompts/general.md +0 -7
  225. data/examples/prompts/github_assistant.md +0 -14
  226. data/examples/prompts/helper.md +0 -4
  227. data/examples/prompts/incident_responder.md +0 -18
  228. data/examples/prompts/keyword_extractor.md +0 -11
  229. data/examples/prompts/llm_config_demo.md +0 -20
  230. data/examples/prompts/order_support.md +0 -35
  231. data/examples/prompts/os_advocate.md +0 -13
  232. data/examples/prompts/os_chief.md +0 -13
  233. data/examples/prompts/os_editor.md +0 -13
  234. data/examples/prompts/parameterized_main_test.md +0 -6
  235. data/examples/prompts/pii_redactor.md +0 -7
  236. data/examples/prompts/product_support.md +0 -37
  237. data/examples/prompts/runbook_protocol.md +0 -12
  238. data/examples/prompts/sentiment_analyzer.md +0 -12
  239. data/examples/prompts/skill_a_test.md +0 -4
  240. data/examples/prompts/skill_b_test.md +0 -4
  241. data/examples/prompts/skill_config_test.md +0 -5
  242. data/examples/prompts/skill_cycle_a_test.md +0 -6
  243. data/examples/prompts/skill_cycle_b_test.md +0 -6
  244. data/examples/prompts/skill_description_test.md +0 -4
  245. data/examples/prompts/skill_leaf_test.md +0 -4
  246. data/examples/prompts/skill_nested_test.md +0 -6
  247. data/examples/prompts/skill_refs_main_test.md +0 -6
  248. data/examples/prompts/skill_self_ref_test.md +0 -6
  249. data/examples/prompts/skill_with_mcp_test.md +0 -9
  250. data/examples/prompts/skill_with_params_test.md +0 -6
  251. data/examples/prompts/skill_with_robot_name_test.md +0 -5
  252. data/examples/prompts/skill_with_tools_test.md +0 -6
  253. data/examples/prompts/sre_compliance.md +0 -10
  254. data/examples/prompts/structured_output.md +0 -7
  255. data/examples/prompts/synthesizer.md +0 -17
  256. data/examples/prompts/technical.md +0 -10
  257. data/examples/prompts/template_with_skills_test.md +0 -6
  258. data/examples/prompts/triage.md +0 -22
  259. data/examples/ruboruby.md +0 -423
  260. data/examples/run_all.rb +0 -60
  261. data/examples/xyzzy.rb +0 -97
  262. data/lib/robot_lab/mcp/client.rb +0 -223
  263. data/lib/robot_lab/mcp/connection_poller.rb +0 -195
  264. data/lib/robot_lab/mcp/transports/base.rb +0 -64
  265. data/lib/robot_lab/mcp/transports/sse.rb +0 -120
  266. data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
  267. data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
  268. data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
  269. data/logfile +0 -8
  270. data/mkdocs.yml +0 -209
  271. data/robot_concurrency.md +0 -38
  272. data/simple_acp_review.md +0 -298
  273. data/temp.md +0 -6
  274. data/tool_manifest_plan.md +0 -155
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # What happens between the model asking for a tool and the tool answering
6
+ # when the tool is not a RobotLab::Tool: MCP tools and plain
7
+ # RubyLLM::Tool instances.
8
+ #
9
+ # - The hook bridge runs the robot's +:tool_call+ hooks around the chat's
10
+ # execution of such tools (RobotLab::Tool runs them itself in #call),
11
+ # and turns an MCP transport failure mid-call into an error result so
12
+ # the model can recover instead of the run aborting.
13
+ # - The settlement loop answers what ruby_llm pauses a run for: tool
14
+ # calls that require approval, MCP input requests (elicitation), and
15
+ # long-running MCP tasks. A run never returns half-finished.
16
+ #
17
+ # Owns: nothing (uses @chat, @tool_approval, @on_tool_approval, @on_input_request)
18
+ # Reads: @name
19
+ module ToolCallGating
20
+ # How long a run waits for long-running MCP tasks before giving up.
21
+ MCP_TASK_TIMEOUT = 300
22
+
23
+ private
24
+
25
+ # Wrap the chat's execute_tool so foreign tools get :tool_call hooks.
26
+ # Prepended on the chat's singleton class, the bridge sits ahead of the
27
+ # per-run doom-loop singleton method (install_doom_loop_detection) and
28
+ # calls through to it with +super+.
29
+ def install_tool_hook_bridge
30
+ robot = self
31
+ bridge = Module.new do
32
+ define_method(:execute_tool) do |tool_call|
33
+ robot.send(:bridge_tool_call, tool_call) { super(tool_call) }
34
+ end
35
+ private :execute_tool
36
+ end
37
+ @chat.singleton_class.prepend(bridge)
38
+ end
39
+
40
+ # Run :tool_call hooks around a chat-executed tool call. Yields to the
41
+ # chat's own execution; the PAUSED sentinel it returns for approvals,
42
+ # input requests and tasks passes through untouched.
43
+ # :reek:TooManyStatements -- context build, hook wrap and the MCP rescue are one guarded dispatch.
44
+ def bridge_tool_call(tool_call)
45
+ tool = @chat.tools[tool_call.name.to_sym]
46
+ return yield if tool.nil? || tool.is_a?(RobotLab::Tool)
47
+
48
+ context = ToolCallHookContext.new(tool: tool, tool_args: tool_call.arguments, robot: self,
49
+ mcp_server: mcp_server_for(tool))
50
+ scope = RobotLab.current_hook_scope || {}
51
+ RobotLab::Hooks.run(:tool_call, context,
52
+ registries: scope[:registries] || hook_registries,
53
+ per_run_hooks: scope[:per_run_hooks]) do
54
+ context.tool_result = yield
55
+ rescue RubyLLM::MCP::Error => e
56
+ context.tool_error = e
57
+ context.tool_result = { error: "#{tool.name}: #{e.message}" }
58
+ end
59
+ context.tool_result
60
+ end
61
+
62
+ # Keep completing the chat until nothing waits on the outside world:
63
+ # decide approvals, answer input requests, wait on tasks, complete again.
64
+ #
65
+ # @param response [RubyLLM::Message] what ask returned
66
+ # @return [RubyLLM::Message] the final response
67
+ def settle_pending_tool_calls(response, &)
68
+ deadline = nil
69
+ while @chat.waiting?
70
+ decide_pending_approvals
71
+ answer_pending_inputs
72
+ deadline = wait_for_mcp_tasks(deadline)
73
+ response = @chat.complete(&)
74
+ end
75
+ response
76
+ end
77
+
78
+ def decide_pending_approvals
79
+ @chat.pending_approvals.each do |tool_call|
80
+ approve_tool_call?(tool_call) ? @chat.approve(tool_call) : @chat.deny(tool_call)
81
+ end
82
+ end
83
+
84
+ # Approval policy: an on_tool_approval Proc decides; else tool_approval
85
+ # :approve allows; else deny with a warning naming the two settings.
86
+ #
87
+ # @param tool_call [RubyLLM::ToolCall]
88
+ # @return [Boolean]
89
+ def approve_tool_call?(tool_call)
90
+ return @on_tool_approval.call(tool_call) ? true : false if @on_tool_approval
91
+ return true if @tool_approval == :approve
92
+
93
+ RobotLab.config.logger.warn(
94
+ "Robot '#{@name}' denied tool call '#{tool_call.name}': it requires approval. " \
95
+ 'Pass tool_approval: :approve or on_tool_approval: to allow it.'
96
+ )
97
+ false
98
+ end
99
+
100
+ def answer_pending_inputs
101
+ @chat.pending_inputs.each { |request| settle_input_request(request) }
102
+ end
103
+
104
+ # An on_input_request Proc answers a request with a Hash of field
105
+ # values (or +true+ to accept a URL request); anything else declines.
106
+ #
107
+ # @param request [RubyLLM::MCP::InputRequest]
108
+ def settle_input_request(request)
109
+ answer = @on_input_request&.call(request)
110
+ case answer
111
+ when Hash then @chat.answer(request, **answer.transform_keys(&:to_sym))
112
+ when true then @chat.answer(request)
113
+ else
114
+ RobotLab.config.logger.warn(
115
+ "Robot '#{@name}' declined an MCP input request (#{request.message}); set on_input_request: to answer it."
116
+ )
117
+ @chat.decline(request)
118
+ end
119
+ end
120
+
121
+ # Sleep one poll interval when tool calls wait on MCP tasks, tracking a
122
+ # deadline across calls so a stuck task cannot hang the run.
123
+ #
124
+ # @param deadline [Float, nil] monotonic time to give up at, once a task has been seen
125
+ # @return [Float, nil] the deadline to carry forward
126
+ def wait_for_mcp_tasks(deadline)
127
+ tasks = @chat.pending_tasks
128
+ return nil if tasks.empty?
129
+
130
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
131
+ deadline ||= now + MCP_TASK_TIMEOUT
132
+ raise_mcp_task_timeout!(tasks) if now > deadline
133
+
134
+ sleep(tasks.filter_map(&:poll_interval).min || 1)
135
+ deadline
136
+ end
137
+
138
+ def raise_mcp_task_timeout!(tasks)
139
+ names = tasks.map { |task| task.tool_call&.name }.compact.join(', ')
140
+ raise MCPError.new("MCP task(s) for #{names} did not finish within #{MCP_TASK_TIMEOUT}s", retryable: true)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'robot/template_rendering'
4
4
  require_relative 'robot/mcp_management'
5
+ require_relative 'robot/tool_call_gating'
5
6
  require_relative 'robot/bus_messaging'
6
7
  require_relative 'robot/history_search'
7
8
  require_relative 'robot/agent_skill_matching'
@@ -51,6 +52,7 @@ module RobotLab
51
52
  class Robot < RubyLLM::Agent
52
53
  include Robot::TemplateRendering
53
54
  include Robot::MCPManagement
55
+ include Robot::ToolCallGating
54
56
  include Robot::BusMessaging
55
57
  include Robot::HistorySearch
56
58
  include Robot::Budget
@@ -74,10 +76,6 @@ module RobotLab
74
76
  # @return [String, nil] inline system prompt (used alone or appended to template)
75
77
  # @!attribute [r] local_tools
76
78
  # @return [Array] the locally defined tools for this robot
77
- # @!attribute [r] mcp_clients
78
- # @return [Hash<String, MCP::Client>] connected MCP clients by server name
79
- # @!attribute [r] mcp_tools
80
- # @return [Array<Tool>] tools discovered from MCP servers
81
79
  # @!attribute [r] memory
82
80
  # @return [Memory] the robot's inherent memory (used when standalone, not in network)
83
81
  # @!attribute [rw] input
@@ -87,7 +85,7 @@ module RobotLab
87
85
  attr_accessor :input, :output
88
86
 
89
87
  attr_reader :name, :description, :template, :system_prompt,
90
- :local_tools, :mcp_clients, :mcp_tools, :memory,
88
+ :local_tools, :memory,
91
89
  :bus, :outbox, :config, :skills, :provider, :hooks,
92
90
  :total_input_tokens, :total_output_tokens, :learnings,
93
91
  :budget_ledger
@@ -133,7 +131,8 @@ module RobotLab
133
131
  auto_compact: @config.auto_compact,
134
132
  compact_threshold: @config.compact_threshold,
135
133
  token_budget: @config.token_budget,
136
- cost_budget: @config.cost_budget
134
+ cost_budget: @config.cost_budget,
135
+ tool_approval: @config.tool_approval
137
136
  }.compact
138
137
  end
139
138
 
@@ -146,12 +145,19 @@ module RobotLab
146
145
  # @param description [String, nil] optional description
147
146
  # @param local_tools [Array] tools defined locally
148
147
  # @param model [String, nil] the LLM model to use
149
- # @param mcp_servers [Array] legacy parameter for MCP server configurations
150
- # @param mcp [Symbol, Array] hierarchical MCP config
148
+ # @param mcp [Symbol, Array] hierarchical MCP config: :none, :inherit, or an Array of
149
+ # config Hashes, RubyLLM::MCP instances and RubyLLM::MCP subclasses (see RobotLab::MCP)
151
150
  # @param tools [Symbol, Array] hierarchical tools config
152
151
  # @param on_tool_call [Proc, nil] callback invoked when a tool is called
153
152
  # @param on_tool_result [Proc, nil] callback invoked when a tool returns a result
154
153
  # @param on_content [Proc, nil] callback invoked with each streaming content chunk
154
+ # @param tool_approval [Symbol, nil] what to do with tool calls that require approval when no
155
+ # on_tool_approval is given: :deny (default) or :approve
156
+ # @param on_tool_approval [Proc, nil] called with each RubyLLM::ToolCall that requires approval;
157
+ # a truthy return approves it
158
+ # @param on_input_request [Proc, nil] called with each RubyLLM::MCP::InputRequest an MCP server
159
+ # raises; return a Hash of field values (or true for a URL request) to answer, anything else declines
160
+ # @param mcp_discovery [Boolean] select only the MCP servers relevant to each message (see MCP::ServerDiscovery)
155
161
  # @param enable_cache [Boolean] whether to enable semantic caching
156
162
  # @param bus [TypedBus::MessageBus, nil] optional message bus for inter-robot communication
157
163
  # @param temperature [Float, nil] controls randomness
@@ -174,12 +180,14 @@ module RobotLab
174
180
  local_tools: [],
175
181
  model: nil,
176
182
  provider: nil,
177
- mcp_servers: [],
178
183
  mcp: :none,
179
184
  tools: :none,
180
185
  on_tool_call: nil,
181
186
  on_tool_result: nil,
182
187
  on_content: nil,
188
+ tool_approval: nil,
189
+ on_tool_approval: nil,
190
+ on_input_request: nil,
183
191
  enable_cache: true,
184
192
  bus: nil,
185
193
  skills: nil,
@@ -210,7 +218,8 @@ module RobotLab
210
218
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
211
219
  on_content: on_content, bus: bus, enable_cache: enable_cache,
212
220
  max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
213
- doom_loop_threshold: doom_loop_threshold, mcp_servers: mcp_servers,
221
+ doom_loop_threshold: doom_loop_threshold, tool_approval: tool_approval,
222
+ on_tool_approval: on_tool_approval, on_input_request: on_input_request,
214
223
  mcp: mcp, tools: tools, config: config
215
224
  )
216
225
 
@@ -319,50 +328,9 @@ module RobotLab
319
328
  self
320
329
  end
321
330
 
322
- # Eagerly connect to configured MCP servers and discover tools.
323
- # Normally MCP connections are lazy (established on first run).
324
- # Call this to connect early, e.g. to display connection status at startup.
325
- #
326
- # @return [self]
327
- def connect_mcp!
328
- resolved_mcp = resolve_mcp_hierarchy(@mcp_config)
329
- ensure_mcp_clients(resolved_mcp) if resolved_mcp.is_a?(Array) && resolved_mcp.any?
330
- self
331
- end
332
-
333
- # Returns server names that failed to connect.
334
- #
335
- # @return [Array<String>]
336
- def failed_mcp_server_names
337
- return [] unless @failed_mcp_configs
338
-
339
- @failed_mcp_configs.keys
340
- end
341
-
342
- # Disconnect all MCP clients and bus channel.
343
- #
344
- # @return [self]
345
- def disconnect
346
- @mcp_clients.each_value(&:disconnect)
347
- teardown_bus_channel if @bus
348
- self
349
- end
350
-
351
- # --- Public APIs for external MCP and history management (A4) ---
352
-
353
- # Inject pre-connected MCP clients and their tools into this robot.
354
- # Used by host applications (e.g. AIA) that manage MCP connections
355
- # externally and need to pass them to robots without re-connecting.
356
- #
357
- # @param clients [Hash<String, MCP::Client>] connected MCP clients by server name
358
- # @param tools [Array<Tool>] tools discovered from the MCP servers
359
- # @return [self]
360
- def inject_mcp!(clients:, tools:)
361
- @mcp_clients = clients
362
- @mcp_tools = tools
363
- @mcp_initialized = true
364
- self
365
- end
331
+ # --- Public APIs for external history management (A4) ---
332
+ # (MCP server management lives in Robot::MCPManagement: #mcp, #with_mcp,
333
+ # #connect_mcp!, #mcp_tools, #failed_mcp_server_names, #disconnect.)
366
334
 
367
335
  # Access the underlying RubyLLM::Chat instance.
368
336
  # Useful for checkpoint/restore operations that need direct
@@ -504,14 +472,6 @@ module RobotLab
504
472
  nil
505
473
  end
506
474
 
507
- # Find an MCP client by server name.
508
- #
509
- # @param server_name [String] the MCP server name
510
- # @return [MCP::Client, nil]
511
- def mcp_client(server_name)
512
- @mcp_clients[server_name]
513
- end
514
-
515
475
  # Add a learning to this robot's accumulation store.
516
476
  #
517
477
  # Deduplicates by bidirectional substring matching: a new learning is
@@ -566,10 +526,10 @@ module RobotLab
566
526
  skills: @skills,
567
527
  system_prompt: system_prompt,
568
528
  local_tools: local_tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s },
569
- mcp_tools: mcp_tools.map(&:name),
570
529
  mcp_config: @mcp_config,
571
530
  tools_config: @tools_config,
572
- mcp_servers: @mcp_clients.keys,
531
+ mcp_servers: mcp.names,
532
+ mcp_failed: (failed_mcp_server_names if @mcp_failures.any?),
573
533
  model: model,
574
534
  config: (@config.empty? ? nil : @config.to_json_hash),
575
535
  bus: @bus ? true : nil
@@ -621,7 +581,8 @@ module RobotLab
621
581
  presence_penalty:, frequency_penalty:, stop:,
622
582
  on_tool_call:, on_tool_result:, on_content:,
623
583
  bus:, enable_cache:, max_tool_rounds:, token_budget:, cost_budget:,
624
- doom_loop_threshold:, mcp_servers:, mcp:, tools:, config:)
584
+ doom_loop_threshold:, tool_approval:, on_tool_approval:, on_input_request:,
585
+ mcp:, tools:, config:)
625
586
  explicit_fields = {
626
587
  model: model, temperature: temperature, top_p: top_p, top_k: top_k,
627
588
  max_tokens: max_tokens, presence_penalty: presence_penalty,
@@ -629,29 +590,33 @@ module RobotLab
629
590
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
630
591
  on_content: on_content, bus: bus, enable_cache: enable_cache,
631
592
  max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
632
- doom_loop_threshold: doom_loop_threshold
593
+ doom_loop_threshold: doom_loop_threshold, tool_approval: tool_approval,
594
+ on_tool_approval: on_tool_approval, on_input_request: on_input_request
633
595
  }.compact
634
596
 
635
- resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
636
- explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
637
- explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
597
+ explicit_fields[:mcp] = mcp unless ToolConfig.none_value?(mcp)
598
+ explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
638
599
 
639
600
  explicit_config = RunConfig.new(**explicit_fields)
640
601
  @config = config ? config.merge(explicit_config) : explicit_config
641
602
  end
642
603
 
643
604
  def extract_config_ivars
644
- @on_tool_call = @config.on_tool_call
645
- @on_tool_result = @config.on_tool_result
646
- @on_content = @config.on_content
647
- @mcp_config = @config.mcp || :none
648
- @tools_config = @config.tools || :none
605
+ @on_tool_call = @config.on_tool_call
606
+ @on_tool_result = @config.on_tool_result
607
+ @on_content = @config.on_content
608
+ @on_tool_approval = @config.on_tool_approval
609
+ @on_input_request = @config.on_input_request
610
+ @tool_approval = @config.tool_approval || :deny
611
+ @mcp_config = @config.mcp || :none
612
+ @tools_config = @config.tools || :none
649
613
  end
650
614
 
651
615
  def initialize_runtime_state
652
- @mcp_clients = {}
653
- @mcp_tools = []
654
- @mcp_initialized = false
616
+ @mcp_registry = MCP::Registry.new
617
+ @mcp_failures = {}
618
+ @mcp_tool_owners = {}.compare_by_identity
619
+ @run_mcp_servers = []
655
620
  @bus = @config.bus
656
621
  @message_counter = 0
657
622
  @outbox = {}
@@ -745,15 +710,17 @@ module RobotLab
745
710
  @chat.after_tool_result do |result|
746
711
  @on_tool_result&.call(result)
747
712
  end
713
+ install_tool_hook_bridge
748
714
  setup_bus_channel if @bus
749
715
  end
750
716
 
751
717
  # Dynamically delegate all with_* methods from @chat, returning self for chaining.
752
718
  # Discovered from the actual Chat class to avoid maintenance sync issues.
719
+ # with_mcp is the robot's own (Robot::MCPManagement), not the chat's.
753
720
  # :reek:NestedIterators -- the inner block is the delegator method body being defined, not an iteration.
754
721
  def define_chat_delegators
755
722
  @chat.class.public_instance_methods(false)
756
- .select { |m| m.start_with?('with_') }
723
+ .select { |m| m.start_with?('with_') && m != :with_mcp }
757
724
  .each do |method_name|
758
725
  define_singleton_method(method_name) do |*args, **kwargs, &block|
759
726
  @chat.public_send(method_name, *args, **kwargs, &block)
@@ -776,16 +743,15 @@ module RobotLab
776
743
  end
777
744
  end
778
745
 
779
- # :reek:TooManyStatements -- resolve/discover/connect/filter/attach is one linear per-turn tool pipeline.
746
+ # :reek:TooManyStatements -- resolve/select/connect/filter/attach is one linear per-turn tool pipeline.
780
747
  def prepare_tools(message:, mcp:, tools:, network:, network_config:)
781
748
  resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
782
749
  resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
783
750
 
784
- if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
785
- resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
786
- end
787
-
788
- ensure_mcp_clients(resolved_mcp)
751
+ # This turn's MCP servers: registered (and connected) on demand, chosen
752
+ # by the cascade and, with mcp_discovery, by relevance to the message.
753
+ @run_mcp_servers = servers_for(resolved_mcp, message: message)
754
+ available = @local_tools + collect_mcp_tools(@run_mcp_servers)
789
755
 
790
756
  # An EXPLICIT `:none` (or literal `[]`) means "send zero tools this turn" —
791
757
  # e.g. a relevance filter judged no tool useful for the prompt. That is
@@ -793,18 +759,22 @@ module RobotLab
793
759
  # tool". Both collapse to `[]` once resolved, and `filtered_tools([])`
794
760
  # treats an empty allowlist as "all tools", so we must branch on the raw
795
761
  # runtime value before resolution to honor the zero-tools intent.
796
- filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
762
+ filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(available, resolved_tools))
763
+ check_unique_tool_names!(filtered)
797
764
 
798
765
  # Clear-then-add so the chat holds EXACTLY this turn's resolved+capped
799
766
  # set. RubyLLM's with_tools appends; on a persistent chat that lets tools
800
767
  # accumulate across turns, so a capped per-turn addition could still push
801
- # the chat's total past the provider limit. with_tools(nil) is ruby_llm
802
- # 2.0's "clear the tool set"; an explicit none stops there, sending zero
803
- # tools this turn.
804
- return unless filtered.any? || explicit_none_tools?(tools)
768
+ # the chat's total past the provider limit. with_tools(nil) is ruby_llm's
769
+ # "clear the tool set"; an explicit none stops there, sending zero tools
770
+ # this turn. MCP tools a previous turn attached are cleared too, so a
771
+ # turn that selects no MCP servers sends none of their tools.
772
+ return unless filtered.any? || explicit_none_tools?(tools) || mcp_tools_attached?
805
773
 
774
+ deferred, offered = filtered.partition { |tool| deferred_mcp_tool?(tool) }
806
775
  @chat.with_tools(nil)
807
- @chat.with_tools(*filtered) if filtered.any?
776
+ @chat.with_tools(*offered) if offered.any?
777
+ @chat.with_tools(*deferred, defer: true) if deferred.any?
808
778
  end
809
779
 
810
780
  # True when the runtime tools value explicitly requests zero tools — `:none`
@@ -872,7 +842,8 @@ module RobotLab
872
842
  install_doom_loop_detection
873
843
  ask_kwargs = kwargs.slice(:with)
874
844
  streaming = effective_streaming_block(block)
875
- ask(effective_message, **ask_kwargs, &streaming)
845
+ response = ask(effective_message, **ask_kwargs, &streaming)
846
+ settle_pending_tool_calls(response, &streaming)
876
847
  end
877
848
  end
878
849
 
@@ -938,20 +909,22 @@ module RobotLab
938
909
  }
939
910
  end
940
911
 
941
- def all_tools
942
- @local_tools + @mcp_tools
943
- end
944
-
945
- def filtered_tools(allowed_names)
946
- available = all_tools
912
+ # Apply a name allowlist to this turn's available tools; an empty
913
+ # allowlist means "no filter".
914
+ #
915
+ # @param available [Array<RubyLLM::Tool>] local tools plus this turn's MCP tools
916
+ # @param allowed_names [Array<String>]
917
+ # @return [Array<RubyLLM::Tool>]
918
+ def filtered_tools(available, allowed_names)
947
919
  return available if allowed_names.empty?
948
920
 
949
921
  ToolConfig.filter_tools(available, allowed_names: allowed_names)
950
922
  end
951
923
 
952
924
  # Prepend accumulated learnings to a user message when learnings exist.
925
+ # Non-String requests (an MCP prompt, for instance) pass through untouched.
953
926
  def inject_learnings(message)
954
- return message if @learnings.empty? || message.nil?
927
+ return message if @learnings.empty? || !message.is_a?(String)
955
928
 
956
929
  learning_block = "LEARNINGS FROM PREVIOUS RUNS:\n" +
957
930
  @learnings.map { |l| "- #{l}" }.join("\n") +
@@ -991,7 +964,7 @@ module RobotLab
991
964
  # :reek:FeatureEnvy -- checking-then-removing on the chat's singleton class is one atomic operation.
992
965
  def remove_doom_loop_detection
993
966
  sc = @chat.singleton_class
994
- sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool)
967
+ sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool, false)
995
968
  end
996
969
 
997
970
  # Compact conversation history before an ask() call if auto_compact is set.
@@ -39,11 +39,12 @@ module RobotLab
39
39
  TOOL_FIELDS = %i[mcp tools].freeze
40
40
 
41
41
  # Callback fields (Procs)
42
- CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
+ CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content on_tool_approval on_input_request].freeze
43
43
 
44
44
  # Infrastructure fields
45
45
  INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget cost_budget ractor_pool_size
46
- max_concurrent_robots doom_loop_threshold auto_compact compact_threshold max_tools].freeze
46
+ max_concurrent_robots doom_loop_threshold auto_compact compact_threshold max_tools
47
+ tool_approval].freeze
47
48
 
48
49
  # All recognized fields
49
50
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
@@ -27,17 +27,13 @@ module RobotLab
27
27
  # enabling tools that modify their robot's state (temperature,
28
28
  # system prompt, spawning, etc.).
29
29
  #
30
- # :reek:InstanceVariableAssumption -- @custom_name/@mcp are set by Tool.create via instance_variable_set,
30
+ # :reek:InstanceVariableAssumption -- @custom_name is set by Tool.create via instance_variable_set,
31
31
  # @raise_on_error/@ractor_safe are class-level DSL ivars; every read is defined?-guarded.
32
32
  class Tool < RubyLLM::Tool
33
33
  # @!attribute [rw] robot
34
34
  # @return [Robot, nil] the robot that owns this tool
35
35
  attr_accessor :robot
36
36
 
37
- # @!attribute [r] mcp
38
- # @return [String, nil] the MCP server name if this is an MCP-provided tool
39
- attr_reader :mcp
40
-
41
37
  class << self
42
38
  # When true, exceptions from #execute propagate instead of being caught.
43
39
  # Default: false (graceful error handling).
@@ -111,26 +107,18 @@ module RobotLab
111
107
  context.tool_result
112
108
  end
113
109
 
114
- # Override name to support explicit names for dynamic/MCP tools.
110
+ # Override name to support explicit names for dynamic tools.
115
111
  #
116
112
  # @return [String] the tool name
117
113
  def name
118
114
  defined?(@custom_name) && @custom_name ? @custom_name : super
119
115
  end
120
116
 
121
- # Check if this is an MCP-provided tool.
122
- #
123
- # @return [Boolean]
124
- def mcp?
125
- !@mcp.nil?
126
- end
127
-
128
- # Factory for dynamic tools (MCP wrappers, inline tools).
117
+ # Factory for dynamic (inline) tools.
129
118
  #
130
119
  # @param name [String, Symbol] the tool name
131
120
  # @param description [String, nil] what the tool does
132
121
  # @param parameters [Hash, nil] JSON Schema parameter definition
133
- # @param mcp [String, nil] MCP server name
134
122
  # @param robot [Robot, nil] the owning robot
135
123
  # @yield [args] block that executes the tool logic
136
124
  # @return [Tool] a new tool instance
@@ -141,16 +129,15 @@ module RobotLab
141
129
  # description: "Get the current time"
142
130
  # ) { |args| Time.now.to_s }
143
131
  #
144
- # @example MCP tool wrapper
132
+ # @example With a parameter schema
145
133
  # tool = RobotLab::Tool.create(
146
134
  # name: "search",
147
135
  # description: "Search the web",
148
- # parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
149
- # mcp: "brave_search"
150
- # ) { |args| mcp_client.call_tool("search", args) }
136
+ # parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }
137
+ # ) { |args| WebSearch.run(args[:q]) }
151
138
  #
152
139
  # :reek:TooManyStatements -- anonymous-class factory: DSL application and instance wiring form one linear build.
153
- def self.create(name:, description: nil, parameters: nil, mcp: nil, robot: nil, &handler)
140
+ def self.create(name:, description: nil, parameters: nil, robot: nil, &handler)
154
141
  desc_text = description
155
142
  params_hash = parameters
156
143
  block = handler
@@ -169,7 +156,6 @@ module RobotLab
169
156
 
170
157
  instance = tool_class.new(robot: robot)
171
158
  instance.instance_variable_set(:@custom_name, name.to_s)
172
- instance.instance_variable_set(:@mcp, mcp)
173
159
  instance
174
160
  end
175
161
 
@@ -192,8 +178,7 @@ module RobotLab
192
178
  def to_h
193
179
  {
194
180
  name: name,
195
- description: description,
196
- mcp: mcp
181
+ description: description
197
182
  }.compact
198
183
  end
199
184
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/robot_lab.rb CHANGED
@@ -53,10 +53,7 @@ loader.ignore("#{__dir__}/robot_lab/names.rb")
53
53
  loader.inflector.inflect(
54
54
  'robot_lab' => 'RobotLab',
55
55
  'mcp' => 'MCP',
56
- 'openai' => 'OpenAI',
57
- 'sse' => 'SSE',
58
- 'streamable_http' => 'StreamableHTTP',
59
- 'websocket' => 'WebSocket'
56
+ 'openai' => 'OpenAI'
60
57
  )
61
58
 
62
59
  loader.setup