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
@@ -0,0 +1,997 @@
1
+ # Hook System
2
+
3
+ RobotLab's hook system lets you intercept any point in a robot's execution pipeline — before, around, or after every LLM call, tool invocation, network run, or task — without modifying core framework code. Hooks are implemented as handler classes: subclasses of `RobotLab::Hook` that define lifecycle callbacks as class methods. Hooks are the intended mechanism for building extensions, middleware, instrumentation, and any other cross-cutting concern. They compose safely: multiple registrations at different levels all fire in order, and each handler class owns its own isolated state.
4
+
5
+ ---
6
+
7
+ ## Hook Families
8
+
9
+ There are seven hook families. Each family has `before_*`, `around_*`, and `after_*` variants. The `:run`, `:network_run`, and `:task` families additionally have `on_error`. The `:compaction` and `:learn` families additionally have point hooks (`on_compaction` and `on_learn`) that allow extensions to replace or augment the core behaviour.
10
+
11
+ | Family | Hook names | Fires during |
12
+ |--------|-----------|-------------|
13
+ | `:run` | `before_run`, `around_run`, `after_run`, `on_error` | every `robot.run(...)` call |
14
+ | `:llm_generation` | `before_llm_generation`, `around_llm_generation`, `after_llm_generation` | each LLM API call within a run (may fire multiple times when tool calls loop) |
15
+ | `:tool_call` | `before_tool_call`, `around_tool_call`, `after_tool_call` | each tool invocation |
16
+ | `:network_run` | `before_network_run`, `around_network_run`, `after_network_run`, `on_error` | every `network.run(...)` call |
17
+ | `:task` | `before_task`, `around_task`, `after_task`, `on_error` | each robot task within a network run |
18
+ | `:compaction` | `before_compaction`, `around_compaction`, `after_compaction`, `on_compaction` | when conversation history is about to be compressed |
19
+ | `:learn` | `before_learn`, `around_learn`, `after_learn`, `on_learn` | every `robot.learn(text)` call that carries non-empty text |
20
+
21
+ Within a single `robot.run(...)` that triggers two tool calls and one compaction, the firing order is:
22
+
23
+ ```
24
+ before_run
25
+ around_run {
26
+ before_llm_generation
27
+ around_llm_generation {
28
+ before_compaction
29
+ around_compaction {
30
+ on_compaction # only fires if a handler is registered
31
+ [compress history]
32
+ }
33
+ after_compaction
34
+ [LLM call 1]
35
+ }
36
+ after_llm_generation
37
+ before_tool_call
38
+ around_tool_call { [tool invocation] }
39
+ after_tool_call
40
+ before_llm_generation
41
+ around_llm_generation { [LLM call 2] }
42
+ after_llm_generation
43
+ }
44
+ after_run
45
+ ```
46
+
47
+ Compaction hooks fire at most once per LLM call, only when the compaction threshold is actually exceeded (or a custom `Proc` strategy is configured). They do not fire on every `run` invocation.
48
+
49
+ The `:learn` family fires synchronously inside `robot.learn(text)`, once per call. Hooks do not fire when `text` is blank.
50
+
51
+ ---
52
+
53
+ ## Handler Classes
54
+
55
+ All hook logic is implemented as a subclass of `RobotLab::Hook`. Lifecycle callbacks are defined as `class << self` methods — one method per hook name. Any method that is not defined is silently skipped when that hook fires.
56
+
57
+ ```ruby
58
+ class MyHook < RobotLab::Hook
59
+ class << self
60
+ def before_run(ctx)
61
+ # fires before every robot.run call
62
+ end
63
+
64
+ def after_run(ctx)
65
+ # fires after every robot.run call
66
+ end
67
+
68
+ def on_error(ctx)
69
+ # fires when an unhandled exception escapes a run
70
+ end
71
+ end
72
+ end
73
+ ```
74
+
75
+ ### Namespace auto-derivation
76
+
77
+ Every handler class has a namespace that isolates its `ctx.local` state from other handlers. The namespace is derived automatically from the class name by snake_casing the final segment:
78
+
79
+ | Class name | Auto namespace |
80
+ |-----------|---------------|
81
+ | `TimerHook` | `:timer_hook` |
82
+ | `AuditHook` | `:audit_hook` |
83
+ | `PerfMonitor` | `:perf_monitor` |
84
+ | `MyExt::Tracer` | `:tracer` |
85
+
86
+ Override the auto-derived namespace at the class level:
87
+
88
+ ```ruby
89
+ class TimerHook < RobotLab::Hook
90
+ self.namespace = :timer # use :timer instead of :timer_hook
91
+ end
92
+ ```
93
+
94
+ ### `RobotLab::Hook` base class
95
+
96
+ ```ruby
97
+ class Hook
98
+ class << self
99
+ attr_writer :namespace
100
+
101
+ def namespace
102
+ return @namespace if @namespace
103
+ return nil if self == Hook
104
+ name.split('::').last
105
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
106
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
107
+ .downcase.to_sym
108
+ end
109
+
110
+ def call(hook_name, context, &block)
111
+ if singleton_class.public_method_defined?(hook_name)
112
+ block ? public_send(hook_name, context, &block) : public_send(hook_name, context)
113
+ elsif block
114
+ block.call
115
+ end
116
+ end
117
+ end
118
+ end
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Registration Levels
124
+
125
+ Hooks are registered on three objects and can optionally be scoped to a single call:
126
+
127
+ | Level | Registration | Scope |
128
+ |-------|-------------|-------|
129
+ | **Global** | `RobotLab.on(...)` | Every robot, every network |
130
+ | **Network** | `network.on(...)` | Only robots inside that network |
131
+ | **Robot** | `robot.on(...)` | Only that robot |
132
+ | **Per-run** | `robot.run("msg", hooks: ...)` | A single `run` call |
133
+
134
+ All four levels are additive. When a run fires, every matching registration executes in order: global → network → robot → per-run. There is no way to suppress an outer registration from an inner one.
135
+
136
+ ---
137
+
138
+ ## The `on` Method
139
+
140
+ All three registration objects share the same signature:
141
+
142
+ ```ruby
143
+ RobotLab.on(handler_class, context: nil)
144
+ network.on(handler_class, context: nil)
145
+ robot.on(handler_class, context: nil)
146
+ ```
147
+
148
+ | Parameter | Type | Description |
149
+ |-----------|------|-------------|
150
+ | `handler_class` | Class | A subclass of `RobotLab::Hook` |
151
+ | `context:` | Hash\|nil | Default state pre-populated into the handler's namespace `DotState` before each callback fires |
152
+
153
+ `handler_class` must be a subclass of `RobotLab::Hook`. The namespace is read from `handler_class.namespace` — there is no `namespace:` parameter on `on`.
154
+
155
+ ---
156
+
157
+ ## Namespaces and `ctx.local`
158
+
159
+ Each handler class's namespace gives it an isolated key-value store — a `DotState` — accessible via `ctx.local`. State set in `before_run` is visible in `around_run`, `after_run`, and `on_error` for the same run.
160
+
161
+ ```ruby
162
+ class TimerHook < RobotLab::Hook
163
+ self.namespace = :timer
164
+
165
+ def self.before_run(ctx)
166
+ ctx.local.start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
167
+ end
168
+
169
+ def self.after_run(ctx)
170
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.start_time
171
+ puts "run took #{elapsed.round(3)}s"
172
+ end
173
+ end
174
+ ```
175
+
176
+ `DotState` is an open struct-like object. Keys are written and read with dot notation. Any key can be set; there is no schema.
177
+
178
+ To read another handler's state from within a hook, use `ctx.ext(:other_namespace)`:
179
+
180
+ ```ruby
181
+ class ReporterHook < RobotLab::Hook
182
+ self.namespace = :reporter
183
+
184
+ def self.after_run(ctx)
185
+ timer_data = ctx.ext(:timer)
186
+ puts "elapsed since start: #{timer_data.start_time}"
187
+ end
188
+ end
189
+ ```
190
+
191
+ ---
192
+
193
+ ## The `context:` Parameter — Default State
194
+
195
+ Pass `context: { key: value }` to pre-populate the handler's namespace `DotState` before each callback fires. Keys are only written if they are not already present, making them defaults that earlier hooks at the same level can override:
196
+
197
+ ```ruby
198
+ class CounterHook < RobotLab::Hook
199
+ self.namespace = :counter
200
+
201
+ def self.before_run(ctx)
202
+ ctx.local.count += 1
203
+ puts "run ##{ctx.local.count}"
204
+ end
205
+ end
206
+
207
+ RobotLab.on(CounterHook, context: { count: 0 })
208
+ ```
209
+
210
+ This is the intended pattern for extensions to declare their required state without asking callers to initialize it. Without `context:`, accessing an unset key on `DotState` returns `nil`.
211
+
212
+ ---
213
+
214
+ ## Around Hooks
215
+
216
+ Around hooks are class methods that accept the context and a block. They must call `block.call` and must return its return value — that is how the actual LLM call, network step, or task is executed:
217
+
218
+ ```ruby
219
+ class PerfHook < RobotLab::Hook
220
+ self.namespace = :perf
221
+
222
+ def self.around_run(ctx, &block)
223
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
224
+ result = block.call # MUST call — this is the actual run
225
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
226
+ puts "#{ctx.request.inspect} — #{elapsed}ms"
227
+ result # MUST return — callers expect the real result
228
+ end
229
+ end
230
+ ```
231
+
232
+ > **Important:** If an around hook does not return the block's return value, the run returns `nil`. This is a silent failure — there is no exception.
233
+
234
+ Around hooks registered across different handler classes are chained: each wraps the next, with the actual operation at the innermost layer.
235
+
236
+ ### `around_tool_call` is different
237
+
238
+ Tool call hooks use `ctx.tool_result` as the result carrier, not the block's return value. `Tool#call` always returns `context.tool_result` regardless of what the around hook returns.
239
+
240
+ To **let the tool execute normally**, call `block.call` — it sets `ctx.tool_result` — and then do any post-processing:
241
+
242
+ ```ruby
243
+ class ToolTimingHook < RobotLab::Hook
244
+ self.namespace = :timing
245
+
246
+ def self.around_tool_call(ctx, &block)
247
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
248
+ block.call # executes the tool and populates ctx.tool_result
249
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
250
+ $stderr.puts "[tool] #{ctx.tool_name} — #{ms}ms"
251
+ end
252
+ end
253
+ ```
254
+
255
+ To **short-circuit the tool** (skip execution entirely), skip `block.call` and set `ctx.tool_result` directly:
256
+
257
+ ```ruby
258
+ class ToolGuardHook < RobotLab::Hook
259
+ self.namespace = :guard
260
+
261
+ ALLOWED_TOOLS = %w[web_search calculator].freeze
262
+
263
+ def self.around_tool_call(ctx, &block)
264
+ if ALLOWED_TOOLS.include?(ctx.tool_name)
265
+ block.call
266
+ else
267
+ ctx.tool_result = "Operation not permitted: #{ctx.tool_name}"
268
+ end
269
+ end
270
+ end
271
+ ```
272
+
273
+ ### `on_compaction` — Replacing the Core Strategy
274
+
275
+ `on_compaction` is a point hook inside the `around_compaction` block. Unlike the other hooks in this family, it is not a lifecycle observer — it is an escape hatch that lets an extension supply its own message array in place of the built-in `HistoryCompressor`.
276
+
277
+ To replace the core algorithm, assign `ctx.compacted_messages` in an `on_compaction` handler. Once assigned, `ctx.handled?` returns `true` and the core skips `compress_history`, using the handler's message array instead:
278
+
279
+ ```ruby
280
+ class SemanticCompressor < RobotLab::Hook
281
+ self.namespace = :semantic_compressor
282
+
283
+ def self.on_compaction(ctx)
284
+ ctx.compacted_messages = MyCompressor.run(ctx.messages_before, ctx.robot)
285
+ end
286
+ end
287
+
288
+ robot.on(SemanticCompressor)
289
+ ```
290
+
291
+ If `on_compaction` does not assign `ctx.compacted_messages`, the core algorithm runs as normal. Multiple `on_compaction` handlers can be registered; the first one that sets `ctx.compacted_messages` wins — subsequent handlers still fire but their assignment is ignored once `handled?` is true.
292
+
293
+ > **Note:** `on_compaction` fires inside the core block of `Hooks.run(:compaction)`. It is not a standard `before_*/around_*/after_*` hook — it does not compose with around handlers or produce a chainable result. Use `around_compaction` if you need to wrap the entire process including observation of the final result.
294
+
295
+ ### `on_learn` — Implementing Long-Term Persistence
296
+
297
+ `on_learn` is a point hook that fires inside the core block of `Hooks.run(:learn)`, after the session-level deduplication and storage have already run. Its purpose is to give extensions the opportunity to persist a learning to long-term storage without the core knowing anything about how or where.
298
+
299
+ The hook receives a `LearnHookContext` with `ctx.stored` already set, so an extension can decide whether to persist based on whether the learning was genuinely new to this session:
300
+
301
+ ```ruby
302
+ class DurableMemoryHook < RobotLab::Hook
303
+ self.namespace = :durable
304
+
305
+ def self.on_learn(ctx)
306
+ return unless ctx.stored # skip deduplicated-away learnings
307
+
308
+ LongTermStore.write(
309
+ text: ctx.text,
310
+ robot: ctx.robot.name,
311
+ domain: ctx.local.domain
312
+ )
313
+ end
314
+ end
315
+
316
+ robot.on(DurableMemoryHook, context: { domain: "customer_support" })
317
+ ```
318
+
319
+ Unlike `on_compaction`, `on_learn` does not use a `handled?` flag — there is no "default persistence" in the core to replace. Every registered `on_learn` handler fires; each extension independently decides what to do with the learning.
320
+
321
+ `on_learn` fires even when `ctx.stored` is `false` (the text was deduplicated away). This allows extensions to apply their own deduplication policy for long-term storage, which may differ from the session-level substring logic. Check `ctx.stored` explicitly if you only want to act on genuinely new learnings.
322
+
323
+ ---
324
+
325
+ ## Context Objects
326
+
327
+ Each hook family receives a typed context object. All context objects provide access to `ctx.local` (the handler's namespace `DotState`) and `ctx.ext(:name)` (cross-namespace reads).
328
+
329
+ ### RunHookContext
330
+
331
+ Passed to `:run` hooks (`before_run`, `around_run`, `after_run`, `on_error`).
332
+
333
+ | Attribute | Type | Notes |
334
+ |-----------|------|-------|
335
+ | `event` | Symbol | `:run` |
336
+ | `robot` | Robot | The robot executing |
337
+ | `network` | Network\|nil | Present when running inside a network |
338
+ | `task` | Task\|nil | Present when running as a network task |
339
+ | `request` | String\|nil | The message passed to `run` |
340
+ | `response` | RobotResult\|nil | Set after the run completes; readable in `after_run` and `on_error` |
341
+ | `error` | Exception\|nil | Set when `on_error` fires |
342
+ | `metadata` | ExtensionState | Namespace-isolated state (backing store for `ctx.local` / `ctx.ext`) |
343
+
344
+ ### LlmGenerationHookContext
345
+
346
+ Extends `RunHookContext` and is passed to `:llm_generation` hooks. All `RunHookContext` attributes are present, plus:
347
+
348
+ | Attribute | Type | Notes |
349
+ |-----------|------|-------|
350
+ | `generation_response` | RubyLLM::Message\|nil | Set after the LLM responds; readable in `after_llm_generation` |
351
+ | `iteration` | Integer | Which LLM call within this run, 0-based |
352
+
353
+ ### ToolCallHookContext
354
+
355
+ Passed to `:tool_call` hooks (`before_tool_call`, `around_tool_call`, `after_tool_call`).
356
+
357
+ | Attribute | Type | Notes |
358
+ |-----------|------|-------|
359
+ | `event` | Symbol | `:tool_call` |
360
+ | `tool` | Tool | The tool instance being called |
361
+ | `tool_name` | String | |
362
+ | `tool_args` | Hash | Arguments passed to the tool |
363
+ | `tool_result` | Object\|nil | Set after the tool executes; readable in `after_tool_call` |
364
+ | `tool_error` | Exception\|nil | Set if the tool raised an exception |
365
+ | `metadata` | ExtensionState | |
366
+
367
+ ### NetworkRunHookContext
368
+
369
+ Passed to `:network_run` hooks (`before_network_run`, `around_network_run`, `after_network_run`, `on_error`).
370
+
371
+ | Attribute | Type | Notes |
372
+ |-----------|------|-------|
373
+ | `event` | Symbol | `:network_run` |
374
+ | `network` | Network | |
375
+ | `context` | Hash | The run parameters |
376
+ | `result` | Object\|nil | Set after the network completes |
377
+ | `error` | Exception\|nil | Set when `on_error` fires |
378
+ | `metadata` | ExtensionState | |
379
+
380
+ ### TaskHookContext
381
+
382
+ Passed to `:task` hooks (`before_task`, `around_task`, `after_task`, `on_error`).
383
+
384
+ | Attribute | Type | Notes |
385
+ |-----------|------|-------|
386
+ | `event` | Symbol | `:task` |
387
+ | `network` | Network\|nil | |
388
+ | `task` | Task | |
389
+ | `task_name` | Symbol | |
390
+ | `robot` | Robot | The robot executing this task |
391
+ | `result` | Object\|nil | Set after the task completes |
392
+ | `error` | Exception\|nil | Set when `on_error` fires |
393
+ | `metadata` | ExtensionState | |
394
+
395
+ ### CompactionHookContext
396
+
397
+ Passed to `:compaction` hooks (`before_compaction`, `around_compaction`, `after_compaction`, `on_compaction`).
398
+
399
+ | Attribute | Type | Notes |
400
+ |-----------|------|-------|
401
+ | `event` | Symbol | `:compaction` |
402
+ | `robot` | Robot | The robot whose history is being compacted |
403
+ | `messages_before` | Array (frozen) | Snapshot of `@chat.messages` at the moment compaction was triggered |
404
+ | `config` | RunConfig | The robot's active configuration |
405
+ | `strategy` | Symbol | `:context_window` when triggered by the threshold check; `:custom` when triggered by a `Proc` |
406
+ | `compacted_messages` | Array\|nil | Set by the core algorithm after compaction, or set by an `on_compaction` handler to replace the core algorithm |
407
+ | `error` | Exception\|nil | Set if compaction raises |
408
+ | `metadata` | ExtensionState | |
409
+
410
+ #### `ctx.handled?`
411
+
412
+ Returns `true` once `ctx.compacted_messages` has been assigned. The core compaction algorithm checks `handled?` after `on_compaction` fires — if `true`, it skips `compress_history` and calls `replace_messages` with the handler's result instead. See [on_compaction](#on_compaction) below.
413
+
414
+ ### LearnHookContext
415
+
416
+ Passed to `:learn` hooks (`before_learn`, `around_learn`, `after_learn`, `on_learn`).
417
+
418
+ | Attribute | Type | Notes |
419
+ |-----------|------|-------|
420
+ | `event` | Symbol | `:learn` |
421
+ | `robot` | Robot | The robot learning |
422
+ | `text` | String | The stripped, non-empty learning text passed to `robot.learn` |
423
+ | `learnings_before` | Array (frozen) | Snapshot of `robot.learnings` at the moment `learn` was called |
424
+ | `stored` | Boolean | `true` if the text was added to session learnings; `false` if it was skipped because an existing learning already covers it. Set during the core block — readable in `on_learn` and `after_learn`. |
425
+ | `error` | Exception\|nil | Set if the learn block raises |
426
+ | `metadata` | ExtensionState | |
427
+
428
+ #### `ctx.stored`
429
+
430
+ `false` means the text was deduplicated away at the session level — an existing learning already contains or supersedes it. Extensions implementing `on_learn` should check this flag when their own deduplication policy matches the session-level policy. Extensions with a different policy (for example, treating every explicit instruction as authoritative regardless of overlap) may ignore it.
431
+
432
+ ---
433
+
434
+ ## Per-Run Hooks
435
+
436
+ For one-off instrumentation tied to a single call, pass a `hooks:` argument to `robot.run`. Supply a single handler class or an array of handler classes:
437
+
438
+ ```ruby
439
+ result = robot.run("summarize this", hooks: TraceHook)
440
+ ```
441
+
442
+ ```ruby
443
+ result = robot.run("summarize this", hooks: [TraceHook, MetricsHook])
444
+ ```
445
+
446
+ Per-run hooks fire after robot-level hooks, in the order supplied.
447
+
448
+ ---
449
+
450
+ ## Error Hooks
451
+
452
+ The `on_error` hook fires when an unhandled exception escapes a run, network run, or task. It receives the same context object as its family's `after_*` hook, with the `error` attribute set:
453
+
454
+ ```ruby
455
+ class AlertingHook < RobotLab::Hook
456
+ self.namespace = :alerting
457
+
458
+ def self.on_error(ctx)
459
+ if ctx.respond_to?(:robot)
460
+ puts "ERROR in #{ctx.robot.name}: #{ctx.error.class} — #{ctx.error.message}"
461
+ Alerting.notify(ctx.error, robot: ctx.robot.name, request: ctx.request)
462
+ else
463
+ puts "Network error: #{ctx.error.message}"
464
+ end
465
+ end
466
+ end
467
+
468
+ RobotLab.on(AlertingHook)
469
+ ```
470
+
471
+ `on_error` does not suppress the exception. The error continues to propagate after all `on_error` hooks finish.
472
+
473
+ ---
474
+
475
+ ## Writing an Extension
476
+
477
+ The recommended pattern is a subclass of `RobotLab::Hook` with all lifecycle callbacks defined in a `class << self` block. Keeping all callbacks in one class makes the extension easy to attach to different registries (global, a specific network, or a specific robot) and easy to test in isolation.
478
+
479
+ ```ruby
480
+ class MyExtension < RobotLab::Hook
481
+ self.namespace = :my_ext
482
+
483
+ class << self
484
+ attr_writer :logger
485
+ def logger
486
+ @logger ||= Logger.new($stdout)
487
+ end
488
+
489
+ def before_run(ctx)
490
+ ctx.local.call_count = (ctx.local.call_count || 0) + 1
491
+ logger.info("run ##{ctx.local.call_count} starting: #{ctx.request.inspect}")
492
+ end
493
+
494
+ def after_run(ctx)
495
+ logger.info("run done: #{ctx.response&.reply.to_s[0, 80]}")
496
+ end
497
+
498
+ def on_error(ctx)
499
+ logger.error("run failed: #{ctx.error.class} — #{ctx.error.message}")
500
+ end
501
+ end
502
+ end
503
+ ```
504
+
505
+ To attach globally:
506
+
507
+ ```ruby
508
+ RobotLab.on(MyExtension)
509
+ ```
510
+
511
+ To attach only to a specific network:
512
+
513
+ ```ruby
514
+ network.on(MyExtension)
515
+ ```
516
+
517
+ To attach only to a specific robot:
518
+
519
+ ```ruby
520
+ robot.on(MyExtension)
521
+ ```
522
+
523
+ With per-registration default state:
524
+
525
+ ```ruby
526
+ RobotLab.on(MyExtension, context: { call_count: 0 })
527
+ ```
528
+
529
+ ### Extension Guidelines
530
+
531
+ - Set `self.namespace = :my_name` explicitly so callers can read the namespace without relying on class naming conventions.
532
+ - Use `context:` on the `on(...)` call to declare default state rather than guarding against `nil` inside callbacks.
533
+ - Around hooks (`around_run`, `around_llm_generation`, `around_network_run`, `around_task`, `around_compaction`) must call `block.call` and return its value — omitting either causes the run to return `nil`. `around_tool_call` is the exception: the result is carried in `ctx.tool_result`, so call `block.call` to let the tool run or set `ctx.tool_result` directly to short-circuit.
534
+ - Keep error hooks non-raising. Exceptions from hook callbacks propagate and can mask the original error.
535
+ - Test each callback method in isolation by constructing a context object directly and calling the class method.
536
+
537
+ ---
538
+
539
+ ## Common Patterns
540
+
541
+ ### Performance Timer
542
+
543
+ ```ruby
544
+ class PerfHook < RobotLab::Hook
545
+ self.namespace = :perf
546
+
547
+ def self.around_run(ctx, &block)
548
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
549
+ result = block.call
550
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
551
+ $stderr.puts "[perf] #{ctx.robot.name} #{ms}ms"
552
+ result
553
+ end
554
+ end
555
+
556
+ RobotLab.on(PerfHook)
557
+ ```
558
+
559
+ ### Request/Response Tracer
560
+
561
+ ```ruby
562
+ class TraceHook < RobotLab::Hook
563
+ self.namespace = :trace
564
+
565
+ def self.before_run(ctx)
566
+ puts "→ #{ctx.robot.name}: #{ctx.request.inspect}"
567
+ end
568
+
569
+ def self.after_run(ctx)
570
+ puts "← #{ctx.robot.name}: #{ctx.response&.reply.to_s[0, 120]}"
571
+ end
572
+ end
573
+
574
+ robot.on(TraceHook)
575
+ ```
576
+
577
+ ### LLM Response Cache
578
+
579
+ Use `around_llm_generation` to skip the LLM call entirely when a cached response exists. A `before_llm_generation` hook cannot short-circuit the call — it must be an `around_` hook:
580
+
581
+ ```ruby
582
+ class LlmCacheHook < RobotLab::Hook
583
+ self.namespace = :cache
584
+
585
+ def self.around_llm_generation(ctx, &block)
586
+ ctx.local.hits ||= 0
587
+ cached = ResponseCache.get(ctx.request)
588
+ if cached
589
+ ctx.local.hits += 1
590
+ cached # return cached value — block.call (LLM) is skipped
591
+ else
592
+ result = block.call # LLM call happens here
593
+ ResponseCache.set(ctx.request, result)
594
+ result
595
+ end
596
+ end
597
+ end
598
+
599
+ robot.on(LlmCacheHook, context: { hits: 0 })
600
+ ```
601
+
602
+ ### Tool Call Audit Log
603
+
604
+ ```ruby
605
+ class ToolAuditHook < RobotLab::Hook
606
+ self.namespace = :audit
607
+
608
+ def self.before_tool_call(ctx)
609
+ AuditLog.write(
610
+ robot: ctx.robot&.name,
611
+ tool: ctx.tool_name,
612
+ args: ctx.tool_args,
613
+ timestamp: Time.now.utc
614
+ )
615
+ end
616
+ end
617
+
618
+ RobotLab.on(ToolAuditHook)
619
+ ```
620
+
621
+ ### Learn Audit Log
622
+
623
+ Record every learning attempt — including the ones that were deduplicated away — for debugging or analytics:
624
+
625
+ ```ruby
626
+ class LearnAuditHook < RobotLab::Hook
627
+ self.namespace = :learn_audit
628
+
629
+ def self.after_learn(ctx)
630
+ status = ctx.stored ? "stored" : "skipped (covered)"
631
+ $stderr.puts "[learn] #{ctx.robot.name}: #{status} — #{ctx.text.inspect}"
632
+ end
633
+ end
634
+
635
+ RobotLab.on(LearnAuditHook)
636
+ ```
637
+
638
+ ### Long-Term Memory Promotion via `on_learn`
639
+
640
+ Persist new learnings to durable storage. `on_learn` fires after session storage so the session state is already updated when the extension runs:
641
+
642
+ ```ruby
643
+ class DurableLearnHook < RobotLab::Hook
644
+ self.namespace = :durable
645
+
646
+ def self.on_learn(ctx)
647
+ return unless ctx.stored
648
+
649
+ DurableStore.promote(
650
+ text: ctx.text,
651
+ robot: ctx.robot.name,
652
+ domain: ctx.local.domain,
653
+ timestamp: Time.now.utc
654
+ )
655
+ end
656
+ end
657
+
658
+ robot.on(DurableLearnHook, context: { domain: "finance" })
659
+ ```
660
+
661
+ ### Blocking Unauthorised Learnings
662
+
663
+ Use `around_learn` to gate what a robot is allowed to learn — useful when the robot's system prompt comes from untrusted input:
664
+
665
+ ```ruby
666
+ class LearnGuardHook < RobotLab::Hook
667
+ self.namespace = :learn_guard
668
+
669
+ BLOCKED_PATTERN = /ignore previous instructions|forget everything/i
670
+
671
+ def self.around_learn(ctx, &block)
672
+ if BLOCKED_PATTERN.match?(ctx.text)
673
+ $stderr.puts "[learn_guard] Blocked: #{ctx.text.inspect}"
674
+ # Do not call block.call — the learning is silently dropped
675
+ else
676
+ block.call
677
+ end
678
+ end
679
+ end
680
+
681
+ RobotLab.on(LearnGuardHook)
682
+ ```
683
+
684
+ ### Compaction Observability
685
+
686
+ Log when and why history compression fires, and how much it reduced the message count:
687
+
688
+ ```ruby
689
+ class CompactionLoggerHook < RobotLab::Hook
690
+ self.namespace = :compaction_logger
691
+
692
+ def self.before_compaction(ctx)
693
+ ctx.local.before_count = ctx.messages_before.size
694
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
695
+ end
696
+
697
+ def self.after_compaction(ctx)
698
+ after_count = ctx.compacted_messages&.size || ctx.local.before_count
699
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
700
+ dropped = ctx.local.before_count - after_count
701
+ $stderr.puts "[compaction] #{ctx.robot.name} #{ctx.strategy}: " \
702
+ "#{ctx.local.before_count} → #{after_count} messages " \
703
+ "(-#{dropped}) in #{elapsed_ms}ms"
704
+ end
705
+ end
706
+
707
+ RobotLab.on(CompactionLoggerHook)
708
+ ```
709
+
710
+ ### Promote Session Learnings Before Compaction
711
+
712
+ When conversation history is about to be compressed, an extension can inspect the messages about to be dropped and promote important information to long-term storage before it is lost:
713
+
714
+ ```ruby
715
+ class LearningPromotionHook < RobotLab::Hook
716
+ self.namespace = :learning_promotion
717
+
718
+ def self.before_compaction(ctx)
719
+ ctx.local.message_ids_before = ctx.messages_before.map(&:object_id).to_set
720
+ end
721
+
722
+ def self.after_compaction(ctx)
723
+ return unless ctx.compacted_messages
724
+ surviving_ids = ctx.compacted_messages.map(&:object_id).to_set
725
+ dropped = ctx.messages_before.reject { |m| surviving_ids.include?(m.object_id) }
726
+ LongTermStore.promote(dropped, domain: ctx.robot.name) if dropped.any?
727
+ end
728
+ end
729
+
730
+ robot.on(LearningPromotionHook)
731
+ ```
732
+
733
+ ### Custom Compaction Strategy
734
+
735
+ Replace the built-in TF-IDF compressor with a domain-specific algorithm using `on_compaction`:
736
+
737
+ ```ruby
738
+ class SummarizerCompactor < RobotLab::Hook
739
+ self.namespace = :summarizer_compactor
740
+
741
+ KEEP_RECENT = 4 # always keep the last N user+assistant pairs verbatim
742
+
743
+ def self.on_compaction(ctx)
744
+ messages = ctx.messages_before
745
+ pinned = messages.select { |m| %i[system tool tool_result].include?(m.role) }
746
+ scoreable = messages.reject { |m| %i[system tool tool_result].include?(m.role) }
747
+
748
+ recent = scoreable.last(KEEP_RECENT * 2)
749
+ older = scoreable.first([scoreable.size - KEEP_RECENT * 2, 0].max)
750
+
751
+ summary_text = ctx.robot.run("Summarize in two sentences: #{older.map(&:content).join(' ')}")
752
+ .reply
753
+
754
+ summary_msg = OpenStruct.new(role: :assistant, content: summary_text,
755
+ tool_calls: nil, stop_reason: :stop)
756
+
757
+ ctx.compacted_messages = pinned + [summary_msg] + recent
758
+ end
759
+ end
760
+
761
+ robot.on(SummarizerCompactor)
762
+ ```
763
+
764
+ ### Run Counter Per Robot
765
+
766
+ ```ruby
767
+ class MetricsHook < RobotLab::Hook
768
+ self.namespace = :metrics
769
+
770
+ def self.before_run(ctx)
771
+ ctx.local.counts ||= {}
772
+ name = ctx.robot.name
773
+ ctx.local.counts[name] = (ctx.local.counts[name] || 0) + 1
774
+ end
775
+ end
776
+
777
+ RobotLab.on(MetricsHook, context: { counts: {} })
778
+ ```
779
+
780
+ ---
781
+
782
+ ## Application Use Cases
783
+
784
+ Hooks are the primary extension point in RobotLab. Below are concrete patterns that production applications commonly build on top of them. The `:compaction` family is particularly important for long-term memory extensions: it provides the earliest possible signal that conversation history is about to be lost, giving extensions the opportunity to promote valuable content before it is compressed away.
785
+
786
+ ### Observability and Distributed Tracing
787
+
788
+ Instrument every LLM call with a trace ID and structured log entry for ingestion into OpenTelemetry, Datadog, or a custom logging pipeline:
789
+
790
+ ```ruby
791
+ class ObservabilityHook < RobotLab::Hook
792
+ self.namespace = :trace
793
+
794
+ def self.before_run(ctx)
795
+ ctx.local.trace_id = SecureRandom.hex(8)
796
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
797
+ end
798
+
799
+ def self.after_run(ctx)
800
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
801
+ Logger.info(
802
+ event: "robot.run.completed",
803
+ trace_id: ctx.local.trace_id,
804
+ robot: ctx.robot.name,
805
+ elapsed_ms: elapsed_ms,
806
+ reply: ctx.response&.reply.to_s[0, 120]
807
+ )
808
+ end
809
+
810
+ def self.on_error(ctx)
811
+ Logger.error(
812
+ event: "robot.run.failed",
813
+ trace_id: ctx.local.trace_id,
814
+ robot: ctx.robot.name,
815
+ error: ctx.error.class.to_s,
816
+ message: ctx.error.message
817
+ )
818
+ end
819
+ end
820
+
821
+ RobotLab.on(ObservabilityHook)
822
+ ```
823
+
824
+ ### Cost Enforcement
825
+
826
+ Cap total spending across all robots in a session and raise before an expensive run would push you over budget:
827
+
828
+ ```ruby
829
+ class BudgetHook < RobotLab::Hook
830
+ self.namespace = :budget
831
+
832
+ COST_PER_INPUT_TOKEN = 0.80 / 1_000_000
833
+ COST_PER_OUTPUT_TOKEN = 4.00 / 1_000_000
834
+ SESSION_BUDGET_USD = 0.50
835
+
836
+ def self.before_run(ctx)
837
+ ctx.local.total_cost ||= 0.0
838
+ if ctx.local.total_cost >= SESSION_BUDGET_USD
839
+ raise RobotLab::Error, "Session budget of $#{SESSION_BUDGET_USD} exceeded"
840
+ end
841
+ end
842
+
843
+ def self.after_run(ctx)
844
+ r = ctx.response
845
+ ctx.local.total_cost +=
846
+ r.input_tokens * COST_PER_INPUT_TOKEN +
847
+ r.output_tokens * COST_PER_OUTPUT_TOKEN
848
+ end
849
+ end
850
+
851
+ RobotLab.on(BudgetHook, context: { total_cost: 0.0 })
852
+ ```
853
+
854
+ For a single robot's own token/dollar ceiling (rather than a cross-robot session total), `token_budget:`/`cost_budget:` on `Robot.new` give you this natively — see [Budgets](observability.md#budgets-token--cost) — including a `RobotLab::BudgetExceeded` raised up front once a prior call already exhausted the budget, so the next call is refused before it spends anything.
855
+
856
+ ### Tool Access Control
857
+
858
+ Allow only approved tools to execute for a given network, without hard-coding restrictions in the tool itself:
859
+
860
+ ```ruby
861
+ class AccessControlHook < RobotLab::Hook
862
+ self.namespace = :access_control
863
+
864
+ ALLOWED_TOOLS = %w[web_search calculator].freeze
865
+
866
+ def self.around_tool_call(ctx, &block)
867
+ if ALLOWED_TOOLS.include?(ctx.tool_name)
868
+ block.call
869
+ else
870
+ ctx.tool_result = "[blocked] Tool '#{ctx.tool_name}' is not permitted in this network."
871
+ end
872
+ end
873
+ end
874
+
875
+ network.on(AccessControlHook)
876
+ ```
877
+
878
+ ### Audit Logging for Compliance
879
+
880
+ Record every tool invocation with its arguments and result for security audits or regulatory compliance:
881
+
882
+ ```ruby
883
+ class ComplianceAuditHook < RobotLab::Hook
884
+ self.namespace = :audit
885
+
886
+ def self.after_tool_call(ctx)
887
+ AuditLog.append(
888
+ robot: ctx.robot&.name,
889
+ tool: ctx.tool_name,
890
+ args: ctx.tool_args,
891
+ result: ctx.tool_result.to_s[0, 500],
892
+ timestamp: Time.now.utc.iso8601
893
+ )
894
+ end
895
+ end
896
+
897
+ RobotLab.on(ComplianceAuditHook)
898
+ ```
899
+
900
+ ### Retry with Exponential Backoff
901
+
902
+ Wrap `around_run` to retry on transient LLM failures without changing any robot code:
903
+
904
+ ```ruby
905
+ class RetryHook < RobotLab::Hook
906
+ self.namespace = :retry
907
+
908
+ MAX_RETRIES = 3
909
+ BACKOFF_BASE = 0.5
910
+
911
+ def self.around_run(ctx, &block)
912
+ attempts = 0
913
+ begin
914
+ attempts += 1
915
+ block.call
916
+ rescue RobotLab::InferenceError
917
+ raise if attempts >= MAX_RETRIES
918
+
919
+ sleep(BACKOFF_BASE * (2**(attempts - 1)))
920
+ retry
921
+ end
922
+ end
923
+ end
924
+
925
+ RobotLab.on(RetryHook)
926
+ ```
927
+
928
+ ### Test Doubles Without Network Calls
929
+
930
+ Inject canned LLM responses in tests by short-circuiting `around_llm_generation`. No VCR cassettes, no HTTP mocks:
931
+
932
+ ```ruby
933
+ class TestDoubleHook < RobotLab::Hook
934
+ self.namespace = :test_double
935
+
936
+ CANNED_RESPONSES = {
937
+ "classify this" => FakeResponse.new(content: "positive"),
938
+ "summarize" => FakeResponse.new(content: "short summary")
939
+ }.freeze
940
+
941
+ def self.around_llm_generation(ctx, &block)
942
+ canned = CANNED_RESPONSES[ctx.request]
943
+ canned ? canned : block.call
944
+ end
945
+ end
946
+
947
+ # In test setup:
948
+ RobotLab.on(TestDoubleHook)
949
+ ```
950
+
951
+ ### Per-Network Latency Metrics
952
+
953
+ Collect timing data scoped to a specific network without touching global hooks:
954
+
955
+ ```ruby
956
+ class NetworkMetricsHook < RobotLab::Hook
957
+ self.namespace = :metrics
958
+
959
+ def self.before_network_run(ctx)
960
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
961
+ end
962
+
963
+ def self.after_network_run(ctx)
964
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
965
+ Metrics.histogram("network.run.duration_ms", elapsed_ms, tags: { network: ctx.network.name })
966
+ end
967
+ end
968
+
969
+ network.on(NetworkMetricsHook)
970
+ ```
971
+
972
+ ### Sensitive Data Redaction
973
+
974
+ Scrub PII from requests before they are logged or sent to the LLM:
975
+
976
+ ```ruby
977
+ class RedactionHook < RobotLab::Hook
978
+ self.namespace = :redaction
979
+
980
+ PII_PATTERN = /\b[A-Z]{2}\d{6,9}\b/ # example: passport numbers
981
+
982
+ def self.before_run(ctx)
983
+ ctx.request = ctx.request&.gsub(PII_PATTERN, "[REDACTED]")
984
+ end
985
+ end
986
+
987
+ RobotLab.on(RedactionHook)
988
+ ```
989
+
990
+ ---
991
+
992
+ ## See Also
993
+
994
+ - [examples/35_hooks.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/35_hooks.rb) — full demo with xyzzy extension, perf timer, LLM response cache, and tracer hooks
995
+ - [examples/xyzzy.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/xyzzy.rb) — single-file reference extension (`RobotLab::Xyzzy < RobotLab::Hook`) that registers for every hook family
996
+ - [Robot Execution](../architecture/robot-execution.md)
997
+ - [Observability & Safety](observability.md)