robot_lab 0.2.1 → 0.2.7

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -0,0 +1,846 @@
1
+ # Support Classes
2
+
3
+ Method-level reference for the classes that back RobotLab's core behaviors but
4
+ that you rarely construct yourself. The features they implement are explained in
5
+ the guides; this page is the API surface, so nothing public is left undocumented.
6
+
7
+ | Class | Backs | Guide |
8
+ |-------|-------|-------|
9
+ | [`Task`](#robotlabtask) | Per-task config in a network pipeline | [Creating Networks](../guides/creating-networks.md) |
10
+ | [`Runnable`](#robotlabrunnable) | The shared `Robot`/`Network` interface | [Core Concepts](../architecture/core-concepts.md#runnable-protocol) |
11
+ | [`ToolConfig`](#robotlabtoolconfig) | `:none` / `:inherit` / array resolution | [Using Tools](../guides/using-tools.md) |
12
+ | [`ToolManifest`](#robotlabtoolmanifest) | Name-keyed tool collection | [Using Tools](../guides/using-tools.md) |
13
+ | [`Budget::Ledger`](#robotlabbudgetledger) | `token_budget` / `cost_budget` | [Budgets](../guides/observability.md#budgets-token-cost) |
14
+ | [`DoomLoopDetector`](#robotlabdoomloopdetector) | Always-on tool-loop detection | [Doom Loop Detection](../guides/observability.md#doom-loop-detection) |
15
+ | [`HistoryCompressor`](#robotlabhistorycompressor) | `compress_history` / `auto_compact` | [Context Compression](../guides/observability.md#context-window-compression) |
16
+ | [`Convergence`](#robotlabconvergence) | Agreement detection between two texts | [Convergence Detection](../guides/observability.md#convergence-detection) |
17
+ | [`TextAnalysis`](#robotlabtextanalysis) | TF/TF-IDF primitives under all of the above | — |
18
+ | [`DelegationFuture`](#robotlabdelegationfuture) | `delegate(async: true)` | [Structured Delegation](../guides/observability.md#structured-delegation) |
19
+ | [`RobotMessage`](#robotlabrobotmessage) | Bus message envelope | [Core Concepts](../architecture/core-concepts.md) |
20
+ | [`BusPoller`](#robotlabbuspoller) | Per-robot bus delivery serialization | [Creating Networks](../guides/creating-networks.md) |
21
+ | [`Waiter`](#robotlabwaiter) | `memory.get(wait:)` blocking reads | [Memory](../guides/memory.md) |
22
+ | [`Narrator`](#robotlabnarrator) | Live console narration | [Live Narration](../guides/observability.md#live-narration-robotlabnarrator) |
23
+ | [`Config`](#robotlabconfig) | `RobotLab.config` | [Configuration](../getting-started/configuration.md) |
24
+ | [`MCP::ServerDiscovery`](#robotlabmcpserverdiscovery) | `mcp_discovery: true` | [MCP Integration](../guides/mcp-integration.md) |
25
+ | [`MCP::ConnectionPoller`](#robotlabmcpconnectionpoller) | Multiplexed stdio MCP I/O | [Transports](mcp/transports.md) |
26
+ | [`Streaming::SequenceCounter`](#robotlabstreamingsequencecounter) | Event ordering (unused by core) | [Streaming](streaming/index.md) |
27
+
28
+ ---
29
+
30
+ ## RobotLab::Task
31
+
32
+ Wraps a `Robot` as a SimpleFlow pipeline step, carrying per-task context, MCP,
33
+ tools, memory, and config. `Network#task` builds one for you — you would only
34
+ construct one directly when driving a `SimpleFlow::Pipeline` yourself.
35
+
36
+ ### Constructor
37
+
38
+ ```ruby
39
+ RobotLab::Task.new(name:, robot:, context: {}, mcp: :none, tools: :none,
40
+ memory: nil, config: nil, network: nil)
41
+ ```
42
+
43
+ | Name | Type | Default | Description |
44
+ |------|------|---------|-------------|
45
+ | `name` | `Symbol` | **required** | Task name; symbolized |
46
+ | `robot` | `Robot` | **required** | The robot to execute |
47
+ | `context` | `Hash` | `{}` | Deep-merged over the run params (nested Hashes merge recursively; Arrays are replaced, not concatenated) |
48
+ | `mcp` | `Symbol`, `Array` | `:none` | Injected into `run_params[:mcp]` — only when not `:none` |
49
+ | `tools` | `Symbol`, `Array` | `:none` | Injected into `run_params[:tools]` — only when not `:none` |
50
+ | `memory` | `Memory`, `Hash`, `nil` | `nil` | Overrides the network's shared memory for this task |
51
+ | `config` | `RunConfig`, `nil` | `nil` | Merged **on top of** any inherited `network_config` |
52
+ | `network` | `Network`, `nil` | `nil` | Owning network; supplies `hooks` and default `memory` |
53
+
54
+ ### name / robot
55
+
56
+ ```ruby
57
+ task.name # => Symbol
58
+ task.robot # => Robot
59
+ ```
60
+
61
+ ### call
62
+
63
+ ```ruby
64
+ task.call(result) # => SimpleFlow::Result
65
+ ```
66
+
67
+ The SimpleFlow step interface. Builds a `TaskHookContext`, runs the `:task` hook
68
+ family against `[RobotLab.hooks, network&.hooks]`, and inside it calls
69
+ `robot.call(enhanced_result)` with the task's configuration merged into
70
+ `run_params`.
71
+
72
+ !!! warning "The `:task` family never sees a robot's own registry"
73
+ Registries are `[RobotLab.hooks, @network&.hooks]` — `robot.hooks` is not
74
+ consulted. A `before_task`/`around_task`/`after_task`/`on_error` handler
75
+ registered with `robot.on` will never fire; use `RobotLab.on` or `network.on`.
76
+
77
+ ### to_h
78
+
79
+ ```ruby
80
+ task.to_h
81
+ # => { name: :billing, robot: "billing_bot", context: {...}, mcp: :none, tools: :none }
82
+ ```
83
+
84
+ `.compact`ed. `memory` appears as `true` when a task-specific memory was supplied
85
+ and is omitted otherwise; the memory object itself is never serialized. `config`
86
+ and `network` are not included at all.
87
+
88
+ ---
89
+
90
+ ## RobotLab::Runnable
91
+
92
+ The shared interface `Robot` and `Network` both implement, so callers can treat
93
+ either uniformly instead of branching on `is_a?(RobotLab::Network)`.
94
+
95
+ **Implementers must provide** `#run(message = nil, **opts)`, `#crew`, and
96
+ optionally override `#network?`. The rest derive.
97
+
98
+ | Method | Default implementation | `Robot` | `Network` |
99
+ |--------|------------------------|---------|-----------|
100
+ | `crew` | raises `NotImplementedError` | `[self]` | `robots.values` |
101
+ | `chief` | `crew.first` | the robot | first robot in pipeline order |
102
+ | `robot_count` | `crew.size` | `1` | number of robots |
103
+ | `network?` | `false` | `false` | `true` |
104
+ | `single?` | `!network?` | `true` | `false` |
105
+
106
+ ```ruby
107
+ def summarize(runnable)
108
+ runnable.run(prompt, mcp: :inherit, tools: :inherit)
109
+ puts "#{runnable.robot_count} robot(s): #{runnable.crew.map(&:name).join(', ')}"
110
+ puts "network!" if runnable.network?
111
+ end
112
+ ```
113
+
114
+ !!! note "`crew` returns robot instances, not `network.robots` keys"
115
+ `network.crew.map(&:name)` yields each **robot's** name, which differs from
116
+ `network.robots.keys` (task names) unless you name each robot after its task.
117
+
118
+ ---
119
+
120
+ ## RobotLab::ToolConfig
121
+
122
+ Resolves the `:none` / `:inherit` / array values used at every level of the tools
123
+ and MCP hierarchy. Module functions.
124
+
125
+ ### NONE_VALUES
126
+
127
+ ```ruby
128
+ RobotLab::ToolConfig::NONE_VALUES # => [nil, [], :none]
129
+ ```
130
+
131
+ All three mean "nothing at this level".
132
+
133
+ ### resolve
134
+
135
+ ```ruby
136
+ RobotLab::ToolConfig.resolve(value, parent_value:) # => Array
137
+ ```
138
+
139
+ | `value` | Result |
140
+ |---------|--------|
141
+ | `:inherit` | `Array(parent_value)` |
142
+ | `nil`, `[]`, `:none` | `[]` |
143
+ | anything else | `Array(value)` |
144
+
145
+ ### resolve_mcp / resolve_tools
146
+
147
+ ```ruby
148
+ RobotLab::ToolConfig.resolve_mcp(value, parent_value:) # => Array
149
+ RobotLab::ToolConfig.resolve_tools(value, parent_value:) # => Array<String>
150
+ ```
151
+
152
+ `resolve_mcp` is `resolve` verbatim (server configs stay as-is).
153
+ `resolve_tools` additionally maps every entry through `to_s`, which is why a
154
+ tools allowlist is matched against `tool.name.to_s` and a Symbol entry works
155
+ interchangeably with a String.
156
+
157
+ ### none_value? / inherit_value?
158
+
159
+ ```ruby
160
+ RobotLab::ToolConfig.none_value?(:none) # => true
161
+ RobotLab::ToolConfig.none_value?([]) # => true
162
+ RobotLab::ToolConfig.inherit_value?(:inherit) # => true
163
+ ```
164
+
165
+ `Robot#build_effective_config` uses `none_value?` to decide whether an
166
+ `mcp:`/`tools:` kwarg is worth storing on the `RunConfig` at all.
167
+
168
+ ### filter_tools
169
+
170
+ ```ruby
171
+ RobotLab::ToolConfig.filter_tools(tools, allowed_names: %w[order_lookup])
172
+ # => Array — the subset whose names match
173
+ ```
174
+
175
+ !!! warning "An empty allowlist means *no* tools here"
176
+ `filter_tools(tools, allowed_names: [])` returns `[]`, **not** all tools.
177
+ `Robot#filtered_tools` short-circuits before reaching this method when the
178
+ allowlist is empty (treating it as "no filter"), which is why `Robot#run`
179
+ has to branch on the *raw* runtime value to honor an explicit `tools: :none`.
180
+ See [Robot: run](core/robot.md#run).
181
+
182
+ Names are extracted with `tool.name.to_s` for objects, and used verbatim for
183
+ String/Symbol entries — so a class-attached tool matches `"RefundTool"` while an
184
+ instance-attached one matches its declared `"refund"`.
185
+
186
+ ---
187
+
188
+ ## RobotLab::ToolManifest
189
+
190
+ A name-keyed collection of tools, including `Enumerable`. Not used on the hot
191
+ path by `Robot` (which keeps plain Arrays), but available for applications
192
+ managing tool catalogs.
193
+
194
+ ```ruby
195
+ manifest = RobotLab::ToolManifest.new([weather_tool, calculator_tool])
196
+ manifest[:get_weather] # => Tool
197
+ manifest.names # => ["get_weather", "calculate"]
198
+ ```
199
+
200
+ Tools are keyed by `tool.name.to_s`, and every lookup coerces its argument with
201
+ `to_s` — so `manifest[:get_weather]` and `manifest["get_weather"]` are the same
202
+ entry.
203
+
204
+ | Method | Returns | Description |
205
+ |--------|---------|-------------|
206
+ | `ToolManifest.new(tools = [])` | `ToolManifest` | Each tool is added via `add` |
207
+ | `add(tool)` | `self` | Register a tool under its `name`. Aliased as `<<` |
208
+ | `[](name)` | `Tool`, `nil` | Lookup; `nil` when absent |
209
+ | `fetch(name)` | `Tool` | Lookup, but **raises `RobotLab::ToolNotFoundError`** listing the available names |
210
+ | `include?(name)` | `Boolean` | Whether that name is registered. Aliased as `has?` |
211
+ | `remove(name)` | `Tool`, `nil` | Delete and return the tool |
212
+ | `replace(tools)` | `self` | **Clears the manifest** and adds `tools` — replaces the whole collection, not one entry |
213
+ | `merge(other)` | `self` | Add every tool from a `ToolManifest`, an `Array`, or a single `Tool`. Mutates the receiver and returns it — it does **not** build a new manifest |
214
+ | `names` | `Array<String>` | Registered names |
215
+ | `values` | `Array<Tool>` | The tools themselves. Aliased as `all` and `to_a` |
216
+ | `each { \|tool\| }` | — | `Enumerable` entry point (yields tools, not pairs) |
217
+ | `size` | `Integer` | Number of tools. Aliased as `count` and `length` |
218
+ | `empty?` | `Boolean` | |
219
+ | `clear` | `self` | Remove everything |
220
+ | `to_h` | `Hash<String, Hash>` | Name => `tool.to_h` |
221
+ | `to_json` | `String` | Serializes `to_h` |
222
+ | `ToolManifest.from_hash(hash)` | `ToolManifest` | Rebuilds each entry with `Tool.create(name:, description:, parameters:, &handler)` |
223
+
224
+ !!! warning "`from_hash` is not the inverse of `to_h`"
225
+ `to_h` emits `Tool#to_h` (`name`, `description`, `mcp`), while `from_hash`
226
+ expects each value to carry `:parameters` and a `:handler` Proc. Round-tripping
227
+ a manifest through `to_h` → `from_hash` yields tools with no parameters and a
228
+ `nil` handler.
229
+
230
+ ---
231
+
232
+ ## RobotLab::Budget::Ledger
233
+
234
+ Thread-safe reserve/reconcile ledger tracking consumption against per-dimension
235
+ limits. `Robot` builds one automatically when `token_budget:` and/or
236
+ `cost_budget:` is configured, and exposes it as `robot.budget_ledger` (`nil`
237
+ otherwise).
238
+
239
+ The reserve-then-reconcile shape exists because an LLM call's size is unknowable
240
+ in advance: `reserve!` claims everything still available so an already-exhausted
241
+ budget is caught *before* spending, and `reconcile!` swaps that claim for the
242
+ actual usage once the response is back.
243
+
244
+ ### Constructor
245
+
246
+ ```ruby
247
+ ledger = RobotLab::Budget::Ledger.new(limits: { tokens: 10_000, cost: 0.50 }, consumed: {})
248
+ ```
249
+
250
+ | Name | Type | Default | Description |
251
+ |------|------|---------|-------------|
252
+ | `limits` | `Hash{Symbol=>Numeric}` | `{}` | Per-dimension ceilings. A dimension **absent** here is unlimited |
253
+ | `consumed` | `Hash{Symbol=>Numeric}` | `{}` | Starting consumption, e.g. restored from a prior session |
254
+
255
+ `Robot` uses the dimensions `:tokens` and `:cost`, but the ledger is generic —
256
+ any Symbol works.
257
+
258
+ ### limits / consumed
259
+
260
+ ```ruby
261
+ ledger.limits # => { tokens: 10_000, cost: 0.5 }
262
+ ledger.consumed # => { tokens: 3_412, cost: 0.081 }
263
+ ```
264
+
265
+ Actual consumption so far, per dimension. `consumed` is backed by a
266
+ `Hash.new(0)`, so an untouched dimension reads `0` rather than `nil`.
267
+
268
+ ### reserve!
269
+
270
+ ```ruby
271
+ ledger.reserve!(:tokens, 6_588)
272
+ ```
273
+
274
+ Claim `amount` against the remaining budget.
275
+
276
+ **Raises `RobotLab::BudgetExceeded`** — `"budget exceeded for tokens: 10588 > 10000"`
277
+ — when `consumed + already_reserved + amount` would exceed the limit. A **no-op
278
+ that never raises** for a dimension with no configured limit.
279
+
280
+ ### reconcile!
281
+
282
+ ```ruby
283
+ ledger.reconcile!(:tokens, reserved_amount, actual_amount)
284
+ ```
285
+
286
+ Release `reserved_amount` from the reservation pool and add `actual_amount` to
287
+ `consumed`. The actual may be larger or smaller than what was reserved. The
288
+ reservation pool is floored at zero, so an over-release cannot make it negative.
289
+
290
+ ### release!
291
+
292
+ ```ruby
293
+ ledger.release!(:tokens, amount)
294
+ ```
295
+
296
+ Drop a reservation **without** recording consumption — for work that was reserved
297
+ and then skipped.
298
+
299
+ ### remaining
300
+
301
+ ```ruby
302
+ ledger.remaining(:tokens) # => Numeric
303
+ ledger.remaining(:unmetered) # => Float::INFINITY
304
+ ```
305
+
306
+ `limit - consumed - reserved`, floored at `0`. Returns `Float::INFINITY` for a
307
+ dimension with no configured limit.
308
+
309
+ ---
310
+
311
+ ## RobotLab::DoomLoopDetector
312
+
313
+ Detects a model stuck calling the same tool — or the same *cycle* of tools — over
314
+ and over. `Robot#run` installs one on every call; see
315
+ [Doom Loop Detection](core/robot.md#doom-loop-detection).
316
+
317
+ ### Constants
318
+
319
+ | Constant | Value | Description |
320
+ |----------|-------|-------------|
321
+ | `DEFAULT_THRESHOLD` | `3` | Repetitions before a loop is declared |
322
+ | `MAX_PERIOD` | `10` | Longest cyclic pattern searched for |
323
+
324
+ ### Constructor / sequence
325
+
326
+ ```ruby
327
+ detector = RobotLab::DoomLoopDetector.new(threshold: 3)
328
+ detector.sequence # => Array<String> — every tracked name, in order
329
+ ```
330
+
331
+ ### track
332
+
333
+ ```ruby
334
+ detector.track("search") # => the sequence array
335
+ ```
336
+
337
+ Append a tool name (coerced with `to_s`). Call once per tool invocation.
338
+
339
+ ### doom_loop?
340
+
341
+ ```ruby
342
+ detector.doom_loop? # => Boolean
343
+ ```
344
+
345
+ `true` when either pattern is present in the tail of the sequence:
346
+
347
+ - **Consecutive** — the last `threshold` entries are all the same name (`A, A, A`).
348
+ - **Cyclic** — the last `threshold × period` entries are exactly `period`-length
349
+ pattern repeated `threshold` times (`A,B,C, A,B,C, A,B,C`), for any period from
350
+ 2 up to `min(MAX_PERIOD, sequence.length / threshold)`.
351
+
352
+ Always `false` while fewer than `threshold` calls have been tracked.
353
+
354
+ ### warning_message
355
+
356
+ ```ruby
357
+ detector.warning_message # => String
358
+ ```
359
+
360
+ The self-correction text embedded in the tool result. `""` for an empty sequence.
361
+ Consecutive loops name the tool; cyclic loops render the pattern as
362
+ `"A → B → C"`. Both close with the same advice to try a fundamentally different
363
+ approach or ask for clarification.
364
+
365
+ ### reset
366
+
367
+ ```ruby
368
+ detector.reset # => []
369
+ ```
370
+
371
+ Clear the sequence. `Robot` calls this immediately after emitting a warning, so
372
+ the same loop is reported once rather than on every subsequent call.
373
+
374
+ !!! note "The detector never raises"
375
+ It only appends a warning to the tool's result — a `String` result gets
376
+ `"\n\n⚠️ <warning>"`, a `Hash` result gains a `:_doom_loop_warning` key. To
377
+ make a loop *fatal*, use `max_tool_rounds:` and its `ToolLoopError`.
378
+
379
+ ---
380
+
381
+ ## RobotLab::HistoryCompressor
382
+
383
+ The algorithm behind [`robot.compress_history`](core/robot.md#compress_history)
384
+ and `auto_compact: :context_window`. Requires the optional `classifier` gem
385
+ (`~> 2.3`).
386
+
387
+ ### Constructor
388
+
389
+ ```ruby
390
+ RobotLab::HistoryCompressor.new(
391
+ messages:, recent_turns:, keep_threshold:, drop_threshold:, summarizer:
392
+ )
393
+ ```
394
+
395
+ All five are **required** keywords — the defaults you see documented
396
+ (`recent_turns: 3`, `keep_threshold: 0.6`, `drop_threshold: 0.2`,
397
+ `summarizer: nil`) live on `Robot#compress_history`, not here.
398
+
399
+ **Raises `ArgumentError`** when `keep_threshold <= drop_threshold`.
400
+
401
+ ### call
402
+
403
+ ```ruby
404
+ compressed = compressor.call # => Array — the new message array
405
+ ```
406
+
407
+ Returns the input **unchanged** — without requiring the `classifier` gem — in any
408
+ of these cases: no messages; nothing scorable; the scorable messages all fit
409
+ inside the recent window (`scorable.size <= recent_turns * 2`); or the recent
410
+ window yields no text long enough to build a reference vector.
411
+
412
+ Otherwise, it builds a mean term-frequency vector from the recent window and
413
+ scores each older message against it:
414
+
415
+ | Score | Action |
416
+ |-------|--------|
417
+ | `>= keep_threshold` | Kept verbatim |
418
+ | `drop_threshold ... keep_threshold` | Passed to `summarizer`; **dropped** when there is no summarizer, or the summary comes back blank |
419
+ | `< drop_threshold` | Dropped |
420
+ | text shorter than `MIN_SCORE_LENGTH` (20 chars) | Kept — too short to score reliably |
421
+
422
+ **Pinned messages are never scored or removed:** system messages, `:tool` and
423
+ `:tool_result` messages, and any assistant message with blank content (a tool-call
424
+ dispatcher — removing one would orphan its `tool_result`).
425
+
426
+ Scoring uses **stemmed term frequencies without IDF**. IDF on a topic-focused
427
+ corpus suppresses exactly the shared terms that signal relevance, so it would
428
+ invert the ranking.
429
+
430
+ ### MIN_SCORE_LENGTH
431
+
432
+ ```ruby
433
+ RobotLab::HistoryCompressor::MIN_SCORE_LENGTH # => 20
434
+ ```
435
+
436
+ ### SUMMARY_STRUCT
437
+
438
+ A minimal `Struct(:role, :content, :tool_calls, :stop_reason)` that duck-types
439
+ enough of `RubyLLM::Message` to sit in a chat's message array: `text?` (always
440
+ `true`), `tool_use?` (always `false`), `system?`, `user?`, `assistant?`. A
441
+ summarized message keeps its **original role**, so user/assistant turn ordering
442
+ survives compression.
443
+
444
+ ---
445
+
446
+ ## RobotLab::Convergence
447
+
448
+ Whether two texts have converged on the same conclusion — for skipping a
449
+ reconciler call when two verifiers already agree. Requires the `classifier` gem.
450
+
451
+ ### Constants
452
+
453
+ | Constant | Value | Description |
454
+ |----------|-------|-------------|
455
+ | `DEFAULT_THRESHOLD` | `0.85` | Similarity at or above which texts are convergent |
456
+ | `MIN_TEXT_LENGTH` | `30` | Characters; shorter texts always score `0.0` |
457
+
458
+ ### detected?
459
+
460
+ ```ruby
461
+ RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.85) # => Boolean
462
+ ```
463
+
464
+ **Raises `ArgumentError`** when `threshold` is outside `[0.0, 1.0]`, and
465
+ `RobotLab::DependencyError` when the `classifier` gem is missing.
466
+
467
+ ### similarity
468
+
469
+ ```ruby
470
+ RobotLab::Convergence.similarity(text_a, text_b) # => Float in [0.0, 1.0]
471
+ ```
472
+
473
+ Stemmed term-frequency cosine similarity. Returns `0.0` when **either** text is
474
+ shorter than `MIN_TEXT_LENGTH` after stripping — so two short but identical
475
+ strings score zero, not one.
476
+
477
+ ---
478
+
479
+ ## RobotLab::TextAnalysis
480
+
481
+ The shared TF/TF-IDF primitives that `Convergence`, `HistoryCompressor`,
482
+ `Robot#search_history`, and `MCP::ServerDiscovery` all sit on. Module functions.
483
+
484
+ | Method | Returns | Description |
485
+ |--------|---------|-------------|
486
+ | `require_classifier!` | — | Loads the `classifier` gem, converting `LoadError` into a `RobotLab::DependencyError` with install instructions. Call it before any other method |
487
+ | `load_classifier_gem` | — | The bare `require "classifier"`, extracted for testability |
488
+ | `fit(corpus)` | `Classifier::TFIDF` | Fit a TF-IDF model (`min_df: 1`) over an Array of document strings |
489
+ | `transform(model, text)` | `Hash{Symbol=>Float}` | L2-normalized sparse term vector; `{}` when no known terms |
490
+ | `cosine_similarity(vec_a, vec_b)` | `Float` in `[0.0, 1.0]` | Dot product (vectors are already normalized), clamped at `1.0`; `0.0` if either is empty |
491
+ | `dot(vec_a, vec_b)` | `Float` | Dot product over shared keys only |
492
+ | `l2_normalize(vec)` | `Hash{Symbol=>Float}` | Normalize a sparse vector; `{}` when the magnitude is zero |
493
+ | `tf_cosine_similarity(text_a, text_b)` | `Float` in `[0.0, 1.0]` | Stemmed term-frequency cosine between two texts — no reference corpus needed, which is why it, not TF-IDF, is used for 2-text comparison |
494
+
495
+ Only `tf_cosine_similarity` calls `require_classifier!` for you; the others assume
496
+ the gem is already loaded.
497
+
498
+ ---
499
+
500
+ ## RobotLab::DelegationFuture
501
+
502
+ The promise returned by `robot.delegate(to:, task:, async: true)`. See
503
+ [Robot: delegate](core/robot.md#delegate).
504
+
505
+ ### Attributes
506
+
507
+ ```ruby
508
+ future.robot_name # => "analyst" — the delegatee
509
+ future.delegated_by # => "manager" — the delegator
510
+ ```
511
+
512
+ ### resolved?
513
+
514
+ ```ruby
515
+ future.resolved? # => Boolean
516
+ ```
517
+
518
+ `true` once the task finished, **whether it succeeded or raised**. Use it to poll
519
+ without blocking.
520
+
521
+ ### value / wait
522
+
523
+ ```ruby
524
+ result = future.value # blocks indefinitely
525
+ result = future.value(timeout: 30) # blocks up to 30s
526
+ result = future.wait # alias for value
527
+ ```
528
+
529
+ Returns the delegatee's `RobotResult`, with `duration` and `delegated_by` already
530
+ set by `delegate`.
531
+
532
+ **Raises `RobotLab::DelegationFuture::DelegationTimeout`** (`"Delegation to 'X'
533
+ timed out after Ns"`) when `timeout:` expires, and **re-raises** whatever the
534
+ delegated task raised. The error is re-raised on every subsequent `value` call —
535
+ the future stays in its failed state.
536
+
537
+ ### resolve! / reject!
538
+
539
+ ```ruby
540
+ future.resolve!(robot_result)
541
+ future.reject!(exception)
542
+ ```
543
+
544
+ Called by `Robot#delegate` from the worker thread to settle the future; both
545
+ broadcast to every blocked `value`. You would only call these when building a
546
+ custom delegation path — settling a future twice silently overwrites the first
547
+ outcome.
548
+
549
+ ---
550
+
551
+ ## RobotLab::RobotMessage
552
+
553
+ Immutable `Data` envelope for TypedBus inter-robot messaging.
554
+
555
+ ### build
556
+
557
+ ```ruby
558
+ msg = RobotLab::RobotMessage.build(id: 1, from: "alice", content: "Hello")
559
+ reply = RobotLab::RobotMessage.build(id: 2, from: "bob", content: "Hi",
560
+ in_reply_to: "alice:1")
561
+ ```
562
+
563
+ Prefer `build` over `new` — it defaults `in_reply_to` to `nil`, which
564
+ `Data.define` does not do for you.
565
+
566
+ | Member | Type | Description |
567
+ |--------|------|-------------|
568
+ | `id` | `Integer` | The **sender's** per-robot counter, not globally unique |
569
+ | `from` | `String` | Sender's robot name, which is also its channel name |
570
+ | `content` | `String`, `Hash` | The payload |
571
+ | `in_reply_to` | `String`, `nil` | The `key` of the message being answered |
572
+
573
+ ### key / reply?
574
+
575
+ ```ruby
576
+ msg.key # => "alice:1" — "#{from}:#{id}"
577
+ msg.reply? # => !in_reply_to.nil?
578
+ ```
579
+
580
+ `key` is the composite identity used for reply correlation: pass it as
581
+ `in_reply_to:` when answering, and the sender's `outbox[key]` entry flips to
582
+ `status: :replied`. `reply?` is what lets `respond_to_tasks` ignore replies and
583
+ avoid an infinite ping-pong between two robots.
584
+
585
+ ---
586
+
587
+ ## RobotLab::BusPoller
588
+
589
+ Serializes bus deliveries per robot. A `Network` creates one and shares it across
590
+ its tasks; a robot with a bus but no network auto-creates a private one.
591
+
592
+ !!! warning "Despite the name, there is no poller thread"
593
+ `start` and `stop` are **no-ops**, `running?` is hard-coded `true`, and
594
+ `enqueue` processes and drains **inline in the caller's own execution
595
+ context** (Async fiber or OS thread). All the class owns is a mutex and a
596
+ per-robot queue. Deliveries make no progress on their own while the calling
597
+ fiber is parked.
598
+
599
+ ### QUEUE_CAPACITY
600
+
601
+ ```ruby
602
+ RobotLab::BusPoller::QUEUE_CAPACITY # => 512
603
+ ```
604
+
605
+ Capacity of each robot's `RactorQueue` of pending deliveries.
606
+
607
+ ### enqueue
608
+
609
+ ```ruby
610
+ poller.enqueue(robot:, delivery:, group: :default) # => void
611
+ ```
612
+
613
+ If the robot is idle, marks it busy and processes the delivery immediately, then
614
+ drains anything that queued up behind it. If the robot is already processing, the
615
+ delivery is pushed onto its queue instead.
616
+
617
+ Errors are contained: a `BusError` (or any `StandardError`) from a handler
618
+ releases the robot's busy flag and is logged at `warn` rather than propagating to
619
+ the publisher.
620
+
621
+ ### add_group / groups
622
+
623
+ ```ruby
624
+ poller.add_group(:slow) # idempotent
625
+ poller.groups # => [:default, :slow]
626
+ ```
627
+
628
+ Poller groups are **informational labels only** — they create no separate queues
629
+ or threads. `Network#task`'s `poller_group:` registers the name here so slow
630
+ robots are identifiable in logs and monitoring.
631
+
632
+ ### start / stop / running?
633
+
634
+ ```ruby
635
+ poller.start # => self (no-op)
636
+ poller.stop # => self (no-op; accepts and ignores any args)
637
+ poller.running? # => true (always)
638
+ ```
639
+
640
+ Retained for API symmetry with the earlier threaded design.
641
+
642
+ ---
643
+
644
+ ## RobotLab::Waiter
645
+
646
+ The blocking primitive behind `memory.get(key, wait:)`. Built on an `IO.pipe`
647
+ pair rather than a `ConditionVariable`, because `IO#wait_readable` yields
648
+ correctly to the Async fiber scheduler while `ConditionVariable#wait` can block
649
+ the whole event loop.
650
+
651
+ | Method | Returns | Description |
652
+ |--------|---------|-------------|
653
+ | `Waiter.new` | `Waiter` | Allocates the pipe pair |
654
+ | `wait(timeout: nil)` | value, or `:timeout` | Blocks until signaled. `nil` timeout waits forever. Returns immediately when already signaled. A closed pipe (`IOError`) also yields `:timeout` |
655
+ | `signal(value)` | — | Stores `value` and wakes every waiter, writing one byte per blocked thread (minimum one, to cover a thread that passed the signaled check but has not yet entered the wait) |
656
+ | `signaled?` | `Boolean` | Whether `signal` has been called |
657
+ | `close` | — | Releases both file descriptors. Call after `wait` returns |
658
+
659
+ Multiple threads may wait on one instance. `Memory#wait_for_key` handles the
660
+ create/wait/close lifecycle and converts a `:timeout` return into
661
+ `RobotLab::AwaitTimeout`.
662
+
663
+ ---
664
+
665
+ ## RobotLab::Narrator
666
+
667
+ The one `RobotLab::Hook` subclass shipped in core: a human-facing console feed of
668
+ what a robot is doing, complementing the persistent record kept by
669
+ `robot_lab-audit`. See
670
+ [Live Narration](../guides/observability.md#live-narration-robotlabnarrator).
671
+
672
+ ### enable!
673
+
674
+ ```ruby
675
+ RobotLab::Narrator.enable! # narrate to $stderr
676
+ RobotLab::Narrator.enable!(output: $stdout)
677
+ # => the Narrator class, so it chains
678
+ ```
679
+
680
+ Sets the output and registers the narrator **globally** via `RobotLab.on(self)`.
681
+ For finer scope, skip `enable!` and register it like any other hook:
682
+ `robot.on(RobotLab::Narrator)` or `network.on(RobotLab::Narrator)` — but set
683
+ `Narrator.output` yourself first if you do not want `$stderr`.
684
+
685
+ ### output / output=
686
+
687
+ ```ruby
688
+ RobotLab::Narrator.output = $stdout
689
+ RobotLab::Narrator.output # => IO, defaulting to $stderr
690
+ ```
691
+
692
+ Narration is written with `IO#puts`, not `Kernel#warn` — `warn` is silenced when
693
+ `$VERBOSE` is `nil`, which is common under `bundle exec`.
694
+
695
+ ### MAX
696
+
697
+ ```ruby
698
+ RobotLab::Narrator::MAX # => 80
699
+ ```
700
+
701
+ Characters before a narrated line is clipped with an ellipsis.
702
+
703
+ ### Hook methods
704
+
705
+ | Hook | Output |
706
+ |------|--------|
707
+ | `before_llm_generation(ctx)` | ` · <robot>: thinking…` |
708
+ | `before_tool_call(ctx)` | ` · → <tool_name> <first_arg>="<clipped value>"` |
709
+ | `after_tool_call(ctx)` | ` · ✗ <clipped error message>` — **only when the call raised** |
710
+
711
+ All three rescue `StandardError` and return `nil`, so narration can never break a
712
+ run.
713
+
714
+ ---
715
+
716
+ ## RobotLab::Config
717
+
718
+ `RobotLab.config` — a `MywayConfig::Base` subclass. For the settings themselves,
719
+ file locations, and precedence rules, see
720
+ [Configuration](../getting-started/configuration.md).
721
+
722
+ | Method | Returns | Description |
723
+ |--------|---------|-------------|
724
+ | `logger` | `Logger` | The configured logger. Defaults to `Rails.logger` under Rails, else `Logger.new($stdout, level: Logger::INFO)` |
725
+ | `logger=` | — | Runtime-only; not read from any config file |
726
+ | `development?` / `test?` / `production?` | `Boolean` | Current-environment predicates, from `MywayConfig::Base` |
727
+ | `after_load` | `void` | Applies the `ruby_llm:` section to RubyLLM and points `prompt_manager` at the resolved template path. `RobotLab.config` calls it once on first construction |
728
+ | `apply_ruby_llm_config!` | `void` | Just the RubyLLM half of `after_load` — provider API keys and endpoints, OpenAI org/project options, default models, connection/retry settings, and logging options. Call it after mutating `config.ruby_llm` at runtime to push the change into RubyLLM |
729
+
730
+ ```ruby
731
+ RobotLab.configure { |c| c.ruby_llm.request_timeout = 300 }
732
+ RobotLab.config.apply_ruby_llm_config! # <- otherwise RubyLLM keeps the old value
733
+ ```
734
+
735
+ API keys fall back to the standard provider environment variables
736
+ (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `AWS_ACCESS_KEY_ID`, …) when the
737
+ corresponding config key is unset, so the `ROBOT_LAB_RUBY_LLM__` prefix is
738
+ optional for credentials.
739
+
740
+ ---
741
+
742
+ ## RobotLab::MCP::ServerDiscovery
743
+
744
+ Narrows a configured MCP server list to the ones relevant to the current message,
745
+ so a robot with many servers connects only what it needs. Activated with
746
+ `mcp_discovery: true` — see [MCP Integration](../guides/mcp-integration.md).
747
+
748
+ ### DEFAULT_THRESHOLD
749
+
750
+ ```ruby
751
+ RobotLab::MCP::ServerDiscovery::DEFAULT_THRESHOLD # => 0.05
752
+ ```
753
+
754
+ Deliberately low: server descriptions are a sentence long, so cosine scores are
755
+ small even for a clearly on-topic query.
756
+
757
+ ### select
758
+
759
+ ```ruby
760
+ RobotLab::MCP::ServerDiscovery.select(query, from: servers, threshold: 0.05)
761
+ # => Array<Hash, MCP::Server>
762
+ ```
763
+
764
+ **Returns `from` unchanged** — connecting everything — whenever discovery cannot
765
+ make a confident call: an empty list, a blank query, no server carrying a
766
+ `description`, no server scoring at or above `threshold`, or the `classifier` gem
767
+ not being installed (`DependencyError` is rescued, not propagated). Discovery can
768
+ therefore only ever *narrow* a run, never break one.
769
+
770
+ ### score / topic_text / description_for / any_descriptions?
771
+
772
+ ```ruby
773
+ ServerDiscovery.score(query, server) # => Float — tf cosine vs. topic_text
774
+ ServerDiscovery.topic_text(server) # => "github GitHub repos, issues, ..."
775
+ ServerDiscovery.description_for(server) # => String ("" when absent)
776
+ ServerDiscovery.any_descriptions?(servers) # => Boolean
777
+ ```
778
+
779
+ All four accept either a config `Hash` (`server[:name]`, `server[:description]`)
780
+ or an `MCP::Server` instance. `topic_text` is `"<name> <description>"` — the name
781
+ participates in matching, so a well-named server scores even with a thin
782
+ description.
783
+
784
+ ---
785
+
786
+ ## RobotLab::MCP::ConnectionPoller
787
+
788
+ Multiplexes I/O across multiple **stdio** MCP transports with a single
789
+ `IO.select` loop, instead of each client blocking independently behind its own
790
+ `Timeout.timeout`. Async-based transports (SSE, WebSocket, StreamableHTTP) are
791
+ unaffected — they already yield to the fiber scheduler. See
792
+ [Transports](mcp/transports.md).
793
+
794
+ Unlike [`BusPoller`](#robotlabbuspoller), this one **does** own a background
795
+ thread.
796
+
797
+ ### POLL_INTERVAL
798
+
799
+ ```ruby
800
+ RobotLab::MCP::ConnectionPoller::POLL_INTERVAL # => 0.1 (seconds)
801
+ ```
802
+
803
+ ### Methods
804
+
805
+ | Method | Returns | Description |
806
+ |--------|---------|-------------|
807
+ | `start` | `self` | Spawn the multiplexing thread (named `RobotLab::MCP::ConnectionPoller`). Idempotent |
808
+ | `stop(timeout: 5)` | `self` | Stop the thread and cancel every pending request with an `MCPError`. Idempotent |
809
+ | `running?` | `Boolean` | Whether the thread is live |
810
+ | `register(client)` | — | Add the client's `transport.stdout` to the select set. **Silently ignores a non-stdio client** |
811
+ | `unregister(client)` | — | Remove it; likewise a no-op for non-stdio clients |
812
+ | `send_request(client, message, timeout:)` | response | Write `message.to_json` to the client's stdin and block on that client's queue for the matching response. `timeout:` is required |
813
+
814
+ ```ruby
815
+ poller = RobotLab::MCP::ConnectionPoller.new.start
816
+ client = RobotLab::MCP::Client.new(server_config, poller: poller)
817
+ client.connect
818
+ # ...
819
+ poller.stop
820
+ ```
821
+
822
+ ---
823
+
824
+ ## RobotLab::Streaming::SequenceCounter
825
+
826
+ Thread-safe monotonic counter for event ordering.
827
+
828
+ | Method | Returns | Description |
829
+ |--------|---------|-------------|
830
+ | `SequenceCounter.new(start: 0)` | `SequenceCounter` | |
831
+ | `next` | `Integer` | Increment and return the new value |
832
+ | `current` | `Integer` | Read without incrementing |
833
+ | `reset(value = 0)` | `Integer` | Set the counter |
834
+
835
+ !!! warning "Nothing in the framework uses this"
836
+ Its only consumer is `Streaming::Context`, which core itself never
837
+ constructs. See [Streaming](streaming/index.md) for what actually streams.
838
+
839
+ ---
840
+
841
+ ## See Also
842
+
843
+ - [Core Classes](core/index.md) — `Robot`, `Network`, `Memory`, `Tool`, `RobotResult`
844
+ - [Hooks API](hooks.md) — the extension seam these classes are wired into
845
+ - [Skills API](skills.md) — `AgentSkill`, `Capabilities`, `ScriptTool`, `Sandbox`
846
+ - [Errors](errors.md) — including `Errors.retryable?` and `Errors.retryable_classes`