robot_lab 0.2.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) 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 +61 -0
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +1 -0
  10. data/docs/api/core/network.md +31 -1
  11. data/docs/api/core/robot.md +91 -5
  12. data/docs/api/errors.md +47 -2
  13. data/docs/api/index.md +3 -0
  14. data/docs/architecture/core-concepts.md +40 -0
  15. data/docs/architecture/network-orchestration.md +8 -0
  16. data/docs/architecture/robot-execution.md +1 -1
  17. data/docs/getting-started/configuration.md +20 -1
  18. data/docs/guides/building-robots.md +39 -1
  19. data/docs/guides/creating-networks.md +6 -1
  20. data/docs/guides/hooks.md +997 -0
  21. data/docs/guides/index.md +5 -0
  22. data/docs/guides/knowledge.md +2 -2
  23. data/docs/guides/observability.md +74 -7
  24. data/docs/guides/using-tools.md +69 -0
  25. data/docs/index.md +1 -1
  26. data/examples/03_network.rb +11 -2
  27. data/examples/34_agentskills.rb +0 -0
  28. data/examples/35_hooks.rb +256 -0
  29. data/examples/README.md +7 -0
  30. data/examples/common.rb +7 -3
  31. data/examples/xyzzy.rb +90 -0
  32. data/lib/robot_lab/agent_skill.rb +5 -4
  33. data/lib/robot_lab/budget/ledger.rb +98 -0
  34. data/lib/robot_lab/capabilities.rb +84 -0
  35. data/lib/robot_lab/config/defaults.yml +10 -0
  36. data/lib/robot_lab/config.rb +4 -4
  37. data/lib/robot_lab/error.rb +34 -4
  38. data/lib/robot_lab/errors.rb +45 -0
  39. data/lib/robot_lab/hook.rb +79 -0
  40. data/lib/robot_lab/hook_context.rb +194 -0
  41. data/lib/robot_lab/hook_registry.rb +55 -0
  42. data/lib/robot_lab/hooks.rb +87 -0
  43. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  44. data/lib/robot_lab/narrator.rb +87 -0
  45. data/lib/robot_lab/network.rb +77 -20
  46. data/lib/robot_lab/robot/budget.rb +89 -0
  47. data/lib/robot_lab/robot/bus_messaging.rb +76 -12
  48. data/lib/robot_lab/robot/hooking.rb +56 -0
  49. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  50. data/lib/robot_lab/robot/template_rendering.rb +2 -2
  51. data/lib/robot_lab/robot.rb +211 -88
  52. data/lib/robot_lab/run_config.rb +24 -5
  53. data/lib/robot_lab/runnable.rb +51 -0
  54. data/lib/robot_lab/sandbox/null.rb +13 -0
  55. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  56. data/lib/robot_lab/sandbox.rb +52 -0
  57. data/lib/robot_lab/script_tool.rb +60 -2
  58. data/lib/robot_lab/task.rb +26 -20
  59. data/lib/robot_lab/tool.rb +52 -11
  60. data/lib/robot_lab/version.rb +1 -1
  61. data/lib/robot_lab.rb +49 -0
  62. data/mkdocs.yml +1 -0
  63. metadata +36 -99
  64. data/examples/temp.md +0 -51
  65. data/site/404.html +0 -2300
  66. data/site/api/core/index.html +0 -2706
  67. data/site/api/core/memory/index.html +0 -3793
  68. data/site/api/core/network/index.html +0 -3500
  69. data/site/api/core/robot/index.html +0 -4566
  70. data/site/api/core/state/index.html +0 -3390
  71. data/site/api/core/tool/index.html +0 -3843
  72. data/site/api/index.html +0 -2635
  73. data/site/api/mcp/client/index.html +0 -3435
  74. data/site/api/mcp/index.html +0 -2783
  75. data/site/api/mcp/server/index.html +0 -3252
  76. data/site/api/mcp/transports/index.html +0 -3352
  77. data/site/api/messages/index.html +0 -2641
  78. data/site/api/messages/text-message/index.html +0 -3087
  79. data/site/api/messages/tool-call-message/index.html +0 -3159
  80. data/site/api/messages/tool-result-message/index.html +0 -3252
  81. data/site/api/messages/user-message/index.html +0 -3212
  82. data/site/api/streaming/context/index.html +0 -3282
  83. data/site/api/streaming/events/index.html +0 -3347
  84. data/site/api/streaming/index.html +0 -2738
  85. data/site/architecture/core-concepts/index.html +0 -3757
  86. data/site/architecture/index.html +0 -2797
  87. data/site/architecture/message-flow/index.html +0 -3238
  88. data/site/architecture/network-orchestration/index.html +0 -3433
  89. data/site/architecture/robot-execution/index.html +0 -3140
  90. data/site/architecture/state-management/index.html +0 -3498
  91. data/site/assets/css/custom.css +0 -56
  92. data/site/assets/images/favicon.png +0 -0
  93. data/site/assets/images/robot_lab.jpg +0 -0
  94. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  95. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  96. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  97. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  98. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  99. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  100. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  101. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  102. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  103. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  104. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  105. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  106. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  107. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  108. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  109. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  110. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  111. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  112. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  113. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  114. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  115. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  116. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  117. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  118. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  119. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  120. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  121. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  122. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  123. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  124. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  125. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  126. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  127. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  128. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  129. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  130. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  131. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  132. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  133. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  134. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  135. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  136. data/site/concepts/index.html +0 -3455
  137. data/site/examples/basic-chat/index.html +0 -2880
  138. data/site/examples/index.html +0 -2907
  139. data/site/examples/mcp-server/index.html +0 -3018
  140. data/site/examples/multi-robot-network/index.html +0 -3131
  141. data/site/examples/rails-application/index.html +0 -3329
  142. data/site/examples/tool-usage/index.html +0 -3085
  143. data/site/getting-started/configuration/index.html +0 -3745
  144. data/site/getting-started/index.html +0 -2572
  145. data/site/getting-started/installation/index.html +0 -2981
  146. data/site/getting-started/quick-start/index.html +0 -2942
  147. data/site/guides/building-robots/index.html +0 -4290
  148. data/site/guides/creating-networks/index.html +0 -3858
  149. data/site/guides/index.html +0 -2586
  150. data/site/guides/mcp-integration/index.html +0 -3581
  151. data/site/guides/memory/index.html +0 -3586
  152. data/site/guides/rails-integration/index.html +0 -4019
  153. data/site/guides/streaming/index.html +0 -3157
  154. data/site/guides/using-tools/index.html +0 -3802
  155. data/site/index.html +0 -2671
  156. data/site/search/search_index.json +0 -1
  157. data/site/sitemap.xml +0 -183
  158. data/site/sitemap.xml.gz +0 -0
  159. data/site/tags.json +0 -1
data/docs/guides/index.md CHANGED
@@ -42,6 +42,10 @@ If you're new to RobotLab, start here:
42
42
 
43
43
  Token tracking, circuit breakers, doom loop detection, auto compaction, and learning accumulation
44
44
 
45
+ - [:octicons-zap-24: **Hook System**](hooks.md)
46
+
47
+ Lifecycle hooks for instrumentation, response caching, extensions, and cross-cutting concerns
48
+
45
49
  - [:octicons-search-24: **Knowledge & Retrieval**](knowledge.md)
46
50
 
47
51
  Chat history search and embedding-based document store for RAG workflows
@@ -59,6 +63,7 @@ If you're new to RobotLab, start here:
59
63
  | [Streaming](streaming.md) | Real-time responses | 5 min |
60
64
  | [Memory](memory.md) | Shared data store | 5 min |
61
65
  | [Observability & Safety](observability.md) | Token tracking, circuit breaker, doom loop detection, auto compaction, learning loop | 10 min |
66
+ | [Hook System](hooks.md) | Lifecycle hooks, extensions, response caching, instrumentation | 15 min |
62
67
  | [Knowledge & Retrieval](knowledge.md) | Chat history search and embedding-based document store (RAG) | 10 min |
63
68
 
64
69
  ## Extension Gems
@@ -184,5 +184,5 @@ This gem bundles `fastembed` for ONNX-based embeddings. The `BAAI/bge-small-en-v
184
184
  ## See Also
185
185
 
186
186
  - [Observability Guide](observability.md)
187
- - [Example 25 — Chat History Search](../../examples/25_history_search.rb)
188
- - [Example 26 — Embedding-Based Document Store](../../examples/26_document_store.rb)
187
+ - [examples/25_history_search.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/25_history_search.rb) — Chat History Search
188
+ - [examples/26_document_store.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/26_document_store.rb) — Embedding-Based Document Store
@@ -10,6 +10,7 @@ Facilities that help you monitor, control, improve, and scale robot behaviour:
10
10
  - **Context Window Compression** — prune irrelevant history to stay within token budgets
11
11
  - **Convergence Detection** — detect when independent agents reach the same conclusion
12
12
  - **Structured Delegation** — synchronous inter-robot calls with duration and token metadata
13
+ - **Live Narration** — an opt-in console feed of what a robot is doing as it happens
13
14
 
14
15
  ---
15
16
 
@@ -91,6 +92,38 @@ result = fresh.run("Explain memoization.")
91
92
  puts result.input_tokens # smallest possible — no prior history
92
93
  ```
93
94
 
95
+ ### Budgets (Token & Cost)
96
+
97
+ Where token/cost tracking above is purely observational, `token_budget:` and `cost_budget:` make it enforceable — a `Robot` refuses to keep spending once a configured limit is reached:
98
+
99
+ ```ruby
100
+ robot = RobotLab.build(
101
+ name: "capped",
102
+ system_prompt: "You are a concise assistant.",
103
+ token_budget: 10_000, # cumulative input + output tokens
104
+ cost_budget: 0.50 # cumulative $ across all runs
105
+ )
106
+ ```
107
+
108
+ Enforcement happens in two layers, backed by a thread-safe `RobotLab::Budget::Ledger`:
109
+
110
+ - **Before the call** — `run()` reserves whatever remains of each configured dimension. If a *prior* call already exhausted the budget, the reservation raises `RobotLab::BudgetExceeded` immediately, refusing the call outright before spending anything on it.
111
+ - **After the call** — actual usage (tokens from the result, cost from the response, when the provider reports pricing) replaces the reservation. If *this* call's actual usage pushes cumulative usage over budget, `RobotLab::InferenceError` is raised (unavoidable for the call that causes the overage, since totals aren't known until the response comes back) — the same error `token_budget` alone has always raised.
112
+
113
+ ```ruby
114
+ begin
115
+ robot.run("Another expensive task")
116
+ rescue RobotLab::BudgetExceeded => e
117
+ puts e.message # "budget exceeded for cost: 0.51 > 0.5" — refused before spending
118
+ rescue RobotLab::InferenceError => e
119
+ puts e.message # "Cost budget exceeded: $0.5231 used, budget is $0.5" — this call pushed it over
120
+ end
121
+ ```
122
+
123
+ A dimension with no configured limit (e.g. `cost_budget` when only `token_budget` is set) is treated as unlimited and never raises. Both fields are also available on `RunConfig` and cascade through the same global → network → robot hierarchy as other infrastructure fields (see [RunConfig](../getting-started/configuration.md#runconfig-shared-operational-defaults)).
124
+
125
+ This is a native alternative to the hand-rolled `BudgetHook` pattern in the [Hooks guide](hooks.md#cost-enforcement) for the common case of a per-robot token or dollar ceiling; reach for a hook instead when you need cross-robot session totals or custom accounting.
126
+
94
127
  ---
95
128
 
96
129
  ## Tool Loop Circuit Breaker
@@ -606,13 +639,47 @@ puts "#{analysis.robot_name} (#{analysis.duration.round(2)}s): #{analysis.reply}
606
639
 
607
640
  ---
608
641
 
642
+ ## Live Narration (`RobotLab::Narrator`)
643
+
644
+ `RobotLab::Narrator` is an opt-in [Hook](hooks.md) that narrates what a robot is doing as it happens, to `$stderr` (or any `IO`), so a run is never silent between events. It complements `RobotLab::Audit` (the `robot_lab-audit` gem, which records a persistent history for post-mortem analysis) with a live, human-facing console feed — the two are independent hooks and can both be registered at once.
645
+
646
+ Enable it globally (applies to every robot run, including networks):
647
+
648
+ ```ruby
649
+ RobotLab::Narrator.enable! # narrate to $stderr
650
+ RobotLab::Narrator.enable!(output: $stdout) # or any IO
651
+ ```
652
+
653
+ Or register it like any hook for a narrower scope:
654
+
655
+ ```ruby
656
+ robot.on(RobotLab::Narrator)
657
+ network.on(RobotLab::Narrator)
658
+ ```
659
+
660
+ Once registered, a run prints a line per event:
661
+
662
+ ```
663
+ · math_bot: thinking…
664
+ · → calculate operation="add"
665
+ · math_bot: thinking…
666
+ ```
667
+
668
+ - Before each LLM call: `"<robot name>: thinking…"`
669
+ - Before each tool call: `"→ <tool name> <first arg>=<value>"` — only the *first* argument is shown, truncated to 80 characters
670
+ - After a tool call: `" ✗ <error message>"` — printed only when the tool raised; silent on success
671
+
672
+ Narrator uses `IO#puts` rather than `Kernel#warn`, since `warn` is silenced whenever Ruby warnings are disabled (`$VERBOSE` is `nil`, the common case under `bundle exec`). All three hooks rescue internally, so a narration failure never breaks the underlying run.
673
+
674
+ ---
675
+
609
676
  ## See Also
610
677
 
611
- - [Robot API](../api/core/robot.md#token--cost-tracking)
612
- - [Example 19 — Token & Cost Tracking](../../examples/19_token_tracking.rb)
613
- - [Example 20 — Tool Loop Circuit Breaker](../../examples/20_circuit_breaker.rb)
614
- - [Example 21 — Learning Accumulation Loop](../../examples/21_learning_loop.rb)
615
- - [Example 22 — Context Window Compression](../../examples/22_context_compression.rb)
616
- - [Example 23 — Convergence Detection](../../examples/23_convergence.rb)
617
- - [Example 24 — Structured Delegation](../../examples/24_structured_delegation.rb)
678
+ - [Robot API](../api/core/robot.md#token-cost-tracking)
679
+ - [examples/19_token_tracking.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/19_token_tracking.rb) — Token & Cost Tracking
680
+ - [examples/20_circuit_breaker.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/20_circuit_breaker.rb) — Tool Loop Circuit Breaker
681
+ - [examples/21_learning_loop.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/21_learning_loop.rb) — Learning Accumulation Loop
682
+ - [examples/22_context_compression.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/22_context_compression.rb) — Context Window Compression
683
+ - [examples/23_convergence.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/23_convergence.rb) — Convergence Detection
684
+ - [examples/24_structured_delegation.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/24_structured_delegation.rb) — Structured Delegation
618
685
  - [RunConfig reference](../getting-started/configuration.md#runconfig-shared-operational-defaults)
@@ -133,6 +133,15 @@ robot = RobotLab.build(
133
133
  )
134
134
  ```
135
135
 
136
+ `local_tools:` and `tools:` are different mechanisms — don't confuse them. `local_tools:` attaches tool **instances or classes**; `tools:` (see [Runtime Tool Filtering](#runtime-tool-filtering) below) is a **name allowlist** that filters which of the already-attached tools are sent for a given turn. Passing an instance or class to `tools:` raises `ArgumentError` immediately, naming the offending class and pointing you at `local_tools:` instead:
137
+
138
+ ```ruby
139
+ RobotLab.build(name: "bot", tools: [GetWeather.new])
140
+ # => ArgumentError: `tools:` expects tool names (String/Symbol) to allow, but
141
+ # received GetWeather. To attach tool instances or classes, pass them as
142
+ # `local_tools:` (e.g. RobotLab.build(local_tools: [MyTool.new])).
143
+ ```
144
+
136
145
  ### Via Template Front Matter
137
146
 
138
147
  Declare tool class names in the template's YAML front matter. RobotLab resolves each string to a Ruby constant via `Object.const_get` and instantiates it:
@@ -163,6 +172,66 @@ robot = RobotLab.build(name: "assistant", system_prompt: "...")
163
172
  robot.with_tools(GetWeather, CalculatorTool)
164
173
  ```
165
174
 
175
+ ## Runtime Tool Filtering
176
+
177
+ `tools:` (and `mcp:`) also work as a **per-run** override, passed to `run()` itself, on top of the build-time/network/global hierarchy described in [Hierarchical MCP and Tools](../getting-started/configuration.md#hierarchical-mcp-and-tools):
178
+
179
+ ```ruby
180
+ robot.run("What's the weather?", tools: :inherit) # every attached tool
181
+ robot.run("Just chat, no tools needed.", tools: :none) # zero tools this turn
182
+ robot.run("Only use the calculator.", tools: %w[calculator]) # allowlist by name
183
+ ```
184
+
185
+ | Value | Meaning |
186
+ |-------|---------|
187
+ | `:inherit` | No filter — use every tool attached at the robot/network/global level |
188
+ | `:none`, `[]`, `nil` | Send **zero** tools this turn |
189
+ | `["name", ...]` | Only these tool names, by exact match |
190
+
191
+ An explicit `:none`/`[]` is useful for a relevance filter that decided no tool is useful for the current message — it now genuinely sends zero tools for that turn (previously an empty allowlist was silently treated as "all tools," which could overflow small-context local models with the full tool set). Each turn's resolved tool set fully **replaces** the chat's tools rather than accumulating, so a later `:none` turn correctly clears whatever a prior turn attached.
192
+
193
+ > **Watch the default.** Both `Robot.new`'s and `run()`'s `tools:`/`mcp:` parameters default to `:none`, not `:inherit`. If you build a robot with `local_tools:` and then call `robot.run(message)` with no `tools:` override at all, the current runtime default takes the explicit-`:none` path above — sending no tools for that turn. Pass `tools: :inherit` explicitly (at build time, per network task, or per `run()` call — wherever fits your call site) anywhere you need the robot's attached tools available.
194
+
195
+ ### Tool Capping and Per-Turn Filtering
196
+
197
+ Most LLM providers reject a tool array longer than 128 entries and fail the whole turn. RobotLab clamps the fully-resolved tool list to a ceiling right before handing it to the chat provider — the definitive choke point regardless of how the tools were configured, filtered, or MCP-connected:
198
+
199
+ ```ruby
200
+ robot = RobotLab.build(
201
+ name: "power_user",
202
+ system_prompt: "...",
203
+ local_tools: many_tools, # say, 150 tools
204
+ config: RobotLab::RunConfig.new(max_tools: 50) # override the default ceiling
205
+ )
206
+ ```
207
+
208
+ - Default ceiling: **128** tools per turn (`RobotLab::Robot::DEFAULT_MAX_TOOLS`)
209
+ - Override with `max_tools:` on `RunConfig` (or the `max_tools:` cascade field — see [Available Fields](../getting-started/configuration.md#available-fields)); `nil` or `<= 0` disables the cap
210
+ - When a turn's resolved tools exceed the cap, RobotLab logs a warning naming how many were dropped and sends the first `max_tools` entries
211
+
212
+ ## Skill Scripts and Sandboxing
213
+
214
+ A skill bundle (a directory with a `SKILL.md` plus `scripts/`, discovered via `AgentSkill`) can expose its scripts as tools (`ScriptTool`). Because those scripts run as real OS processes, each `SKILL.md` can declare the capabilities its scripts need directly in front matter, alongside `name`/`description`:
215
+
216
+ ```markdown title="skills/deploy-checker/SKILL.md"
217
+ ---
218
+ name: deploy-checker
219
+ description: Verifies a deployment's health before promoting it.
220
+ fs_read: ["./data", "/etc/hosts"]
221
+ fs_write: ["./out"]
222
+ network: true
223
+ timeout: 30
224
+ trust: external # or "core" for trusted, always-unconfined skills
225
+ ---
226
+ ```
227
+
228
+ Sandboxing itself is **opt-in and off by default** — see the [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section). When disabled, scripts run exactly as they always have, unconfined. When enabled:
229
+
230
+ - The global `sandbox:` config is a **ceiling** (`fs_read`, `fs_write`, `network`, `timeout`); each skill's front matter is its **declared** request. The script actually runs under the **intersection** of the two — a path outside the ceiling's roots is dropped even if the skill declares it, `network` requires both sides to allow it, and `timeout` is the smaller of the two.
231
+ - On macOS, confinement is enforced with a generated `sandbox-exec` (Seatbelt) profile: deny-by-default, with narrow allowances for the interpreter to boot, the granted read/write paths, and (optionally) the network. Notably, `$HOME` is never implicitly readable — SSH keys and cloud credentials stay out of reach unless a path under `$HOME` is explicitly granted.
232
+ - Off macOS, or for any skill declaring `trust: core`, sandboxing is a passthrough — confinement is currently macOS-only and is always skipped for trusted "core" skills regardless of platform.
233
+ - A script that runs past its `timeout` is killed (its whole process group) and reported back to the LLM as a timed-out error rather than hanging the turn.
234
+
166
235
  ## Parameter Types
167
236
 
168
237
  Define parameters on `RubyLLM::Tool` subclasses using `param`:
data/docs/index.md CHANGED
@@ -50,7 +50,7 @@ Each robot is backed by a persistent LLM chat, configured with keyword arguments
50
50
 
51
51
  Mix reusable prompt behaviors into any robot. Skills expand depth-first with automatic cycle detection and config cascading.
52
52
 
53
- [:octicons-arrow-right-24: Skills Guide](guides/building-robots.md#skills)
53
+ [:octicons-arrow-right-24: Skills Guide](guides/building-robots.md#composable-skills)
54
54
 
55
55
  - :material-tools:{ .lg .middle } **Extensible Tools**
56
56
 
@@ -11,6 +11,8 @@
11
11
 
12
12
  require_relative "common"
13
13
 
14
+ SPECIALIST_TASKS = %i[billing technical general].freeze
15
+
14
16
  # Classifier robot that activates the appropriate specialist
15
17
  class ClassifierRobot < RobotLab::Robot
16
18
  def call(result)
@@ -76,8 +78,13 @@ network = RobotLab.create_network(name: "support_network", config: shared_config
76
78
  end
77
79
 
78
80
  banner "Multi-Robot Network"
79
- puts "Network structure:"
80
- puts network.visualize
81
+ puts "Routing plan:"
82
+ puts " Entry task: classifier"
83
+ puts " Optional specialists: #{SPECIALIST_TASKS.join(', ')}"
84
+ puts " Runtime behavior: classifier activates exactly one specialist"
85
+ puts
86
+ puts "Underlying SimpleFlow structure:"
87
+ puts " Optional tasks: #{network.to_h[:optional_tasks].join(', ')}"
81
88
  hr
82
89
 
83
90
  # Run the network with a billing question
@@ -85,6 +92,8 @@ result = network.run(message: "I was charged twice for my subscription last mont
85
92
 
86
93
  # Display results
87
94
  puts "Network: #{network.name}"
95
+ puts "Executed tasks: #{result.context.keys.grep_v(:run_params).join(', ')}"
96
+ puts "Skipped specialists: #{(SPECIALIST_TASKS - result.context.keys).join(', ')}"
88
97
  puts "\nConversation flow:"
89
98
 
90
99
  # Show classifier result
File without changes
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 35: Hooks Architecture with robot_lab-xyzzy
5
+ #
6
+ # Demonstrates the hook handler class pattern. Hooks are named classes that
7
+ # inherit from RobotLab::Hook and implement class methods for each lifecycle
8
+ # phase they care about. All hooks are Ractor-safe by default.
9
+ #
10
+ # Usage:
11
+ # bundle exec ruby examples/35_hooks.rb
12
+
13
+ require_relative "common"
14
+ require_relative "xyzzy"
15
+
16
+ XYZZY_LOG = File.join(__dir__, "xyzzy_hooks.log")
17
+ RobotLab::Xyzzy.logger = Logger.new(XYZZY_LOG)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Handler classes used in this demo
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class RuntimeDemoHook < RobotLab::Hook
24
+ self.namespace = :runtime_demo
25
+
26
+ def self.after_run(ctx)
27
+ ctx.local.reply_seen = ctx.response.reply
28
+ end
29
+ end
30
+
31
+ class LocalDemoHook < RobotLab::Hook
32
+ self.namespace = :local_demo
33
+
34
+ def self.before_run(ctx)
35
+ ctx.local.note = "local robot hook state"
36
+ ctx.local.demo = "robot_run"
37
+ end
38
+ end
39
+
40
+ class PerfHook < RobotLab::Hook
41
+ self.namespace = :perf
42
+ @timings = []
43
+ @mutex = Mutex.new
44
+
45
+ class << self
46
+ def reset!
47
+ @mutex.synchronize { @timings = [] }
48
+ end
49
+
50
+ def timings
51
+ @mutex.synchronize { @timings.dup }
52
+ end
53
+
54
+ def around_run(ctx, &block)
55
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ result = block.call
57
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1_000).round(3)
58
+ @mutex.synchronize { @timings << elapsed }
59
+ puts " [perf] #{ctx.request.inspect} — #{elapsed}ms"
60
+ result
61
+ end
62
+ end
63
+ end
64
+
65
+ class GenerationTracerHook < RobotLab::Hook
66
+ self.namespace = :tracer
67
+
68
+ def self.before_llm_generation(ctx)
69
+ puts " [tracer] llm_generation start request=#{ctx.request.inspect}"
70
+ end
71
+
72
+ def self.after_llm_generation(ctx)
73
+ msg = ctx.generation_response
74
+ puts " [tracer] llm_generation done model=#{msg&.model_id} tokens=#{msg&.output_tokens}"
75
+ end
76
+ end
77
+
78
+ class GenCacheHook < RobotLab::Hook
79
+ self.namespace = :gen_cache
80
+ @cache = {}
81
+ @mutex = Mutex.new
82
+ @call_count = 0
83
+
84
+ class << self
85
+ def reset!
86
+ @mutex.synchronize do
87
+ @cache = {}
88
+ @call_count = 0
89
+ end
90
+ end
91
+
92
+ def call_count
93
+ @mutex.synchronize { @call_count }
94
+ end
95
+
96
+ def around_llm_generation(ctx, &block)
97
+ cached = @mutex.synchronize { @cache[ctx.request] }
98
+ if cached
99
+ puts " [gen_cache] HIT — skipping LLM"
100
+ cached
101
+ else
102
+ puts " [gen_cache] MISS — calling LLM"
103
+ @mutex.synchronize { @call_count += 1 }
104
+ block.call.tap { |r| @mutex.synchronize { @cache[ctx.request] = r } }
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ class NetworkDemoHook < RobotLab::Hook
111
+ self.namespace = :network_demo
112
+
113
+ def self.before_task(ctx)
114
+ ctx.local.task_note = "network-scoped task hook"
115
+ end
116
+ end
117
+
118
+ # ---------------------------------------------------------------------------
119
+
120
+ HookDemoResponse = Data.define(:content, :tool_calls, :stop_reason) do
121
+ def initialize(content:, tool_calls: nil, stop_reason: "end_turn")
122
+ super
123
+ end
124
+ end
125
+
126
+ class HookDemoTool < RobotLab::Tool
127
+ description "Returns a deterministic hook demo value"
128
+ param :label, type: "string", desc: "The label to echo"
129
+
130
+ def execute(label:)
131
+ { label: label, status: "handled by HookDemoTool" }
132
+ end
133
+ end
134
+
135
+ class HookDemo
136
+ def run
137
+ banner "Hooks Architecture with robot_lab-xyzzy"
138
+
139
+ explain_setup
140
+ run_robot
141
+ run_llm_loop
142
+ run_tool
143
+ run_network
144
+ run_error
145
+
146
+ hr
147
+ puts "Hook demo complete."
148
+ end
149
+
150
+ private
151
+
152
+ def explain_setup
153
+ section "Extension Registration"
154
+ puts <<~TEXT
155
+ xyzzy.rb is a single-file hook extension loaded from examples/.
156
+
157
+ It is a RobotLab::Hook subclass registered under namespace :#{RobotLab::Xyzzy.namespace}.
158
+ stdout : [xyzzy] timestamp hook_name (one line per call)
159
+ logfile : full context snapshot → #{XYZZY_LOG}
160
+ TEXT
161
+ end
162
+
163
+ def run_robot
164
+ section "Robot Run Hooks"
165
+ robot = hooked_robot("hook_demo_robot")
166
+ robot.on(LocalDemoHook)
167
+
168
+ result = robot.run("show the hook pipeline", hooks: RuntimeDemoHook)
169
+
170
+ puts "Robot reply: #{result.reply}"
171
+ end
172
+
173
+ def run_llm_loop
174
+ section "LLM Request/Response Loop"
175
+ puts <<~TEXT
176
+ Demonstrates before/around/after_llm_generation hooks.
177
+ GenCacheHook implements around_llm_generation: on a cache hit the block
178
+ (the real LLM call) is never invoked. PerfHook times each robot.run call.
179
+ TEXT
180
+
181
+ GenCacheHook.reset!
182
+ PerfHook.reset!
183
+
184
+ puts " provider=#{LLM[:default].provider} model=#{LLM[:default].model}\n\n"
185
+
186
+ robot = RobotLab.build(
187
+ name: "loop_demo_robot",
188
+ system_prompt: "You are a helpful assistant. Answer every question in one concise sentence."
189
+ ).with_model(LLM[:default].model)
190
+
191
+ robot.on(PerfHook)
192
+ robot.on(GenerationTracerHook)
193
+ robot.on(GenCacheHook)
194
+
195
+ queries = [
196
+ "what is the boiling point of water?",
197
+ "what is 6 * 7?",
198
+ "what is the boiling point of water?",
199
+ "what is 6 * 7?"
200
+ ]
201
+
202
+ queries.each_with_index do |query, i|
203
+ puts "\n--- query #{i + 1}: #{query.inspect} ---"
204
+ result = robot.run(query)
205
+ puts " reply: #{result.reply}"
206
+ end
207
+
208
+ timings = PerfHook.timings
209
+ cache_hits = queries.size - GenCacheHook.call_count
210
+ avg_ms = (timings.sum / timings.size).round(3)
211
+ puts "\n--- summary ---"
212
+ puts " robot.run calls : #{queries.size}"
213
+ puts " LLM calls made : #{GenCacheHook.call_count} (#{cache_hits} served from gen_cache)"
214
+ puts " avg run time : #{avg_ms}ms"
215
+ end
216
+
217
+ def run_tool
218
+ section "Tool Call Hooks"
219
+ tool = HookDemoTool.new
220
+ result = tool.call({ "label" => "tool hook payload" })
221
+ puts "Tool result: #{result.inspect}"
222
+ end
223
+
224
+ def run_network
225
+ section "Network and Task Hooks"
226
+ network = RobotLab::Network.new(name: "hook_demo_network")
227
+ network.on(NetworkDemoHook)
228
+ network.task(:summarize, hooked_robot("network_worker"), depends_on: :none)
229
+
230
+ result = network.run(message: "summarize hook activity")
231
+ reply = result.respond_to?(:reply) ? result.reply : result&.value&.reply
232
+ puts "Network result: #{reply}"
233
+ end
234
+
235
+ def run_error
236
+ section "Error Hook"
237
+ failing_robot = RobotLab.build(name: "failing_hook_robot", system_prompt: "raise")
238
+ failing_robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |_message = nil, **_kwargs|
239
+ raise "planned hook demo failure"
240
+ end
241
+
242
+ failing_robot.run("trigger the on_error hook")
243
+ rescue RuntimeError => e
244
+ puts "Caught expected error: #{e.message}"
245
+ end
246
+
247
+ def hooked_robot(name)
248
+ RobotLab.build(name: name, system_prompt: "deterministic hook demo").tap do |robot|
249
+ robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |message = nil, **_kwargs, &_block|
250
+ HookDemoResponse.new(content: "deterministic response to #{message.inspect}")
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ HookDemo.new.run
data/examples/README.md CHANGED
@@ -58,6 +58,7 @@ examples/
58
58
  29_ractor_tools.rb # Ractor-safe tools: worker pool, freeze_deep, parallel batch
59
59
  30_ractor_network.rb # Ractor network scheduler: dependency waves, parallel_mode
60
60
  31_launch_assessment.rb # 6 parallel analysts, max_concurrent_robots: 4 semaphore cap
61
+ 35_hooks.rb # Hook architecture demo using robot_lab-xyzzy
61
62
  18_rails/ # Minimal Rails 8 demo app (full integration)
62
63
  app/robots/chat_robot.rb # Robot factory with system prompt + TimeTool
63
64
  app/tools/time_tool.rb # Custom RobotLab::Tool subclass
@@ -306,6 +307,12 @@ Demonstrates: `max_concurrent_robots:` on `RunConfig`, `Async::Semaphore` back-p
306
307
 
307
308
  **Requires:** LLM API key
308
309
 
310
+ ### 35 — Hooks Architecture
311
+
312
+ Loads the local `robot_lab-xyzzy` extension, which registers for every hook and logs each callback with the context it receives. Demonstrates robot run, LLM generation, tool call, network run, task, and error hooks with deterministic stubbed responses.
313
+
314
+ **Requires:** None (no LLM calls)
315
+
309
316
  ### 18 — Rails Integration Demo
310
317
 
311
318
  A minimal, hand-built Rails 8 app that exercises every piece of RobotLab's Rails integration end-to-end. No `rails new` — every file is hand-crafted for minimum size.
data/examples/common.rb CHANGED
@@ -10,14 +10,18 @@ require_relative "../lib/robot_lab"
10
10
  LlmConfig = Data.define(:provider, :model)
11
11
 
12
12
  LLM = {
13
- default: LlmConfig.new(provider: "openai", model: "gpt-5.4"),
13
+ default: LlmConfig.new(provider: "openai", model: "gpt-4.1-mini"),
14
14
  local: LlmConfig.new(provider: "ollama", model: "llama3.2"),
15
15
  anthropic: LlmConfig.new(provider: "anthropic", model: "claude-opus-4-7")
16
16
  }.freeze
17
17
 
18
18
  RubyLLM.configure do |c|
19
- c.logger = Logger.new(File::NULL)
20
- c.default_model = LLM[:default].model
19
+ c.logger = Logger.new(File::NULL)
20
+ c.default_model = LLM[:default].model
21
+ c.openai_api_key = ENV['OPENAI_API_KEY']
22
+ c.openai_organization_id = ENV['OPENAI_ORGANIZATION_ID']
23
+ c.openai_project_id = ENV['OPENAI_PROJECT_ID']
24
+ c.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
21
25
  end
22
26
 
23
27
  RobotLab.configure do |c|
data/examples/xyzzy.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # xyzzy.rb — single-file RobotLab hook extension demo
4
+ #
5
+ # Demonstrates the Hook handler class pattern: implement class methods for
6
+ # every hook under a dedicated namespace and log each callback with its
7
+ # context snapshot. Useful as a live trace of the full hook pipeline during
8
+ # development.
9
+ #
10
+ # Fully Ractor-safe: no class-level mutable state. LOG_PATH is a frozen
11
+ # string constant (shareable); each call opens its own file handle.
12
+ #
13
+ # stdout : one tagline per hook call → [xyzzy] HH:MM:SS.mmm hook_name
14
+ # logfile: full context snapshot written to LOG_PATH via PP.pp
15
+ #
16
+ # Usage (from any example that requires common):
17
+ # require_relative "xyzzy"
18
+
19
+ require "pp"
20
+ require "fileutils"
21
+
22
+ module RobotLab
23
+ class Xyzzy < Hook
24
+ self.namespace = :xyzzy
25
+
26
+ LOG_PATH = File.expand_path("~/.robot_lab/xyzzy_hooks.log").freeze
27
+ FileUtils.mkdir_p(File.dirname(LOG_PATH))
28
+
29
+ class << self
30
+ def before_run(ctx) = log_hook(:before_run, ctx)
31
+ def after_run(ctx) = log_hook(:after_run, ctx)
32
+ def on_error(ctx) = log_hook(:on_error, ctx)
33
+ def before_llm_generation(ctx) = log_hook(:before_llm_generation, ctx)
34
+ def after_llm_generation(ctx) = log_hook(:after_llm_generation, ctx)
35
+ def before_tool_call(ctx) = log_hook(:before_tool_call, ctx)
36
+ def after_tool_call(ctx) = log_hook(:after_tool_call, ctx)
37
+ def before_network_run(ctx) = log_hook(:before_network_run, ctx)
38
+ def after_network_run(ctx) = log_hook(:after_network_run, ctx)
39
+ def before_task(ctx) = log_hook(:before_task, ctx)
40
+ def after_task(ctx) = log_hook(:after_task, ctx)
41
+
42
+ def around_run(ctx, &block)
43
+ log_hook(:around_run, ctx)
44
+ block.call
45
+ end
46
+
47
+ def around_llm_generation(ctx, &block)
48
+ log_hook(:around_llm_generation, ctx)
49
+ block.call
50
+ end
51
+
52
+ def around_tool_call(ctx, &block)
53
+ log_hook(:around_tool_call, ctx)
54
+ block.call
55
+ end
56
+
57
+ def around_network_run(ctx, &block)
58
+ log_hook(:around_network_run, ctx)
59
+ block.call
60
+ end
61
+
62
+ def around_task(ctx, &block)
63
+ log_hook(:around_task, ctx)
64
+ block.call
65
+ end
66
+
67
+ private
68
+
69
+ def log_hook(hook_name, ctx)
70
+ ts = Time.now.strftime('%H:%M:%S.%3N')
71
+ snapshot = context_snapshot(ctx)
72
+ $stdout.puts " [xyzzy] #{ts} #{hook_name}"
73
+ File.open(LOG_PATH, "a") { |f| f.puts "#{ts} #{hook_name} | #{PP.pp(snapshot, +"").chomp}" }
74
+ end
75
+
76
+ def context_snapshot(ctx)
77
+ {
78
+ robot: ctx.respond_to?(:robot) ? ctx.robot&.name : nil,
79
+ request: ctx.respond_to?(:request) ? ctx.request : nil,
80
+ network: ctx.respond_to?(:network) ? ctx.network&.name : nil,
81
+ task: ctx.respond_to?(:task) ? ctx.task : nil,
82
+ error: ctx.respond_to?(:error) ? ctx.error&.message : nil
83
+ }.compact
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ RobotLab.register_extension(:xyzzy, RobotLab::Xyzzy) if RobotLab.respond_to?(:register_extension)
90
+ RobotLab.on(RobotLab::Xyzzy)