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
@@ -12,11 +12,22 @@ RubyLLM::Agent
12
12
 
13
13
  `Robot` inherits from `RubyLLM::Agent`, which creates a persistent `@chat` on initialization. The robot adds template-based prompts, shared memory, hierarchical MCP configuration, and SimpleFlow pipeline integration on top of the base agent.
14
14
 
15
+ `Robot` also includes `RobotLab::Runnable` and the mixins
16
+ `Robot::TemplateRendering`, `Robot::MCPManagement`, `Robot::BusMessaging`,
17
+ `Robot::HistorySearch`, `Robot::Budget`, and `Robot::Hooking`, and prepends
18
+ `Robot::AgentSkillMatching`.
19
+
20
+ ## Constants
21
+
22
+ | Constant | Value | Description |
23
+ |----------|-------|-------------|
24
+ | `Robot::DEFAULT_MAX_TOOLS` | `128` | Ceiling on the number of tools handed to the provider per turn. Override per robot with `RunConfig#max_tools`; a nil, zero, or negative `max_tools` falls back to this default, so the cap cannot be disabled |
25
+
15
26
  ## Constructor
16
27
 
17
28
  ```ruby
18
29
  Robot.new(
19
- name:,
30
+ name:, # required
20
31
  template: nil,
21
32
  system_prompt: nil,
22
33
  context: {},
@@ -33,8 +44,6 @@ Robot.new(
33
44
  enable_cache: true,
34
45
  bus: nil,
35
46
  skills: nil,
36
- max_tool_rounds: nil,
37
- token_budget: nil,
38
47
  temperature: nil,
39
48
  top_p: nil,
40
49
  top_k: nil,
@@ -42,15 +51,27 @@ Robot.new(
42
51
  presence_penalty: nil,
43
52
  frequency_penalty: nil,
44
53
  stop: nil,
54
+ max_tool_rounds: nil,
55
+ token_budget: nil,
56
+ cost_budget: nil,
57
+ doom_loop_threshold: nil,
58
+ mcp_discovery: false,
45
59
  config: nil
46
60
  )
47
61
  ```
48
62
 
63
+ !!! warning "The keyword list is closed"
64
+ `Robot#initialize` has no `**rest`. Any keyword not listed above raises
65
+ `ArgumentError`. In particular `auto_compact:`, `compact_threshold:`,
66
+ `ractor_pool_size:`, `max_concurrent_robots:`, and `max_tools:` are
67
+ **`RunConfig` fields only** — pass them via `config:`, not as constructor
68
+ kwargs. There is no `memory:`, `learn:`, or `learn_domain:` keyword.
69
+
49
70
  ### Parameters
50
71
 
51
72
  | Name | Type | Default | Description |
52
73
  |------|------|---------|-------------|
53
- | `name` | `String` | **required** | Unique identifier for the robot |
74
+ | `name` | `String` | **required** | Identifier for the robot. `RobotLab.build` defaults it to the literal string `"robot"`; that default is load-bearing — front-matter `robot_name:` is applied only when the constructor name is still `"robot"` |
54
75
  | `template` | `Symbol`, `nil` | `nil` | Prompt template (e.g., `:assistant` loads `prompts/assistant.md`) |
55
76
  | `system_prompt` | `String`, `nil` | `nil` | Inline system prompt (appended after template if both given) |
56
77
  | `context` | `Hash`, `Proc` | `{}` | Variables passed to the template |
@@ -60,7 +81,7 @@ Robot.new(
60
81
  | `provider` | `String`, `Symbol`, `nil` | `nil` | LLM provider for local providers (e.g., `:ollama`, `:gpustack`). Automatically sets `assume_model_exists: true` |
61
82
  | `mcp_servers` | `Array` | `[]` | Legacy MCP server configurations |
62
83
  | `mcp` | `Symbol`, `Array` | `:none` | Hierarchical MCP config (`:none`, `:inherit`, or server array) |
63
- | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name array) |
84
+ | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name **array**). Must be tool *names* (String/Symbol) — `validate_tools_filter!` raises `ArgumentError` for an instance or class, telling you to use `local_tools:` instead. **For a standalone robot, leave this unset at build time**: `tools: :inherit` here resolves against the global parent `:none` and yields an allowlist that matches nothing. Inside a network whose `config:` sets `tools:`, build-time `:inherit` is the correct way to opt in. See [Runtime Tool Filtering](../../guides/using-tools.md#runtime-tool-filtering) |
64
85
  | `on_tool_call` | `Proc`, `nil` | `nil` | Callback invoked when a tool is called |
65
86
  | `on_tool_result` | `Proc`, `nil` | `nil` | Callback invoked when a tool returns a result |
66
87
  | `on_content` | `Proc`, `nil` | `nil` | Stored streaming callback invoked with each content chunk (see [Streaming](#streaming)) |
@@ -68,18 +89,27 @@ Robot.new(
68
89
  | `bus` | `TypedBus::MessageBus`, `nil` | `nil` | Optional message bus for inter-robot communication |
69
90
  | `skills` | `Symbol`, `Array<Symbol>`, `nil` | `nil` | Skill templates to prepend (see [Skills](#skills)) |
70
91
  | `max_tool_rounds` | `Integer`, `nil` | `nil` | Circuit breaker: raise `ToolLoopError` after this many tool calls in one `run()` (see [Tool Loop Circuit Breaker](#tool-loop-circuit-breaker)) |
71
- | `token_budget` | `Integer`, `nil` | `nil` | Raise `InferenceError` if cumulative input tokens exceed this limit |
92
+ | `token_budget` | `Integer`, `nil` | `nil` | Raise `InferenceError` if cumulative tokens exceed this limit after a call; raise `BudgetExceeded` up front if already exhausted (see [Budgets](#budgets)) |
93
+ | `cost_budget` | `Float`, `nil` | `nil` | Same enforcement as `token_budget`, tracked in cumulative dollar cost instead of tokens (requires provider pricing data) |
94
+ | `doom_loop_threshold` | `Integer`, `nil` | `nil` | Tunes the always-on doom-loop detector (default threshold 3). See [Doom Loop Detection](#doom-loop-detection) |
95
+ | `mcp_discovery` | `Boolean` | `false` | When true, the first run narrows the configured MCP server list to those `MCP::ServerDiscovery` judges relevant to the message |
72
96
  | `config` | `RunConfig`, `nil` | `nil` | Shared config merged with explicit kwargs (see [RunConfig](#runconfig)) |
73
- | `temperature` | `Float`, `nil` | `nil` | Controls randomness (0.0-1.0) |
74
- | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold |
75
- | `top_k` | `Integer`, `nil` | `nil` | Top-k sampling |
76
- | `max_tokens` | `Integer`, `nil` | `nil` | Maximum tokens in response |
77
- | `presence_penalty` | `Float`, `nil` | `nil` | Penalize based on presence |
78
- | `frequency_penalty` | `Float`, `nil` | `nil` | Penalize based on frequency |
79
- | `stop` | `String`, `Array`, `nil` | `nil` | Stop sequences |
97
+ | `temperature` | `Float`, `nil` | `nil` | Controls randomness — applied via `chat.with_temperature` |
98
+ | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold — applied via `chat.with_params` |
99
+ | `top_k` | `Integer`, `nil` | `nil` | Top-k sampling — applied via `chat.with_params` |
100
+ | `max_tokens` | `Integer`, `nil` | `nil` | Maximum tokens in response — applied via `chat.with_params` |
101
+ | `presence_penalty` | `Float`, `nil` | `nil` | Penalize based on presence — applied via `chat.with_params` |
102
+ | `frequency_penalty` | `Float`, `nil` | `nil` | Penalize based on frequency — applied via `chat.with_params` |
103
+ | `stop` | `String`, `Array`, `nil` | `nil` | Stop sequences — applied via `chat.with_params` |
80
104
 
81
105
  When both `config:` and explicit kwargs (e.g., `temperature:`) are provided, explicit kwargs always win.
82
106
 
107
+ `model` and `temperature` are applied to the chat with dedicated `with_model` /
108
+ `with_temperature` calls. The remaining six LLM fields (`top_p`, `top_k`,
109
+ `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`) are collected into
110
+ a single `chat.with_params(...)` call. This distinction matters for template
111
+ front matter — see [Templates](#templates).
112
+
83
113
  ## Factory Method
84
114
 
85
115
  ```ruby
@@ -91,12 +121,15 @@ robot = RobotLab.build(
91
121
  enable_cache: true,
92
122
  bus: nil, # Optional TypedBus::MessageBus
93
123
  skills: nil, # Optional skill templates
124
+ config: nil, # Optional RunConfig
94
125
  **options # All other Robot.new parameters
95
126
  )
96
127
  # => RobotLab::Robot
97
128
  ```
98
129
 
99
- If `name` is omitted, it defaults to `"robot"`.
130
+ If `name` is omitted, it defaults to the literal string `"robot"`. `**options`
131
+ is forwarded verbatim to `Robot.new`, whose keyword list is closed — an unknown
132
+ option raises `ArgumentError`.
100
133
 
101
134
  ## Attributes (Read-Only)
102
135
 
@@ -120,6 +153,8 @@ If `name` is omitted, it defaults to `"robot"`.
120
153
  | `total_input_tokens` | `Integer` | Cumulative input tokens sent across all `run()` calls |
121
154
  | `total_output_tokens` | `Integer` | Cumulative output tokens received across all `run()` calls |
122
155
  | `learnings` | `Array<String>` | Accumulated cross-run observations (see [Learning Accumulation](#learning-accumulation)) |
156
+ | `budget_ledger` | `RobotLab::Budget::Ledger`, `nil` | Reserve/reconcile ledger backing `token_budget`/`cost_budget`; `nil` when neither is configured (see [Budgets](#budgets)) |
157
+ | `hooks` | `RobotLab::HookRegistry` | This robot's own hook registry. Populated by [`robot.on`](#on); consulted alongside `RobotLab.hooks` and the network's registry on every run |
123
158
 
124
159
  ## Attributes (Read-Write)
125
160
 
@@ -135,7 +170,9 @@ Used by tools like [`AskUser`](tool.md#built-in-askuser) that need terminal IO.
135
170
  ### run
136
171
 
137
172
  ```ruby
138
- result = robot.run(message, **kwargs, &block)
173
+ result = robot.run(message = nil, network: nil, task: nil,
174
+ network_memory: nil, network_config: nil, memory: nil,
175
+ mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
139
176
  # => RobotResult
140
177
  ```
141
178
 
@@ -145,33 +182,63 @@ Primary execution method. Sends a message to the LLM with memory/MCP/tools resol
145
182
 
146
183
  | Name | Type | Default | Description |
147
184
  |------|------|---------|-------------|
148
- | `message` | `String` | **required** | The user message to send |
149
- | `network` | `NetworkRun`, `nil` | `nil` | Network context (passed internally) |
150
- | `network_memory` | `Memory`, `nil` | `nil` | Shared network memory |
151
- | `memory` | `Memory`, `Hash`, `nil` | `nil` | Runtime memory to merge |
152
- | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override |
153
- | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override |
154
- | `**kwargs` | `Hash` | `{}` | Additional keyword arguments passed to `Agent#ask` |
185
+ | `message` | `String`, `nil` | `nil` | The user message to send (positional, optional) |
186
+ | `network` | `Network`, `nil` | `nil` | Network context (passed internally by `Network#run`) |
187
+ | `task` | `Task`, `nil` | `nil` | Task wrapper for the current pipeline step (passed internally); surfaces on hook contexts |
188
+ | `network_memory` | `Memory`, `nil` | `nil` | Shared network memory (passed internally) |
189
+ | `network_config` | `RunConfig`, `nil` | `nil` | Network-level config used when resolving `:inherit` for `mcp`/`tools` (passed internally) |
190
+ | `memory` | `Memory`, `Hash`, `nil` | `nil` | A `Memory` replaces the active memory for this run; a `Hash` is merged into it |
191
+ | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override `:inherit` (all attached servers), `:none`/`[]` (zero this turn), or an explicit array |
192
+ | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override — `:inherit` (all attached tools), `:none`/`[]` (zero this turn), or an explicit name array. See [Runtime Tool Filtering](../../guides/using-tools.md#runtime-tool-filtering) |
193
+ | `hooks` | `Array`, `nil` | `nil` | Per-run hook handler classes, active only for this call |
194
+ | `**kwargs` | `Hash` | `{}` | See below — **not** a passthrough to `Agent#ask` |
155
195
  | `&block` | `Proc` | `nil` | Per-call streaming block, receives each content chunk |
156
196
 
197
+ **What `**kwargs` actually does.** Only `:with` is forwarded to the underlying
198
+ `Agent#ask` (`kwargs.slice(:with)`). *Every other* keyword is treated as
199
+ template re-render context: `kwargs.except(:with)` is merged over the build-time
200
+ context and the template is re-rendered before the call. If the robot has no
201
+ `template:`, those extra keywords are simply ignored.
202
+
203
+ ```ruby
204
+ robot = RobotLab.build(name: "support", template: :support)
205
+ robot.run("Help me", company: "Acme") # re-renders the template with company: "Acme"
206
+ robot.run("Describe this", with: image) # forwarded to Agent#ask as attachments
207
+ ```
208
+
157
209
  When both a stored `on_content` callback and a runtime block are provided, both fire (stored first, then runtime block).
158
210
 
211
+ !!! warning "`tools:`/`mcp:` default to `:none` here"
212
+ A bare `robot.run(message)` sends **zero** tools and connects **no** MCP
213
+ servers for that call, even when `local_tools:`/`mcp:` were supplied at
214
+ build time. Pass `tools: :inherit` (and/or `mcp: :inherit`) explicitly to
215
+ use what is attached. `mcp: :inherit` triggers the connection attempt;
216
+ `tools: :inherit` is additionally required for the MCP tools to be sent.
217
+
218
+ Each call's resolved tool set *replaces* the chat's tools rather than accumulating, so a subsequent `:none` call correctly clears whatever a prior call attached, and the fully-resolved set is clamped to `max_tools` (`DEFAULT_MAX_TOOLS = 128` by default) right before being handed to the provider — see [Tool Capping](../../guides/using-tools.md#tool-capping-and-per-turn-filtering).
219
+
159
220
  **Returns:** `RobotResult`
160
221
 
161
222
  **Examples:**
162
223
 
163
224
  ```ruby
164
- # Simple message
225
+ # Simple message — sends no tools, connects no MCP servers
165
226
  result = robot.run("What is 2+2?")
166
227
 
228
+ # Send the tools attached via local_tools:
229
+ result = robot.run("What is 15 * 7?", tools: :inherit)
230
+
231
+ # Connect MCP servers and send their tools
232
+ result = robot.run("Search the repo", mcp: :inherit, tools: :inherit)
233
+
234
+ # Restrict this turn to a named subset
235
+ result = robot.run("Look it up", tools: %w[order_lookup])
236
+
167
237
  # With runtime memory
168
238
  result = robot.run("Summarize the data", memory: { data: report })
169
239
 
170
240
  # With per-call streaming block
171
241
  result = robot.run("Tell me a story") { |chunk| print chunk.content }
172
-
173
- # With runtime overrides
174
- result = robot.run("Help me", mcp: :none, tools: :none)
175
242
  ```
176
243
 
177
244
  ### model
@@ -182,6 +249,21 @@ robot.model # => "claude-sonnet-4" or nil
182
249
 
183
250
  Returns the model ID string. Resolves through the underlying chat object.
184
251
 
252
+ ### effective_config
253
+
254
+ ```ruby
255
+ robot.effective_config
256
+ # => { model: "claude-sonnet-4-20250514", temperature: 0.7, max_tokens: 4096 }
257
+ ```
258
+
259
+ Snapshot of the robot's merged `RunConfig` as a plain Hash, `.compact`ed so unset
260
+ fields are omitted. Reports exactly these keys when set: `model`, `temperature`,
261
+ `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`,
262
+ `tools`, `mcp`, `max_tool_rounds`, `doom_loop_threshold`, `auto_compact`,
263
+ `compact_threshold`, `token_budget`, `cost_budget`.
264
+
265
+ This is a *view*, not the config object — use `robot.config` for the `RunConfig` itself.
266
+
185
267
  ### update
186
268
 
187
269
  ```ruby
@@ -198,29 +280,52 @@ robot.update(
198
280
 
199
281
  Reconfigure the robot after construction. Returns `self` for chaining.
200
282
 
283
+ The five named parameters are applied directly (`template` re-renders the prompt;
284
+ `system_prompt`, `model`, and `temperature` call the corresponding `with_*` on the chat).
285
+
286
+ !!! warning "`**kwargs` only reaches fields the chat exposes as `with_<key>`"
287
+ Each extra keyword is forwarded as `@chat.with_#{key}(value)` **only if
288
+ `@chat.respond_to?(:"with_#{key}")`**. `RubyLLM::Chat` has no
289
+ `with_max_tokens`, `with_top_p`, `with_top_k`, `with_stop`,
290
+ `with_presence_penalty`, or `with_frequency_penalty` — so
291
+ `robot.update(max_tokens: 4000)` silently does nothing. Use
292
+ `robot.with_params(max_tokens: 4000)` for those fields.
293
+
201
294
  ### with_* Methods (Chaining)
202
295
 
203
- All `with_*` methods delegate to the persistent `@chat` and return `self` for chaining:
296
+ `with_*` methods are discovered from `RubyLLM::Chat` at construction time and
297
+ defined as singleton methods that delegate to the persistent `@chat` and return
298
+ `self` for chaining. This is the **complete** set:
204
299
 
205
300
  | Method | Description |
206
301
  |--------|-------------|
207
302
  | `with_model(model_id)` | Change the LLM model |
208
303
  | `with_temperature(temp)` | Set temperature |
209
- | `with_top_p(value)` | Set nucleus sampling |
210
- | `with_top_k(value)` | Set top-k sampling |
211
- | `with_max_tokens(value)` | Set max response tokens |
212
- | `with_presence_penalty(value)` | Set presence penalty |
213
- | `with_frequency_penalty(value)` | Set frequency penalty |
214
- | `with_stop(sequences)` | Set stop sequences |
215
304
  | `with_instructions(prompt)` | Set system instructions |
216
305
  | `with_tool(tool)` | Add a single tool |
217
306
  | `with_tools(*tools)` | Add multiple tools |
218
- | `with_params(**params)` | Set additional parameters |
307
+ | `with_params(**params)` | Set arbitrary provider parameters |
219
308
  | `with_headers(**headers)` | Set custom headers |
220
309
  | `with_schema(schema)` | Set output schema |
221
310
  | `with_context(**ctx)` | Set context |
222
311
  | `with_thinking(opts)` | Enable extended thinking |
223
- | `with_bus(bus)` | Connect to a message bus (creates one if nil) |
312
+
313
+ Plus two defined by RobotLab itself:
314
+
315
+ | Method | Description |
316
+ |--------|-------------|
317
+ | `with_template(id, **context)` | Apply a prompt_manager template (see below) |
318
+ | `with_bus(bus = nil)` | Connect to a message bus (creates one if nil) |
319
+
320
+ !!! danger "These do not exist"
321
+ `with_max_tokens`, `with_top_p`, `with_top_k`, `with_stop`,
322
+ `with_presence_penalty`, and `with_frequency_penalty` are **not** defined
323
+ and raise `NoMethodError`. Set those fields with a constructor kwarg
324
+ (`max_tokens: 2000`) or with `with_params`:
325
+
326
+ ```ruby
327
+ robot.with_params(max_tokens: 2000, top_p: 0.3)
328
+ ```
224
329
 
225
330
  **Example:**
226
331
 
@@ -229,6 +334,7 @@ robot = RobotLab.build(name: "bot")
229
334
  robot
230
335
  .with_model("claude-sonnet-4")
231
336
  .with_temperature(0.7)
337
+ .with_params(max_tokens: 2000)
232
338
  .with_instructions("Be concise.")
233
339
  .run("Hello")
234
340
  ```
@@ -271,7 +377,7 @@ message = robot.send_message(to: :bob, content: "Tell me a joke.")
271
377
  # => RobotMessage
272
378
  ```
273
379
 
274
- Publish a message to another robot's bus channel. Increments the internal message counter, creates a `RobotMessage`, tracks it in the outbox, and publishes to the target channel.
380
+ Publish a message to another robot's bus channel. Increments the internal message counter, creates a `RobotMessage`, tracks it in the outbox, and publishes to the target channel. The counter and outbox mutation are synchronized with an internal mutex, so concurrent `send_message`/`send_reply` calls from multiple threads and reply correlation never clobber each other.
275
381
 
276
382
  **Parameters:**
277
383
 
@@ -337,6 +443,49 @@ robot.on_message do |delivery, message|
337
443
  end
338
444
  ```
339
445
 
446
+ ### respond_to_tasks
447
+
448
+ ```ruby
449
+ robot.respond_to_tasks(auto_reply: true) { |message| "the reply content" }
450
+ # => self
451
+ ```
452
+
453
+ Auto-answer inbound (non-reply) bus tasks: run the block to produce a reply, and send it back to the sender. This is the symmetric counterpart to how a `robot_lab-cyborg` Cyborg answers its human — one call makes any bus member a first-class responder without hand-wiring `on_message` yourself.
454
+
455
+ **Parameters:**
456
+
457
+ | Name | Type | Default | Description |
458
+ |------|------|---------|-------------|
459
+ | `auto_reply` | `Boolean` | `true` | Send the block's result back to the sender via `send_reply` |
460
+ | `&responder` | `Proc` | **required** | Receives the inbound `message`; return the reply content (`nil` means no reply) |
461
+
462
+ **Returns:** `self`
463
+
464
+ Messages that are themselves replies (`message.reply?`) are ignored, so a two-way `respond_to_tasks` conversation between robots does not loop. The responder runs **inline in the caller's context** — `BusPoller` has no background thread; its `enqueue` either processes the delivery immediately or queues it behind the one in flight and drains it when that finishes. Deliveries to a given robot are therefore handled one at a time, and a long-running responder blocks the sender as well as the next inbound message.
465
+
466
+ ```ruby
467
+ bob.respond_to_tasks { |message| "handled: #{message.content}" }
468
+ alice.send_message(to: :bob, content: "ping")
469
+ # bob replies "handled: ping" back to alice automatically
470
+ ```
471
+
472
+ ### serve
473
+
474
+ ```ruby
475
+ robot.serve(auto_reply: true)
476
+ # => self
477
+ ```
478
+
479
+ The common case of `respond_to_tasks`: run every inbound task through this robot's own `#run` and reply with the result — the one-call way to make a Robot cooperate on the bus the way a Cyborg already does out of the box.
480
+
481
+ ```ruby
482
+ bob.serve
483
+ alice.send_message(to: :bob, content: "Tell me a joke.")
484
+ # bob runs "Tell me a joke." through its LLM and replies with the result
485
+ ```
486
+
487
+ Equivalent to `respond_to_tasks(auto_reply: auto_reply) { |message| run(message.content).reply }` (with Hash-content messages flattened to `"key: value"` lines first).
488
+
340
489
  ### spawn
341
490
 
342
491
  ```ruby
@@ -349,6 +498,8 @@ child = robot.spawn(
349
498
 
350
499
  Create a new robot on the same message bus. If the parent has no bus, one is created automatically and the parent is connected to it.
351
500
 
501
+ The spawned robot inherits its parent's `model` and `provider` (via `robot.model`/`robot.provider`) so a specialist runs on the same LLM as the robot that spawned it — a robot running on a local Ollama model, for instance, spawns specialists that also target that model rather than falling back to `RobotLab.config.ruby_llm.model` (the global default, typically a cloud model that would fail without credentials). Caller-supplied `model:`/`provider:` in `**options` still override.
502
+
352
503
  **Parameters:**
353
504
 
354
505
  | Name | Type | Default | Description |
@@ -381,6 +532,69 @@ worker2 = bot.spawn(name: "worker", system_prompt: "Worker 2")
381
532
  # Messages sent to :worker are delivered to both
382
533
  ```
383
534
 
535
+ ### assign_bus_poller
536
+
537
+ ```ruby
538
+ robot.assign_bus_poller(poller, group: :default)
539
+ # => void — do not rely on the return value
540
+ ```
541
+
542
+ Adopt a shared [`BusPoller`](../support.md#robotlabbuspoller) — normally the
543
+ network's. `Network#task` calls this for every robot that responds to it, passing
544
+ the task's `poller_group:`. Any private poller the robot auto-created is dropped
545
+ first.
546
+
547
+ **Parameters:**
548
+
549
+ | Name | Type | Default | Description |
550
+ |------|------|---------|-------------|
551
+ | `poller` | `BusPoller` | **required** | The shared poller to adopt |
552
+ | `group` | `Symbol` | `:default` | Poller group label — informational only; groups share one drain mechanism |
553
+
554
+ You only call this directly when wiring robots onto a shared poller outside a
555
+ `Network`.
556
+
557
+ ### inherited_llm_settings
558
+
559
+ ```ruby
560
+ robot.inherited_llm_settings
561
+ # => { model: "llama3.2", provider: :ollama }
562
+ ```
563
+
564
+ The model/provider pair a [`spawn`](#spawn)ed child inherits from this robot.
565
+ Returns `{}` when neither is set — each key is included only when the
566
+ corresponding reader is truthy. Exposed so an application building children by
567
+ some route other than `spawn` can apply the same inheritance:
568
+
569
+ ```ruby
570
+ child = RobotLab.build(name: "helper", bus: parent.bus, **parent.inherited_llm_settings)
571
+ ```
572
+
573
+ ### rerender_template
574
+
575
+ ```ruby
576
+ robot.rerender_template(run_context) # internal — see the warning below
577
+ ```
578
+
579
+ Re-renders the robot's template with `run_context` merged over the build-time
580
+ context (skill bodies included, when `skills:` are in play) and reinstalls the
581
+ result as the system prompt, re-appending the inline `system_prompt`. `run` calls
582
+ it automatically when the robot has a `template:` and the call carried extra
583
+ keywords — every keyword except `:with` — which is the mechanism behind
584
+ `robot.run("Help me", company: "Acme")`.
585
+
586
+ `Robot::AgentSkillMatching` overrides it to re-prepend any matched AgentSkill
587
+ instructions afterward, because a re-render replaces the whole system prompt and
588
+ would otherwise discard them mid-run.
589
+
590
+ !!! warning "Public only by accident — treat it as internal"
591
+ `rerender_template` is `private` in `Robot::TemplateRendering`, but the
592
+ prepended `Robot::AgentSkillMatching` redefines it **above** its own
593
+ `private` keyword, so the effective method on `Robot` is public. That is an
594
+ artifact of the override, not a supported entry point: the return value is
595
+ unspecified, and it mutates the chat's system prompt for the rest of the
596
+ conversation. Pass template context to `run` instead.
597
+
384
598
  ### with_bus
385
599
 
386
600
  ```ruby
@@ -456,7 +670,18 @@ Inject pre-connected MCP clients and their tools into this robot. Used by host a
456
670
  ```ruby
457
671
  # Host app manages MCP connections
458
672
  clients = { "github" => github_client }
459
- tools = github_client.list_tools.map { |t| RobotLab::Tool.from_mcp(t) }
673
+
674
+ # There is no Tool.from_mcp — MCP wrappers are built with Tool.create,
675
+ # exactly as RobotLab's own discover_mcp_tools does.
676
+ tools = github_client.list_tools.map do |tool_def|
677
+ name = tool_def[:name]
678
+ RobotLab::Tool.create(
679
+ name: name,
680
+ description: tool_def[:description],
681
+ parameters: tool_def[:inputSchema],
682
+ mcp: "github"
683
+ ) { |args| github_client.call_tool(name, args) }
684
+ end
460
685
 
461
686
  robot.inject_mcp!(clients: clients, tools: tools)
462
687
  ```
@@ -523,6 +748,147 @@ saved = robot.messages.dup
523
748
  robot.replace_messages(saved)
524
749
  ```
525
750
 
751
+ ### compress_history
752
+
753
+ ```ruby
754
+ robot.compress_history(
755
+ recent_turns: 3,
756
+ keep_threshold: 0.6,
757
+ drop_threshold: 0.2,
758
+ summarizer: nil
759
+ )
760
+ # => self
761
+ ```
762
+
763
+ Shrink the conversation by scoring each older turn against the most recent
764
+ context and dropping or summarizing the least relevant ones. Internally builds a
765
+ `RobotLab::HistoryCompressor` and hands the result to `replace_messages`.
766
+
767
+ **Parameters:**
768
+
769
+ | Name | Type | Default | Description |
770
+ |------|------|---------|-------------|
771
+ | `recent_turns` | `Integer` | `3` | Turn pairs at the end that are always kept verbatim |
772
+ | `keep_threshold` | `Float` | `0.6` | Cosine score at or above this → kept verbatim |
773
+ | `drop_threshold` | `Float` | `0.2` | Cosine score below this → dropped |
774
+ | `summarizer` | `#call`, `nil` | `nil` | `callable(text) -> String` applied to the medium tier; `nil` drops the medium tier instead |
775
+
776
+ **Returns:** `self`
777
+
778
+ System messages and tool-call/tool-result messages are always preserved.
779
+
780
+ Scoring uses **term-frequency cosine similarity without IDF** (see
781
+ `RobotLab::Convergence`), so it is a lexical overlap measure, not a semantic one.
782
+
783
+ **Raises:** `RobotLab::DependencyError` when the optional `classifier` gem
784
+ (`~> 2.3`) is not installed.
785
+
786
+ ```ruby
787
+ robot.compress_history(recent_turns: 5, summarizer: ->(text) { text[0, 200] })
788
+ ```
789
+
790
+ `auto_compact: :context_window` on a `RunConfig` calls this automatically before
791
+ an LLM call once estimated tokens exceed `compact_threshold` (default `0.80`) of
792
+ the model's context window. When the `classifier` gem is missing there, the
793
+ `DependencyError` is caught, logged at `:warn`, and compaction is skipped.
794
+
795
+ ### delegate
796
+
797
+ ```ruby
798
+ result = robot.delegate(to:, task:, async: false, **run_kwargs)
799
+ # => RobotResult (async: false) | DelegationFuture (async: true)
800
+ ```
801
+
802
+ Hand a task to another robot and annotate the result with delegation metadata.
803
+
804
+ **Parameters:**
805
+
806
+ | Name | Type | Default | Description |
807
+ |------|------|---------|-------------|
808
+ | `to` | `Robot` | **required** | The robot to delegate to |
809
+ | `task` | `String` | **required** | The message to send |
810
+ | `async` | `Boolean` | `false` | When true, returns a `DelegationFuture` immediately |
811
+ | `**run_kwargs` | `Hash` | `{}` | Forwarded verbatim to the delegatee's `run` — including `tools:`/`mcp:`, which still default to `:none` |
812
+
813
+ **Synchronous** (default) blocks until the delegatee finishes and returns its
814
+ `RobotResult` with `duration` and `delegated_by` set.
815
+
816
+ **Asynchronous** (`async: true`) runs the delegatee on a new `Thread` and returns
817
+ a `RobotLab::DelegationFuture`. Call `future.value` to block, `future.value(timeout: N)`
818
+ to block with a deadline (raises `RobotLab::DelegationFuture::DelegationTimeout`),
819
+ or `future.resolved?` to poll. An exception in the delegatee is captured and
820
+ re-raised from `future.value`.
821
+
822
+ ```ruby
823
+ # Synchronous
824
+ result = manager.delegate(to: analyst, task: "What are the risks?")
825
+ result.reply
826
+ result.delegated_by # => "manager"
827
+ result.duration # => 1.43
828
+
829
+ # Async fan-out
830
+ f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
831
+ f2 = manager.delegate(to: analyst, task: "analyze ...", async: true, tools: :inherit)
832
+ summary = f1.value
833
+ analysis = f2.value(timeout: 30)
834
+ ```
835
+
836
+ ### search_history
837
+
838
+ ```ruby
839
+ results = robot.search_history(query, limit: 5)
840
+ # => Array<RobotLab::Robot::HistorySearch::HistoryResult>
841
+ ```
842
+
843
+ Rank the robot's own conversation messages against a natural-language query
844
+ using stemmed term-frequency cosine similarity.
845
+
846
+ **Parameters:**
847
+
848
+ | Name | Type | Default | Description |
849
+ |------|------|---------|-------------|
850
+ | `query` | `String` | **required** | Natural-language search query |
851
+ | `limit` | `Integer` | `5` | Maximum results to return |
852
+
853
+ **Returns:** `Array<HistoryResult>` sorted by score descending. `HistoryResult`
854
+ is a `Data` type with members `text`, `role`, `score`, and `index`.
855
+
856
+ Messages shorter than `MIN_SCORE_LENGTH` (20 characters) are skipped, as are
857
+ messages that score zero.
858
+
859
+ **Raises:** `RobotLab::DependencyError` when the optional `classifier` gem is not installed.
860
+
861
+ ```ruby
862
+ robot.search_history("quarterly revenue", limit: 3).each do |r|
863
+ puts "[#{r.role}] (#{r.score.round(3)}) #{r.text}"
864
+ end
865
+ ```
866
+
867
+ ### on
868
+
869
+ ```ruby
870
+ robot.on(HandlerClass, context: nil)
871
+ # => the registration
872
+ ```
873
+
874
+ Register a hook handler on **this robot's** registry (`robot.hooks`). The robot's
875
+ registry is consulted on every run alongside `RobotLab.hooks` (global) and the
876
+ network's registry, in that order.
877
+
878
+ **Parameters:**
879
+
880
+ | Name | Type | Default | Description |
881
+ |------|------|---------|-------------|
882
+ | `handler_class` | `Class` | **required** | The hook handler class |
883
+ | `context` | `Object`, `nil` | `nil` | Optional per-registration context passed to the handler |
884
+
885
+ !!! note "Task hooks bypass robot registries"
886
+ The `:task` hook family resolves against `[RobotLab.hooks, network&.hooks]`
887
+ only. A handler registered with `robot.on` never fires for task hooks —
888
+ register it with `RobotLab.on` or `network.on` instead.
889
+
890
+ Handlers can also be scoped to a single call with `robot.run(msg, hooks: [HandlerClass])`.
891
+
526
892
  ### chat_provider
527
893
 
528
894
  ```ruby
@@ -565,7 +931,23 @@ robot.to_h
565
931
  # => Hash
566
932
  ```
567
933
 
568
- Returns a hash representation of the robot including name, description, template, skills, system_prompt, local_tools, mcp_tools, mcp_config, tools_config, mcp_servers, model, and bus (true if configured, omitted otherwise). Nil values are compacted out.
934
+ Returns a hash representation of the robot. Keys, in order: `name`,
935
+ `description`, `template`, `skills`, `system_prompt`, `local_tools` (tool names),
936
+ `mcp_tools` (tool names), `mcp_config`, `tools_config`, `mcp_servers` (connected
937
+ client names), `model`, `config` (the `RunConfig` as a JSON-safe hash, omitted
938
+ when the config is empty), and `bus` (`true` if configured, omitted otherwise).
939
+ The whole hash is `.compact`ed, so nil values are dropped.
940
+
941
+ ```ruby
942
+ RobotLab.build(name: "x", max_tokens: 100).to_h
943
+ # => { name: "x", local_tools: [], mcp_tools: [], mcp_config: :none,
944
+ # tools_config: :none, mcp_servers: [], model: "claude-sonnet-4-20250514",
945
+ # config: { max_tokens: 100, enable_cache: true } }
946
+ ```
947
+
948
+ The `config` value comes from `RunConfig#to_json_hash`, which omits the
949
+ non-serializable fields (`on_tool_call`, `on_tool_result`, `on_content`, `bus`,
950
+ `auto_compact`).
569
951
 
570
952
  ## Memory Behavior
571
953
 
@@ -590,20 +972,60 @@ Templates are `.md` files with optional YAML front matter, loaded via `prompt_ma
590
972
  robot = RobotLab.build(name: "bot", template: :assistant, context: { tone: "friendly" })
591
973
  ```
592
974
 
593
- Front matter supports two categories of keys:
975
+ Front matter supports two categories of keys.
976
+
977
+ **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`,
978
+ `presence_penalty`, `frequency_penalty`, `stop` are all *parsed* into a
979
+ `RunConfig`.
594
980
 
595
- **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` applied to the underlying chat.
981
+ !!! warning "Only `model` and `temperature` actually take effect from front matter"
982
+ Front-matter LLM fields are applied through `RunConfig#apply_to`, which
983
+ dispatches `chat.with_<field>` guarded by `respond_to?`. `RubyLLM::Chat`
984
+ defines only `with_model` and `with_temperature`, so `top_p`, `top_k`,
985
+ `max_tokens`, `presence_penalty`, `frequency_penalty`, and `stop` are parsed
986
+ and **silently dropped**. Set those six as constructor kwargs or on a
987
+ `config:` `RunConfig` instead — that path goes through `with_params` and
988
+ does work.
596
989
 
597
990
  **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
598
991
 
599
992
  | Key | Type | Description |
600
993
  |-----|------|-------------|
601
- | `robot_name` | `String` | Override robot name (when constructor uses the default `"robot"`) |
602
- | `description` | `String` | Human-readable description |
603
- | `tools` | `Array<String>` | Tool class names resolved via `Object.const_get` |
604
- | `mcp` | `Array<Hash>` | MCP server configurations |
994
+ | `robot_name` | `String` | Override robot name — applied only when the constructor name is still the default `"robot"` |
995
+ | `description` | `String` | Human-readable description; applied only when the constructor passed no `description:` |
996
+ | `tools` | `Array` | Tool entries; applied only when `local_tools:` is empty (see below) |
997
+ | `mcp` | `Array<Hash>` | MCP server configurations; applied only when the constructor `mcp:` is `:none` |
605
998
  | `skills` | `Array<Symbol>` | Skill templates to prepend (recursive, with cycle detection) |
606
999
 
1000
+ Templates render with ERB — write `<%= var %>`. `{{ var }}` is not interpolated
1001
+ and passes through verbatim.
1002
+
1003
+ ### Front-matter `tools:` resolution
1004
+
1005
+ Front-matter `tools` entries are resolved by `resolve_frontmatter_tools`, and the
1006
+ result becomes `local_tools` (real tool objects), **not** the `tools_config`
1007
+ name allowlist. Three entry shapes are accepted:
1008
+
1009
+ | Entry | Behavior |
1010
+ |-------|----------|
1011
+ | `String` | Resolved with `Object.const_get`. If the constant is a `Class` that is `< RubyLLM::Tool`, it is **instantiated** (`const.new`); any other constant is used as-is |
1012
+ | `Class` | **Instantiated** (`name.new`) |
1013
+ | anything else | Used as-is (e.g. an already-built tool instance) |
1014
+
1015
+ An unresolvable name does **not** raise. It is logged at `:warn`
1016
+ (`"Robot '<name>': tool '<X>' not found, skipping"`) and skipped.
1017
+
1018
+ ```markdown
1019
+ ---
1020
+ tools:
1021
+ - OrderLookup # instantiated: OrderLookup.new
1022
+ - RefundProcessor
1023
+ ---
1024
+ ```
1025
+
1026
+ Because `run()` still defaults to `tools: :none`, front-matter tools are sent
1027
+ only when you pass `tools: :inherit` at run time.
1028
+
607
1029
  ## Skills
608
1030
 
609
1031
  Skills compose robot behaviors from reusable templates. Each skill is a standard `.md` template whose prompt body is prepended before the main template. Skills are expanded depth-first with automatic cycle detection.
@@ -654,7 +1076,45 @@ robot = RobotLab.build(
654
1076
  robot.config #=> RunConfig with model: "claude-sonnet-4", temperature: 0.9, ...
655
1077
  ```
656
1078
 
657
- RunConfig fields: `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`, `mcp`, `tools`, `on_tool_call`, `on_tool_result`, `on_content`, `bus`, `enable_cache`.
1079
+ `RunConfig::FIELDS` is the complete, authoritative list. Passing any other key to
1080
+ `RunConfig.new` raises `ArgumentError: Unknown RunConfig field: ...`.
1081
+
1082
+ | Group | Constant | Fields |
1083
+ |-------|----------|--------|
1084
+ | LLM | `LLM_FIELDS` | `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` |
1085
+ | Tools | `TOOL_FIELDS` | `mcp`, `tools` |
1086
+ | Callbacks | `CALLBACK_FIELDS` | `on_tool_call`, `on_tool_result`, `on_content` |
1087
+ | Infrastructure | `INFRA_FIELDS` | `bus`, `enable_cache`, `max_tool_rounds`, `token_budget`, `cost_budget`, `ractor_pool_size`, `max_concurrent_robots`, `doom_loop_threshold`, `auto_compact`, `compact_threshold`, `max_tools` |
1088
+
1089
+ Five of those infrastructure fields are `RunConfig`-only — they are **not**
1090
+ `Robot.new` keywords: `ractor_pool_size`, `max_concurrent_robots`,
1091
+ `auto_compact`, `compact_threshold`, `max_tools`. (`max_concurrent_robots` is
1092
+ consumed by `Network`, not by `Robot`; `ractor_pool_size` by the
1093
+ `robot_lab-ractor` extension.)
1094
+
1095
+ ```ruby
1096
+ config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.7, max_tools: 32)
1097
+ robot = RobotLab.build(name: "long_runner", system_prompt: "...", config: config)
1098
+ ```
1099
+
1100
+ Other `RunConfig` API:
1101
+
1102
+ | Method | Description |
1103
+ |--------|-------------|
1104
+ | `RunConfig.new(**kwargs) { \|c\| ... }` | Keyword construction plus an optional block DSL (`c.model "..."`) |
1105
+ | `#merge(other)` | Returns a **new** RunConfig; the other's non-nil values win |
1106
+ | `#to_h` | The explicitly-set fields |
1107
+ | `#to_json_hash` | `to_h` minus `NON_SERIALIZABLE_FIELDS` (`on_tool_call`, `on_tool_result`, `on_content`, `bus`, `auto_compact`) |
1108
+ | `#apply_to(chat, provider: nil, assume_model_exists: false)` | Applies `LLM_FIELDS` via `chat.with_<field>`, guarded by `respond_to?` |
1109
+ | `#empty?` / `#key?(field)` | Introspection |
1110
+ | `RunConfig.from_front_matter(metadata)` | Builds a RunConfig from a template's parsed metadata |
1111
+
1112
+ !!! note "A network-level `config:` only propagates `mcp` and `tools`"
1113
+ LLM fields and callbacks (`on_content`, `on_tool_call`, `on_tool_result`)
1114
+ are read from the robot's own config at construction time and are never
1115
+ inherited from a network. A member robot picks up the network's `mcp`/`tools`
1116
+ only when it opts in with `:inherit`. `max_concurrent_robots` is the one
1117
+ field the network itself consumes.
658
1118
 
659
1119
  See [Configuration: RunConfig](../../getting-started/configuration.md#runconfig-shared-operational-defaults) for full details.
660
1120
 
@@ -728,27 +1188,55 @@ robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
728
1188
 
729
1189
  ## Configuration Hierarchy
730
1190
 
731
- Tools and MCP servers use hierarchical resolution: **runtime > robot > network > global config**.
1191
+ Tools and MCP servers use hierarchical resolution: **runtime > robot > task > network > global config**.
732
1192
 
733
1193
  ```
734
1194
  RobotLab.config (global)
735
1195
  |
736
- +-- Network (config:)
1196
+ +-- Network (config:) -- propagates only mcp/tools to members
737
1197
  | |
738
- | +-- Task (config:)
1198
+ | +-- Task (config:) -- likewise only mcp/tools
739
1199
  | | |
740
1200
  | | +-- Robot (config: + build-time mcp:, tools:)
741
1201
  | | |
742
- | | +-- Template front matter
743
- | | |
744
- | | +-- run() call (runtime mcp:, tools:)
1202
+ | | +-- run() call (runtime mcp:, tools:) <- default :none
745
1203
  ```
746
1204
 
747
1205
  Values at each level:
748
1206
 
749
- - `:none` -- no tools/MCP at this level
750
- - `:inherit` -- inherit from parent level
751
- - `Array` -- explicit list of tool names or MCP server configs
1207
+ - `:none` -- no tools/MCP at this level (the default at every level)
1208
+ - `:inherit` -- inherit from the parent level
1209
+ - `Array` -- a filter over the already-attached tools, or a list of MCP server configs. Entries are matched against `tool.name.to_s`, so they must be written in the same form the tool was attached in: a class-attached tool matches `"RefundTool"`, an instance-attached one matches `"refund"`. (The constructor's `tools:` accepts only Strings/Symbols; the class form is usable at the task/`run` level, which is not validated.)
1210
+
1211
+ !!! danger "For a standalone robot, do not set `tools: :inherit` at build time"
1212
+ The parent is recomputed on every run as
1213
+ `network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools`.
1214
+ For a **standalone** robot that resolves to the global `:none`, so a
1215
+ build-time `:inherit` produces the allowlist `["none"]`, which matches
1216
+ nothing. Leave `tools:` unset on the constructor and pass `tools: :inherit`
1217
+ on `run()` instead.
1218
+
1219
+ | build `tools:` | run `tools:` | tools sent |
1220
+ |---|---|---|
1221
+ | unset | `:none` (default) | none |
1222
+ | unset | `:inherit` | all attached — **the correct pattern** |
1223
+ | `:inherit` | `:inherit` | none — broken |
1224
+ | `:none` | `:inherit` | all attached |
1225
+
1226
+ This does **not** generalize to robots inside a network. When the network's
1227
+ `config:` sets `tools:`/`mcp:`, the parent resolved at run time is that
1228
+ network value, and a build-time `:inherit` is exactly how the robot opts
1229
+ into it. See [MCP in Networks](../mcp/index.md#mcp-in-networks).
1230
+
1231
+ ### Per-robot config cascade
1232
+
1233
+ For a single robot, least- to most-specific:
1234
+
1235
+ ```
1236
+ template front matter -> config: (RunConfig) -> constructor kwargs
1237
+ ```
1238
+
1239
+ Front matter is the **base**, not an override. Constructor kwargs always win.
752
1240
 
753
1241
  ## Examples
754
1242
 
@@ -798,9 +1286,14 @@ robot = RobotLab.build(
798
1286
  system_prompt: "You help with math.",
799
1287
  local_tools: [Calculator]
800
1288
  )
801
- result = robot.run("What is 15 * 7?")
1289
+
1290
+ # run() defaults to tools: :none — pass :inherit to actually send Calculator
1291
+ result = robot.run("What is 15 * 7?", tools: :inherit)
802
1292
  ```
803
1293
 
1294
+ Note that `param` accepts only `type:`, `desc:`/`description:`, and `required:` —
1295
+ there is no `enum:` option. See [Tool](tool.md#param).
1296
+
804
1297
  ### Robot with Local Provider
805
1298
 
806
1299
  ```ruby
@@ -813,6 +1306,10 @@ robot = RobotLab.build(
813
1306
  result = robot.run("Hello!")
814
1307
  ```
815
1308
 
1309
+ `provider:` is threaded through on every re-application of the effective `RunConfig` — including when a template's front matter is re-rendered mid-run — so a local-provider robot (Ollama, GPUStack, LM Studio) doesn't fall back to RubyLLM's static model registry lookup on later turns and raise a spurious "model not found" error.
1310
+
1311
+ Some local/thinking-mode models (e.g. `qwen3` on Ollama) route all of their output through reasoning content rather than the normal response text. When `response.content` is `nil`, `result.reply` falls back first to `response.thinking.text` (RubyLLM's extended-thinking text), then to the most recent assistant text from later in *the current turn only* — never a stale reply left over from a previous turn.
1312
+
816
1313
  ### Robot with MCP
817
1314
 
818
1315
  ```ruby
@@ -826,10 +1323,15 @@ robot = RobotLab.build(
826
1323
  }
827
1324
  ]
828
1325
  )
829
- result = robot.run("Search for popular Ruby repos")
1326
+
1327
+ # mcp: :inherit triggers the connection; tools: :inherit sends the discovered tools
1328
+ result = robot.run("Search for popular Ruby repos", mcp: :inherit, tools: :inherit)
830
1329
  robot.disconnect
831
1330
  ```
832
1331
 
1332
+ `transport:` must be a nested hash. MCP connection failures are logged and
1333
+ recorded in `robot.failed_mcp_server_names` — they are not raised.
1334
+
833
1335
  ### Robot with Skills
834
1336
 
835
1337
  ```ruby
@@ -931,7 +1433,7 @@ Every `robot.run()` returns a `RobotResult` with token counts for that call. The
931
1433
 
932
1434
  ```ruby
933
1435
  robot.reset_token_totals
934
- # => nil
1436
+ # => the robot itself (returns self, so it chains)
935
1437
  ```
936
1438
 
937
1439
  Reset the cumulative accounting counters to zero. Useful when you want to measure cost for a specific task batch while keeping the robot alive for the next batch.
@@ -958,6 +1460,26 @@ robot.reset_token_totals
958
1460
  puts robot.total_input_tokens # => 0
959
1461
  ```
960
1462
 
1463
+ ### Budgets
1464
+
1465
+ `token_budget:` and `cost_budget:` turn the counters above into enforceable ceilings, backed by a thread-safe `RobotLab::Budget::Ledger` (`robot.budget_ledger`, `nil` when neither is configured):
1466
+
1467
+ ```ruby
1468
+ robot = RobotLab.build(
1469
+ name: "capped",
1470
+ system_prompt: "...",
1471
+ token_budget: 10_000,
1472
+ cost_budget: 0.50
1473
+ )
1474
+ ```
1475
+
1476
+ Each `run()` reserves the remaining budget for every configured dimension before the LLM call, and reconciles the reservation with actual usage after:
1477
+
1478
+ - **`RobotLab::BudgetExceeded`** — raised up front when a *prior* call already exhausted a dimension; the new call is refused before it spends anything.
1479
+ - **`RobotLab::InferenceError`** — raised after the call when *this* call's actual usage (from `RobotResult#input_tokens`/`output_tokens`, and the response's reported cost when the provider supports pricing) pushes cumulative usage over budget. This is the same error `token_budget` alone has always raised; `cost_budget` uses the analogous message (`"Cost budget exceeded: $X used, budget is $Y"`).
1480
+
1481
+ See [Budgets](../../guides/observability.md#budgets-token-cost) for the full walkthrough.
1482
+
961
1483
  ## Tool Loop Circuit Breaker
962
1484
 
963
1485
  Set `max_tool_rounds:` to guard against a robot looping indefinitely through tool calls. After the limit is reached, `RobotLab::ToolLoopError` is raised.
@@ -984,7 +1506,13 @@ robot = RobotLab.build(name: "runner", system_prompt: "...", config: config)
984
1506
 
985
1507
  `RobotLab::ToolLoopError < RobotLab::InferenceError`
986
1508
 
987
- Raised when the number of tool calls in a single `run()` exceeds `max_tool_rounds`. The error message includes the limit that was exceeded.
1509
+ Raised when the number of tool calls in a single `run()` exceeds `max_tool_rounds`. The message reads:
1510
+
1511
+ ```
1512
+ Circuit breaker triggered: <N> tool calls exceeded max_tool_rounds (<M>)
1513
+ ```
1514
+
1515
+ where `N` is the call count that tripped the breaker and `M` is the configured limit.
988
1516
 
989
1517
  ### Recovery after ToolLoopError
990
1518
 
@@ -996,6 +1524,7 @@ After a `ToolLoopError`, the chat contains a dangling `tool_use` block with no m
996
1524
  begin
997
1525
  robot.run("Execute all steps.")
998
1526
  rescue RobotLab::ToolLoopError => e
1527
+ # "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
999
1528
  puts "Circuit breaker fired: #{e.message}"
1000
1529
  end
1001
1530
 
@@ -1007,9 +1536,34 @@ puts robot.config.max_tool_rounds # still set — config unchanged
1007
1536
  result = robot.run("Something new.")
1008
1537
  ```
1009
1538
 
1539
+ ## Doom Loop Detection
1540
+
1541
+ Distinct from the circuit breaker, doom-loop detection is **always on**. Every
1542
+ `run()` unconditionally installs a `RobotLab::DoomLoopDetector` over the chat's
1543
+ `execute_tool`, and removes it again when the run ends. `doom_loop_threshold:`
1544
+ only *tunes* it; it cannot be disabled from the constructor.
1545
+
1546
+ ```ruby
1547
+ robot = RobotLab.build(name: "worker", system_prompt: "...", doom_loop_threshold: 5)
1548
+ ```
1549
+
1550
+ | | |
1551
+ |---|---|
1552
+ | Default threshold | `RobotLab::DoomLoopDetector::DEFAULT_THRESHOLD` (3) |
1553
+ | Set via | `doom_loop_threshold:` constructor kwarg or `RunConfig#doom_loop_threshold` |
1554
+
1555
+ When a consecutive or cyclic repetition of the same tool name exceeds the
1556
+ threshold, the detector does **not** raise. It appends a self-correction warning
1557
+ to that tool's result so the model can change strategy: a `String` result gets
1558
+ `"\n\n⚠️ <warning>"` appended, and a `Hash` result gains a `:_doom_loop_warning`
1559
+ key. The detector then resets.
1560
+
1010
1561
  ## Learning Accumulation
1011
1562
 
1012
- `robot.learn(text)` records a cross-run observation. On each subsequent `run()`, active learnings are automatically prepended to the user message as a `LEARNINGS FROM PREVIOUS RUNS:` block.
1563
+ `robot.learn(text)` records a cross-run observation. On each subsequent `run()`,
1564
+ **all** accumulated learnings are prepended to the user message as a
1565
+ `LEARNINGS FROM PREVIOUS RUNS:` block. There is no active/inactive distinction —
1566
+ every entry in `robot.learnings` is injected.
1013
1567
 
1014
1568
  ### learn
1015
1569
 
@@ -1023,7 +1577,17 @@ Add a learning to the robot's accumulated observations. Learnings are automatica
1023
1577
  - If the new text is a substring of an existing learning, it is dropped (the existing broader learning already covers it).
1024
1578
  - If an existing learning is a substring of the new text, the narrower one is replaced.
1025
1579
 
1026
- Learnings are persisted to `memory[:learnings]` and survive a robot rebuild when the same `Memory` object is reused.
1580
+ Learnings are written to the robot's own memory under `memory[:learnings]`.
1581
+
1582
+ !!! note "Learnings do not survive process restart on their own"
1583
+ `initialize_memory` always constructs a fresh `Memory.new`, and there is no
1584
+ `memory:` constructor keyword, so a newly built robot starts with an empty
1585
+ `:learnings` key. `learn` reads back whatever is already in `memory[:learnings]`
1586
+ at construction, which means persistence requires an external store — for
1587
+ example the `robot_lab-durable` extension — to repopulate it.
1588
+
1589
+ `learn` runs inside the `:learn` hook family (`before_learn` / `around_learn` /
1590
+ `after_learn`, plus `on_learn`), so a hook handler can observe or veto the write.
1027
1591
 
1028
1592
  **Parameters:**
1029
1593
 
@@ -1084,6 +1648,18 @@ robot.learn("avoid using puts and p in production code")
1084
1648
  # => broader learning replaces narrower; robot.learnings.size == 1
1085
1649
  ```
1086
1650
 
1651
+ ## Runnable Protocol
1652
+
1653
+ `Robot` includes `RobotLab::Runnable`, the shared interface it has in common with `Network` — see [Runnable Protocol](../../architecture/core-concepts.md#runnable-protocol) for the full picture. For a single robot:
1654
+
1655
+ | Method | Returns |
1656
+ |--------|---------|
1657
+ | `crew` | `[self]` — a robot is a crew of one |
1658
+ | `chief` | `self` |
1659
+ | `robot_count` | `1` |
1660
+ | `network?` | `false` |
1661
+ | `single?` | `true` |
1662
+
1087
1663
  ## See Also
1088
1664
 
1089
1665
  - [Building Robots Guide](../../guides/building-robots.md) (includes [Composable Skills](../../guides/building-robots.md#composable-skills))