robot_lab 0.2.1 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -0,0 +1,1084 @@
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` | exactly once per `robot.run(...)` — it wraps the whole generation phase, including the provider's tool 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
+ before_tool_call
36
+ around_tool_call { [tool invocation 1] }
37
+ after_tool_call
38
+ [LLM call 2]
39
+ before_tool_call
40
+ around_tool_call { [tool invocation 2] }
41
+ after_tool_call
42
+ [LLM call 3]
43
+ }
44
+ after_llm_generation
45
+ }
46
+ after_run
47
+ ```
48
+
49
+ > [!IMPORTANT]
50
+ > The `:llm_generation` family fires **once per `run`**, not once per LLM API
51
+ > call. The provider's tool loop — every LLM round trip and every tool call —
52
+ > happens *inside* the `around_llm_generation` block. Tool hooks therefore fire
53
+ > nested within it, never between two `llm_generation` cycles. If you need
54
+ > per-API-call instrumentation, `:tool_call` hooks are the only per-round signal
55
+ > the hook system exposes.
56
+
57
+ Compaction hooks fire at most once per `run` — they live inside the same single `llm_generation` block — and only when the compaction threshold is actually exceeded (or a custom `Proc` strategy is configured). They do not fire on every `run` invocation.
58
+
59
+ The `:learn` family fires synchronously inside `robot.learn(text)`, once per call. Hooks do not fire when `text` is blank.
60
+
61
+ ---
62
+
63
+ ## Handler Classes
64
+
65
+ 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.
66
+
67
+ ```ruby
68
+ class MyHook < RobotLab::Hook
69
+ class << self
70
+ def before_run(ctx)
71
+ # fires before every robot.run call
72
+ end
73
+
74
+ def after_run(ctx)
75
+ # fires after every robot.run call
76
+ end
77
+
78
+ def on_error(ctx)
79
+ # fires when an unhandled exception escapes a run
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Namespace auto-derivation
86
+
87
+ 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:
88
+
89
+ | Class name | Auto namespace |
90
+ |-----------|---------------|
91
+ | `TimerHook` | `:timer_hook` |
92
+ | `AuditHook` | `:audit_hook` |
93
+ | `PerfMonitor` | `:perf_monitor` |
94
+ | `MyExt::Tracer` | `:tracer` |
95
+
96
+ Override the auto-derived namespace at the class level:
97
+
98
+ ```ruby
99
+ class TimerHook < RobotLab::Hook
100
+ self.namespace = :timer # use :timer instead of :timer_hook
101
+ end
102
+ ```
103
+
104
+ ### `RobotLab::Hook` base class
105
+
106
+ ```ruby
107
+ class Hook
108
+ class << self
109
+ attr_writer :namespace
110
+
111
+ def namespace
112
+ return @namespace if @namespace
113
+ return nil if self == Hook
114
+ name.split('::').last
115
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
116
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
117
+ .downcase.to_sym
118
+ end
119
+
120
+ def call(hook_name, context, &block)
121
+ if singleton_class.public_method_defined?(hook_name)
122
+ block ? public_send(hook_name, context, &block) : public_send(hook_name, context)
123
+ elsif block
124
+ block.call
125
+ end
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Registration Levels
134
+
135
+ Hooks are registered on three objects and can optionally be scoped to a single call:
136
+
137
+ | Level | Registration | Scope |
138
+ |-------|-------------|-------|
139
+ | **Global** | `RobotLab.on(...)` | Every robot, every network |
140
+ | **Network** | `network.on(...)` | Only robots inside that network |
141
+ | **Robot** | `robot.on(...)` | Only that robot |
142
+ | **Per-run** | `robot.run("msg", hooks: ...)` | A single `run` call |
143
+
144
+ 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.
145
+
146
+ > [!WARNING]
147
+ > The **`:task` family is the exception.** `Task#call` resolves its handlers from
148
+ > `[RobotLab.hooks, network.hooks]` only — the robot's own registry is not
149
+ > consulted. A `before_task`/`around_task`/`after_task`/`on_error` handler
150
+ > registered with `robot.on(...)` (or per-run `hooks:`) **never fires**. Register
151
+ > task hooks globally or on the network.
152
+ >
153
+ > The `:network_run` family likewise reads `[RobotLab.hooks, network.hooks]`,
154
+ > which is the same set it would see anyway since networks have no robot scope.
155
+
156
+ ---
157
+
158
+ ## The `on` Method
159
+
160
+ All three registration objects share the same signature:
161
+
162
+ ```ruby
163
+ RobotLab.on(handler_class, context: nil)
164
+ network.on(handler_class, context: nil)
165
+ robot.on(handler_class, context: nil)
166
+ ```
167
+
168
+ | Parameter | Type | Description |
169
+ |-----------|------|-------------|
170
+ | `handler_class` | Class | A subclass of `RobotLab::Hook` |
171
+ | `context:` | Hash\|nil | Default state pre-populated into the handler's namespace `DotState` before each callback fires |
172
+
173
+ `handler_class` must be a subclass of `RobotLab::Hook`. The namespace is read from `handler_class.namespace` — there is no `namespace:` parameter on `on`.
174
+
175
+ ---
176
+
177
+ ## Namespaces and `ctx.local`
178
+
179
+ 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, and in the `:llm_generation`, `:tool_call`, and `:compaction` contexts nested inside it.
180
+
181
+ > [!WARNING]
182
+ > **`ctx.local` lives for exactly one run.** Its backing store is a fresh
183
+ > `ExtensionState` created with each `HookContext`, so the next `robot.run` starts
184
+ > from an empty slate (or from whatever `context:` defaults you declared).
185
+ > `ctx.local` is a scratchpad for correlating the phases of a single run — it is
186
+ > **not** a place to accumulate counters, totals, or caches across runs. For
187
+ > cross-run state, use a class-level `attr_accessor` on the handler itself, or an
188
+ > external store.
189
+
190
+ ```ruby
191
+ class TimerHook < RobotLab::Hook
192
+ self.namespace = :timer
193
+
194
+ def self.before_run(ctx)
195
+ ctx.local.start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
196
+ end
197
+
198
+ def self.after_run(ctx)
199
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.start_time
200
+ puts "run took #{elapsed.round(3)}s"
201
+ end
202
+ end
203
+ ```
204
+
205
+ `DotState` is an open struct-like object. Keys are written and read with dot notation. Any key can be set; there is no schema.
206
+
207
+ To read another handler's state from within a hook, use `ctx.ext(:other_namespace)`:
208
+
209
+ ```ruby
210
+ class ReporterHook < RobotLab::Hook
211
+ self.namespace = :reporter
212
+
213
+ def self.after_run(ctx)
214
+ timer_data = ctx.ext(:timer)
215
+ puts "elapsed since start: #{timer_data.start_time}"
216
+ end
217
+ end
218
+ ```
219
+
220
+ ---
221
+
222
+ ## The `context:` Parameter — Default State
223
+
224
+ 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 in the same run can override:
225
+
226
+ ```ruby
227
+ class TagHook < RobotLab::Hook
228
+ self.namespace = :tagging
229
+
230
+ def self.before_run(ctx)
231
+ # ctx.local.environment is pre-populated from context:, so no nil guard needed
232
+ puts "[#{ctx.local.environment}] #{ctx.robot.name}: #{ctx.request.inspect}"
233
+ end
234
+ end
235
+
236
+ RobotLab.on(TagHook, context: { environment: "staging" })
237
+ ```
238
+
239
+ 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`.
240
+
241
+ Because `ctx.local` is re-created per run, the defaults are re-applied on *every* run — a `context: { count: 0 }` default means `ctx.local.count` is `0` at the start of each run, not a counter that survives. Keep cross-run tallies on the handler class:
242
+
243
+ ```ruby
244
+ class RunCounter < RobotLab::Hook
245
+ self.namespace = :run_counter
246
+
247
+ class << self
248
+ def count = @count ||= 0
249
+
250
+ def before_run(_ctx)
251
+ @count = count + 1
252
+ puts "run ##{@count}" # 1, 2, 3, ... across runs
253
+ end
254
+ end
255
+ end
256
+
257
+ RobotLab.on(RunCounter)
258
+ ```
259
+
260
+ ---
261
+
262
+ ## Around Hooks
263
+
264
+ 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:
265
+
266
+ ```ruby
267
+ class PerfHook < RobotLab::Hook
268
+ self.namespace = :perf
269
+
270
+ def self.around_run(ctx, &block)
271
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
272
+ result = block.call # MUST call — this is the actual run
273
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
274
+ puts "#{ctx.request.inspect} — #{elapsed}ms"
275
+ result # MUST return — callers expect the real result
276
+ end
277
+ end
278
+ ```
279
+
280
+ > [!IMPORTANT]
281
+ > If `around_run`, `around_llm_generation`, `around_network_run`, or `around_task`
282
+ > does not return the block's return value, the operation returns `nil`. This is a
283
+ > silent failure — there is no exception.
284
+ >
285
+ > `around_compaction` and `around_learn` are the return-value-agnostic exceptions:
286
+ > nothing consumes their result, so forgetting the return value is harmless. They
287
+ > must still call `block.call` — skipping it suppresses the compaction or the
288
+ > learning entirely. `around_tool_call` is different again; see below.
289
+
290
+ Around hooks registered across different handler classes are chained: each wraps the next, with the actual operation at the innermost layer.
291
+
292
+ ### `around_tool_call` is different
293
+
294
+ 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.
295
+
296
+ To **let the tool execute normally**, call `block.call` — it sets `ctx.tool_result` — and then do any post-processing:
297
+
298
+ ```ruby
299
+ class ToolTimingHook < RobotLab::Hook
300
+ self.namespace = :timing
301
+
302
+ def self.around_tool_call(ctx, &block)
303
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
304
+ block.call # executes the tool and populates ctx.tool_result
305
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
306
+ $stderr.puts "[tool] #{ctx.tool_name} — #{ms}ms"
307
+ end
308
+ end
309
+ ```
310
+
311
+ To **short-circuit the tool** (skip execution entirely), skip `block.call` and set `ctx.tool_result` directly:
312
+
313
+ ```ruby
314
+ class ToolGuardHook < RobotLab::Hook
315
+ self.namespace = :guard
316
+
317
+ ALLOWED_TOOLS = %w[web_search calculator].freeze
318
+
319
+ def self.around_tool_call(ctx, &block)
320
+ if ALLOWED_TOOLS.include?(ctx.tool_name)
321
+ block.call
322
+ else
323
+ ctx.tool_result = "Operation not permitted: #{ctx.tool_name}"
324
+ end
325
+ end
326
+ end
327
+ ```
328
+
329
+ ### `on_compaction` — Replacing the Core Strategy
330
+
331
+ `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`.
332
+
333
+ 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:
334
+
335
+ ```ruby
336
+ class SemanticCompressor < RobotLab::Hook
337
+ self.namespace = :semantic_compressor
338
+
339
+ def self.on_compaction(ctx)
340
+ ctx.compacted_messages = MyCompressor.run(ctx.messages_before, ctx.robot)
341
+ end
342
+ end
343
+
344
+ robot.on(SemanticCompressor)
345
+ ```
346
+
347
+ If `on_compaction` does not assign `ctx.compacted_messages`, the core algorithm runs as normal.
348
+
349
+ > [!WARNING]
350
+ > With multiple `on_compaction` handlers, **the last one to assign
351
+ > `ctx.compacted_messages` wins.** `Hooks.call(:on_compaction, ...)` runs every
352
+ > registered handler unconditionally, and `compacted_messages` is a plain
353
+ > accessor — `handled?` is only consulted *after* all of them have run, so a later
354
+ > handler silently overwrites an earlier handler's message array. Register at most
355
+ > one compaction-replacing handler, or have later handlers check `ctx.handled?`
356
+ > themselves before assigning.
357
+
358
+ > **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.
359
+
360
+ ### `on_learn` — Implementing Long-Term Persistence
361
+
362
+ `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.
363
+
364
+ 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:
365
+
366
+ ```ruby
367
+ class DurableMemoryHook < RobotLab::Hook
368
+ self.namespace = :durable
369
+
370
+ def self.on_learn(ctx)
371
+ return unless ctx.stored # skip deduplicated-away learnings
372
+
373
+ LongTermStore.write(
374
+ text: ctx.text,
375
+ robot: ctx.robot.name,
376
+ domain: ctx.local.domain
377
+ )
378
+ end
379
+ end
380
+
381
+ robot.on(DurableMemoryHook, context: { domain: "customer_support" })
382
+ ```
383
+
384
+ 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.
385
+
386
+ `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.
387
+
388
+ ---
389
+
390
+ ## Context Objects
391
+
392
+ 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).
393
+
394
+ ### RunHookContext
395
+
396
+ Passed to `:run` hooks (`before_run`, `around_run`, `after_run`, `on_error`).
397
+
398
+ | Attribute | Type | Notes |
399
+ |-----------|------|-------|
400
+ | `event` | Symbol | `:run` |
401
+ | `robot` | Robot | The robot executing |
402
+ | `network` | Network\|nil | Present when running inside a network |
403
+ | `task` | Task\|nil | Present when running as a network task |
404
+ | `request` | String\|nil | The message passed to `run` |
405
+ | `response` | RobotResult\|nil | Set after the run completes; readable in `after_run` and `on_error` |
406
+ | `error` | Exception\|nil | Set when `on_error` fires |
407
+ | `metadata` | ExtensionState | Namespace-isolated state (backing store for `ctx.local` / `ctx.ext`) |
408
+
409
+ ### LlmGenerationHookContext
410
+
411
+ Extends `RunHookContext` and is passed to `:llm_generation` hooks. All `RunHookContext` attributes are present, plus:
412
+
413
+ | Attribute | Type | Notes |
414
+ |-----------|------|-------|
415
+ | `generation_response` | RubyLLM::Message\|nil | Set after the LLM responds; readable in `after_llm_generation` |
416
+ | `iteration` | Integer | **Always `0`.** The field exists but is never passed a value, because the family fires once per run rather than once per LLM call. Do not branch on it. |
417
+
418
+ ### ToolCallHookContext
419
+
420
+ Passed to `:tool_call` hooks (`before_tool_call`, `around_tool_call`, `after_tool_call`).
421
+
422
+ | Attribute | Type | Notes |
423
+ |-----------|------|-------|
424
+ | `event` | Symbol | `:tool_call` |
425
+ | `tool` | Tool | The tool instance being called |
426
+ | `tool_name` | String | |
427
+ | `tool_args` | Hash | Arguments passed to the tool |
428
+ | `tool_result` | Object\|nil | Set after the tool executes; readable in `after_tool_call` |
429
+ | `tool_error` | Exception\|nil | Set if the tool raised an exception |
430
+ | `metadata` | ExtensionState | |
431
+
432
+ ### NetworkRunHookContext
433
+
434
+ Passed to `:network_run` hooks (`before_network_run`, `around_network_run`, `after_network_run`, `on_error`).
435
+
436
+ | Attribute | Type | Notes |
437
+ |-----------|------|-------|
438
+ | `event` | Symbol | `:network_run` |
439
+ | `network` | Network | |
440
+ | `context` | Hash | The run parameters |
441
+ | `result` | Object\|nil | Set after the network completes |
442
+ | `error` | Exception\|nil | Set when `on_error` fires |
443
+ | `metadata` | ExtensionState | |
444
+
445
+ ### TaskHookContext
446
+
447
+ Passed to `:task` hooks (`before_task`, `around_task`, `after_task`, `on_error`).
448
+
449
+ | Attribute | Type | Notes |
450
+ |-----------|------|-------|
451
+ | `event` | Symbol | `:task` |
452
+ | `network` | Network\|nil | |
453
+ | `task` | Task | |
454
+ | `task_name` | Symbol | |
455
+ | `robot` | Robot | The robot executing this task |
456
+ | `result` | Object\|nil | Set after the task completes |
457
+ | `error` | Exception\|nil | Set when `on_error` fires |
458
+ | `metadata` | ExtensionState | |
459
+
460
+ ### CompactionHookContext
461
+
462
+ Passed to `:compaction` hooks (`before_compaction`, `around_compaction`, `after_compaction`, `on_compaction`).
463
+
464
+ | Attribute | Type | Notes |
465
+ |-----------|------|-------|
466
+ | `event` | Symbol | `:compaction` |
467
+ | `robot` | Robot | The robot whose history is being compacted |
468
+ | `messages_before` | Array (frozen) | Snapshot of `@chat.messages` at the moment compaction was triggered |
469
+ | `config` | RunConfig | The robot's active configuration |
470
+ | `strategy` | Symbol | `:context_window` when triggered by the threshold check; `:custom` when triggered by a `Proc` |
471
+ | `compacted_messages` | Array\|nil | Set by the core algorithm after compaction, or set by an `on_compaction` handler to replace the core algorithm |
472
+ | `error` | Exception\|nil | Set if compaction raises |
473
+ | `metadata` | ExtensionState | |
474
+
475
+ #### `ctx.handled?`
476
+
477
+ 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-replacing-the-core-strategy) below.
478
+
479
+ ### LearnHookContext
480
+
481
+ Passed to `:learn` hooks (`before_learn`, `around_learn`, `after_learn`, `on_learn`).
482
+
483
+ | Attribute | Type | Notes |
484
+ |-----------|------|-------|
485
+ | `event` | Symbol | `:learn` |
486
+ | `robot` | Robot | The robot learning |
487
+ | `text` | String | The stripped, non-empty learning text passed to `robot.learn` |
488
+ | `learnings_before` | Array (frozen) | Snapshot of `robot.learnings` at the moment `learn` was called |
489
+ | `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`. |
490
+ | `error` | Exception\|nil | Set if the learn block raises |
491
+ | `metadata` | ExtensionState | |
492
+
493
+ #### `ctx.stored`
494
+
495
+ `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.
496
+
497
+ ---
498
+
499
+ ## Per-Run Hooks
500
+
501
+ 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:
502
+
503
+ ```ruby
504
+ result = robot.run("summarize this", hooks: TraceHook)
505
+ ```
506
+
507
+ ```ruby
508
+ result = robot.run("summarize this", hooks: [TraceHook, MetricsHook])
509
+ ```
510
+
511
+ Per-run hooks fire after robot-level hooks, in the order supplied.
512
+
513
+ ---
514
+
515
+ ## Error Hooks
516
+
517
+ 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:
518
+
519
+ ```ruby
520
+ class AlertingHook < RobotLab::Hook
521
+ self.namespace = :alerting
522
+
523
+ def self.on_error(ctx)
524
+ if ctx.respond_to?(:robot)
525
+ puts "ERROR in #{ctx.robot.name}: #{ctx.error.class} — #{ctx.error.message}"
526
+ Alerting.notify(ctx.error, robot: ctx.robot.name, request: ctx.request)
527
+ else
528
+ puts "Network error: #{ctx.error.message}"
529
+ end
530
+ end
531
+ end
532
+
533
+ RobotLab.on(AlertingHook)
534
+ ```
535
+
536
+ `on_error` does not suppress the exception. The error continues to propagate after all `on_error` hooks finish.
537
+
538
+ ---
539
+
540
+ ## Writing an Extension
541
+
542
+ 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.
543
+
544
+ ```ruby
545
+ class MyExtension < RobotLab::Hook
546
+ self.namespace = :my_ext
547
+
548
+ class << self
549
+ attr_writer :logger
550
+ def logger
551
+ @logger ||= Logger.new($stdout)
552
+ end
553
+
554
+ attr_accessor :call_count # class-level: survives across runs
555
+
556
+ def before_run(ctx)
557
+ self.call_count = call_count.to_i + 1
558
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
559
+ logger.info("run ##{call_count} starting: #{ctx.request.inspect}")
560
+ end
561
+
562
+ def after_run(ctx)
563
+ logger.info("run done: #{ctx.response&.reply.to_s[0, 80]}")
564
+ end
565
+
566
+ def on_error(ctx)
567
+ logger.error("run failed: #{ctx.error.class} — #{ctx.error.message}")
568
+ end
569
+ end
570
+ end
571
+ ```
572
+
573
+ To attach globally:
574
+
575
+ ```ruby
576
+ RobotLab.on(MyExtension)
577
+ ```
578
+
579
+ To attach only to a specific network:
580
+
581
+ ```ruby
582
+ network.on(MyExtension)
583
+ ```
584
+
585
+ To attach only to a specific robot:
586
+
587
+ ```ruby
588
+ robot.on(MyExtension)
589
+ ```
590
+
591
+ With per-registration default state — re-applied at the start of every run, so use it for constants and configuration, not for accumulators:
592
+
593
+ ```ruby
594
+ RobotLab.on(MyExtension, context: { service: "checkout" })
595
+ ```
596
+
597
+ ### Extension Guidelines
598
+
599
+ - Set `self.namespace = :my_name` explicitly so callers can read the namespace without relying on class naming conventions.
600
+ - Use `context:` on the `on(...)` call to declare default state rather than guarding against `nil` inside callbacks. Remember the defaults are re-applied per run — keep anything that must accumulate on the handler class instead of in `ctx.local`.
601
+ - Around hooks whose block result is the operation's result (`around_run`, `around_llm_generation`, `around_network_run`, `around_task`) must call `block.call` **and** return its value — omitting either causes the run to return `nil`.
602
+ - `around_compaction` and `around_learn` are return-value-agnostic: nothing consumes their result, so forgetting to return the block's value is harmless. They must still call `block.call` — skipping it suppresses the compaction (or the learning) entirely.
603
+ - `around_tool_call` is different again: 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.
604
+ - Keep error hooks non-raising. Exceptions from hook callbacks propagate and can mask the original error.
605
+ - Test each callback method in isolation by constructing a context object directly and calling the class method.
606
+
607
+ ---
608
+
609
+ ## Common Patterns
610
+
611
+ ### Performance Timer
612
+
613
+ ```ruby
614
+ class PerfHook < RobotLab::Hook
615
+ self.namespace = :perf
616
+
617
+ def self.around_run(ctx, &block)
618
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
619
+ result = block.call
620
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
621
+ $stderr.puts "[perf] #{ctx.robot.name} #{ms}ms"
622
+ result
623
+ end
624
+ end
625
+
626
+ RobotLab.on(PerfHook)
627
+ ```
628
+
629
+ ### Request/Response Tracer
630
+
631
+ ```ruby
632
+ class TraceHook < RobotLab::Hook
633
+ self.namespace = :trace
634
+
635
+ def self.before_run(ctx)
636
+ puts "→ #{ctx.robot.name}: #{ctx.request.inspect}"
637
+ end
638
+
639
+ def self.after_run(ctx)
640
+ puts "← #{ctx.robot.name}: #{ctx.response&.reply.to_s[0, 120]}"
641
+ end
642
+ end
643
+
644
+ robot.on(TraceHook)
645
+ ```
646
+
647
+ ### LLM Response Cache
648
+
649
+ 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:
650
+
651
+ ```ruby
652
+ class LlmCacheHook < RobotLab::Hook
653
+ self.namespace = :cache
654
+
655
+ class << self
656
+ attr_accessor :hits # class-level — ctx.local resets every run
657
+
658
+ def around_llm_generation(ctx, &block)
659
+ cached = ResponseCache.get(ctx.request)
660
+ if cached
661
+ self.hits = hits.to_i + 1
662
+ cached # return cached value — block.call (LLM) is skipped
663
+ else
664
+ result = block.call # LLM call happens here
665
+ ResponseCache.set(ctx.request, result)
666
+ result
667
+ end
668
+ end
669
+ end
670
+ end
671
+
672
+ robot.on(LlmCacheHook)
673
+ ```
674
+
675
+ Skipping `block.call` here skips the *entire* generation phase for that run, including the provider's tool loop — the `:llm_generation` family wraps the whole phase, not one API round trip.
676
+
677
+ ### Tool Call Audit Log
678
+
679
+ ```ruby
680
+ class ToolAuditHook < RobotLab::Hook
681
+ self.namespace = :audit
682
+
683
+ def self.before_tool_call(ctx)
684
+ AuditLog.write(
685
+ robot: ctx.robot&.name,
686
+ tool: ctx.tool_name,
687
+ args: ctx.tool_args,
688
+ timestamp: Time.now.utc
689
+ )
690
+ end
691
+ end
692
+
693
+ RobotLab.on(ToolAuditHook)
694
+ ```
695
+
696
+ ### Learn Audit Log
697
+
698
+ Record every learning attempt — including the ones that were deduplicated away — for debugging or analytics:
699
+
700
+ ```ruby
701
+ class LearnAuditHook < RobotLab::Hook
702
+ self.namespace = :learn_audit
703
+
704
+ def self.after_learn(ctx)
705
+ status = ctx.stored ? "stored" : "skipped (covered)"
706
+ $stderr.puts "[learn] #{ctx.robot.name}: #{status} — #{ctx.text.inspect}"
707
+ end
708
+ end
709
+
710
+ RobotLab.on(LearnAuditHook)
711
+ ```
712
+
713
+ ### Long-Term Memory Promotion via `on_learn`
714
+
715
+ Persist new learnings to durable storage. `on_learn` fires after session storage so the session state is already updated when the extension runs:
716
+
717
+ ```ruby
718
+ class DurableLearnHook < RobotLab::Hook
719
+ self.namespace = :durable
720
+
721
+ def self.on_learn(ctx)
722
+ return unless ctx.stored
723
+
724
+ DurableStore.promote(
725
+ text: ctx.text,
726
+ robot: ctx.robot.name,
727
+ domain: ctx.local.domain,
728
+ timestamp: Time.now.utc
729
+ )
730
+ end
731
+ end
732
+
733
+ robot.on(DurableLearnHook, context: { domain: "finance" })
734
+ ```
735
+
736
+ ### Blocking Unauthorised Learnings
737
+
738
+ Use `around_learn` to gate what a robot is allowed to learn — useful when the robot's system prompt comes from untrusted input:
739
+
740
+ ```ruby
741
+ class LearnGuardHook < RobotLab::Hook
742
+ self.namespace = :learn_guard
743
+
744
+ BLOCKED_PATTERN = /ignore previous instructions|forget everything/i
745
+
746
+ def self.around_learn(ctx, &block)
747
+ if BLOCKED_PATTERN.match?(ctx.text)
748
+ $stderr.puts "[learn_guard] Blocked: #{ctx.text.inspect}"
749
+ # Do not call block.call — the learning is silently dropped
750
+ else
751
+ block.call
752
+ end
753
+ end
754
+ end
755
+
756
+ RobotLab.on(LearnGuardHook)
757
+ ```
758
+
759
+ ### Compaction Observability
760
+
761
+ Log when and why history compression fires, and how much it reduced the message count:
762
+
763
+ ```ruby
764
+ class CompactionLoggerHook < RobotLab::Hook
765
+ self.namespace = :compaction_logger
766
+
767
+ def self.before_compaction(ctx)
768
+ ctx.local.before_count = ctx.messages_before.size
769
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
770
+ end
771
+
772
+ def self.after_compaction(ctx)
773
+ after_count = ctx.compacted_messages&.size || ctx.local.before_count
774
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
775
+ dropped = ctx.local.before_count - after_count
776
+ $stderr.puts "[compaction] #{ctx.robot.name} #{ctx.strategy}: " \
777
+ "#{ctx.local.before_count} → #{after_count} messages " \
778
+ "(-#{dropped}) in #{elapsed_ms}ms"
779
+ end
780
+ end
781
+
782
+ RobotLab.on(CompactionLoggerHook)
783
+ ```
784
+
785
+ ### Promote Session Learnings Before Compaction
786
+
787
+ 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:
788
+
789
+ ```ruby
790
+ class LearningPromotionHook < RobotLab::Hook
791
+ self.namespace = :learning_promotion
792
+
793
+ def self.before_compaction(ctx)
794
+ ctx.local.message_ids_before = ctx.messages_before.map(&:object_id).to_set
795
+ end
796
+
797
+ def self.after_compaction(ctx)
798
+ return unless ctx.compacted_messages
799
+ surviving_ids = ctx.compacted_messages.map(&:object_id).to_set
800
+ dropped = ctx.messages_before.reject { |m| surviving_ids.include?(m.object_id) }
801
+ LongTermStore.promote(dropped, domain: ctx.robot.name) if dropped.any?
802
+ end
803
+ end
804
+
805
+ robot.on(LearningPromotionHook)
806
+ ```
807
+
808
+ ### Custom Compaction Strategy
809
+
810
+ Replace the built-in term-frequency compressor with a domain-specific algorithm using `on_compaction`:
811
+
812
+ ```ruby
813
+ class SummarizerCompactor < RobotLab::Hook
814
+ self.namespace = :summarizer_compactor
815
+
816
+ KEEP_RECENT = 4 # always keep the last N user+assistant pairs verbatim
817
+
818
+ def self.on_compaction(ctx)
819
+ messages = ctx.messages_before
820
+ pinned = messages.select { |m| %i[system tool tool_result].include?(m.role) }
821
+ scoreable = messages.reject { |m| %i[system tool tool_result].include?(m.role) }
822
+
823
+ recent = scoreable.last(KEEP_RECENT * 2)
824
+ older = scoreable.first([scoreable.size - KEEP_RECENT * 2, 0].max)
825
+
826
+ summary_text = ctx.robot.run("Summarize in two sentences: #{older.map(&:content).join(' ')}")
827
+ .reply
828
+
829
+ summary_msg = OpenStruct.new(role: :assistant, content: summary_text,
830
+ tool_calls: nil, stop_reason: :stop)
831
+
832
+ ctx.compacted_messages = pinned + [summary_msg] + recent
833
+ end
834
+ end
835
+
836
+ robot.on(SummarizerCompactor)
837
+ ```
838
+
839
+ ### Run Counter Per Robot
840
+
841
+ Cross-run tallies belong on the handler class — `ctx.local` is wiped between runs:
842
+
843
+ ```ruby
844
+ class MetricsHook < RobotLab::Hook
845
+ self.namespace = :metrics
846
+
847
+ class << self
848
+ def counts = @counts ||= Hash.new(0)
849
+
850
+ def before_run(ctx)
851
+ counts[ctx.robot.name] += 1
852
+ end
853
+ end
854
+ end
855
+
856
+ RobotLab.on(MetricsHook)
857
+
858
+ # later
859
+ MetricsHook.counts # => { "classifier" => 12, "responder" => 12 }
860
+ ```
861
+
862
+ ---
863
+
864
+ ## Application Use Cases
865
+
866
+ 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.
867
+
868
+ ### Observability and Distributed Tracing
869
+
870
+ Instrument every LLM call with a trace ID and structured log entry for ingestion into OpenTelemetry, Datadog, or a custom logging pipeline:
871
+
872
+ ```ruby
873
+ class ObservabilityHook < RobotLab::Hook
874
+ self.namespace = :trace
875
+
876
+ def self.before_run(ctx)
877
+ ctx.local.trace_id = SecureRandom.hex(8)
878
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
879
+ end
880
+
881
+ def self.after_run(ctx)
882
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
883
+ Logger.info(
884
+ event: "robot.run.completed",
885
+ trace_id: ctx.local.trace_id,
886
+ robot: ctx.robot.name,
887
+ elapsed_ms: elapsed_ms,
888
+ reply: ctx.response&.reply.to_s[0, 120]
889
+ )
890
+ end
891
+
892
+ def self.on_error(ctx)
893
+ Logger.error(
894
+ event: "robot.run.failed",
895
+ trace_id: ctx.local.trace_id,
896
+ robot: ctx.robot.name,
897
+ error: ctx.error.class.to_s,
898
+ message: ctx.error.message
899
+ )
900
+ end
901
+ end
902
+
903
+ RobotLab.on(ObservabilityHook)
904
+ ```
905
+
906
+ ### Cost Enforcement
907
+
908
+ Cap total spending across all robots in a session and raise before an expensive run would push you over budget:
909
+
910
+ A session total must live on the handler class — `ctx.local` is re-created per run and would reset the tally every time:
911
+
912
+ ```ruby
913
+ class BudgetHook < RobotLab::Hook
914
+ self.namespace = :budget
915
+
916
+ COST_PER_INPUT_TOKEN = 0.80 / 1_000_000
917
+ COST_PER_OUTPUT_TOKEN = 4.00 / 1_000_000
918
+ SESSION_BUDGET_USD = 0.50
919
+
920
+ class << self
921
+ def total_cost = @total_cost ||= 0.0
922
+
923
+ def before_run(_ctx)
924
+ if total_cost >= SESSION_BUDGET_USD
925
+ raise RobotLab::Error, "Session budget of $#{SESSION_BUDGET_USD} exceeded"
926
+ end
927
+ end
928
+
929
+ def after_run(ctx)
930
+ r = ctx.response
931
+ @total_cost = total_cost +
932
+ r.input_tokens * COST_PER_INPUT_TOKEN +
933
+ r.output_tokens * COST_PER_OUTPUT_TOKEN
934
+ end
935
+ end
936
+ end
937
+
938
+ RobotLab.on(BudgetHook)
939
+ ```
940
+
941
+ 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.
942
+
943
+ ### Tool Access Control
944
+
945
+ Allow only approved tools to execute for a given network, without hard-coding restrictions in the tool itself:
946
+
947
+ ```ruby
948
+ class AccessControlHook < RobotLab::Hook
949
+ self.namespace = :access_control
950
+
951
+ ALLOWED_TOOLS = %w[web_search calculator].freeze
952
+
953
+ def self.around_tool_call(ctx, &block)
954
+ if ALLOWED_TOOLS.include?(ctx.tool_name)
955
+ block.call
956
+ else
957
+ ctx.tool_result = "[blocked] Tool '#{ctx.tool_name}' is not permitted in this network."
958
+ end
959
+ end
960
+ end
961
+
962
+ network.on(AccessControlHook)
963
+ ```
964
+
965
+ ### Audit Logging for Compliance
966
+
967
+ Record every tool invocation with its arguments and result for security audits or regulatory compliance:
968
+
969
+ ```ruby
970
+ class ComplianceAuditHook < RobotLab::Hook
971
+ self.namespace = :audit
972
+
973
+ def self.after_tool_call(ctx)
974
+ AuditLog.append(
975
+ robot: ctx.robot&.name,
976
+ tool: ctx.tool_name,
977
+ args: ctx.tool_args,
978
+ result: ctx.tool_result.to_s[0, 500],
979
+ timestamp: Time.now.utc.iso8601
980
+ )
981
+ end
982
+ end
983
+
984
+ RobotLab.on(ComplianceAuditHook)
985
+ ```
986
+
987
+ ### Retry with Exponential Backoff
988
+
989
+ Wrap `around_run` to retry on transient LLM failures without changing any robot code:
990
+
991
+ ```ruby
992
+ class RetryHook < RobotLab::Hook
993
+ self.namespace = :retry
994
+
995
+ MAX_RETRIES = 3
996
+ BACKOFF_BASE = 0.5
997
+
998
+ def self.around_run(ctx, &block)
999
+ attempts = 0
1000
+ begin
1001
+ attempts += 1
1002
+ block.call
1003
+ rescue RobotLab::InferenceError
1004
+ raise if attempts >= MAX_RETRIES
1005
+
1006
+ sleep(BACKOFF_BASE * (2**(attempts - 1)))
1007
+ retry
1008
+ end
1009
+ end
1010
+ end
1011
+
1012
+ RobotLab.on(RetryHook)
1013
+ ```
1014
+
1015
+ ### Test Doubles Without Network Calls
1016
+
1017
+ Inject canned LLM responses in tests by short-circuiting `around_llm_generation`. No VCR cassettes, no HTTP mocks:
1018
+
1019
+ ```ruby
1020
+ class TestDoubleHook < RobotLab::Hook
1021
+ self.namespace = :test_double
1022
+
1023
+ CANNED_RESPONSES = {
1024
+ "classify this" => FakeResponse.new(content: "positive"),
1025
+ "summarize" => FakeResponse.new(content: "short summary")
1026
+ }.freeze
1027
+
1028
+ def self.around_llm_generation(ctx, &block)
1029
+ canned = CANNED_RESPONSES[ctx.request]
1030
+ canned ? canned : block.call
1031
+ end
1032
+ end
1033
+
1034
+ # In test setup:
1035
+ RobotLab.on(TestDoubleHook)
1036
+ ```
1037
+
1038
+ ### Per-Network Latency Metrics
1039
+
1040
+ Collect timing data scoped to a specific network without touching global hooks:
1041
+
1042
+ ```ruby
1043
+ class NetworkMetricsHook < RobotLab::Hook
1044
+ self.namespace = :metrics
1045
+
1046
+ def self.before_network_run(ctx)
1047
+ ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1048
+ end
1049
+
1050
+ def self.after_network_run(ctx)
1051
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
1052
+ Metrics.histogram("network.run.duration_ms", elapsed_ms, tags: { network: ctx.network.name })
1053
+ end
1054
+ end
1055
+
1056
+ network.on(NetworkMetricsHook)
1057
+ ```
1058
+
1059
+ ### Sensitive Data Redaction
1060
+
1061
+ Scrub PII from requests before they are logged or sent to the LLM:
1062
+
1063
+ ```ruby
1064
+ class RedactionHook < RobotLab::Hook
1065
+ self.namespace = :redaction
1066
+
1067
+ PII_PATTERN = /\b[A-Z]{2}\d{6,9}\b/ # example: passport numbers
1068
+
1069
+ def self.before_run(ctx)
1070
+ ctx.request = ctx.request&.gsub(PII_PATTERN, "[REDACTED]")
1071
+ end
1072
+ end
1073
+
1074
+ RobotLab.on(RedactionHook)
1075
+ ```
1076
+
1077
+ ---
1078
+
1079
+ ## See Also
1080
+
1081
+ - [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
1082
+ - [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
1083
+ - [Robot Execution](../architecture/robot-execution.md)
1084
+ - [Observability & Safety](observability.md)