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.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +5 -167
- data/CHANGELOG.md +54 -0
- data/README.md +61 -0
- data/Rakefile +28 -12
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/getting-started/configuration.md +20 -1
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +2 -2
- data/docs/guides/observability.md +74 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +1 -1
- data/examples/03_network.rb +11 -2
- data/examples/34_agentskills.rb +0 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +7 -0
- data/examples/common.rb +7 -3
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +5 -4
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +4 -4
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/connection_poller.rb +2 -2
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +76 -12
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +8 -2
- data/lib/robot_lab/robot/template_rendering.rb +2 -2
- data/lib/robot_lab/robot.rb +211 -88
- data/lib/robot_lab/run_config.rb +24 -5
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +60 -2
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +52 -11
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +49 -0
- data/mkdocs.yml +1 -0
- metadata +36 -99
- data/examples/temp.md +0 -51
- data/site/404.html +0 -2300
- data/site/api/core/index.html +0 -2706
- data/site/api/core/memory/index.html +0 -3793
- data/site/api/core/network/index.html +0 -3500
- data/site/api/core/robot/index.html +0 -4566
- data/site/api/core/state/index.html +0 -3390
- data/site/api/core/tool/index.html +0 -3843
- data/site/api/index.html +0 -2635
- data/site/api/mcp/client/index.html +0 -3435
- data/site/api/mcp/index.html +0 -2783
- data/site/api/mcp/server/index.html +0 -3252
- data/site/api/mcp/transports/index.html +0 -3352
- data/site/api/messages/index.html +0 -2641
- data/site/api/messages/text-message/index.html +0 -3087
- data/site/api/messages/tool-call-message/index.html +0 -3159
- data/site/api/messages/tool-result-message/index.html +0 -3252
- data/site/api/messages/user-message/index.html +0 -3212
- data/site/api/streaming/context/index.html +0 -3282
- data/site/api/streaming/events/index.html +0 -3347
- data/site/api/streaming/index.html +0 -2738
- data/site/architecture/core-concepts/index.html +0 -3757
- data/site/architecture/index.html +0 -2797
- data/site/architecture/message-flow/index.html +0 -3238
- data/site/architecture/network-orchestration/index.html +0 -3433
- data/site/architecture/robot-execution/index.html +0 -3140
- data/site/architecture/state-management/index.html +0 -3498
- data/site/assets/css/custom.css +0 -56
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
- data/site/assets/javascripts/lunr/tinyseg.js +0 -206
- data/site/assets/javascripts/lunr/wordcut.js +0 -6708
- data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
- data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
- data/site/concepts/index.html +0 -3455
- data/site/examples/basic-chat/index.html +0 -2880
- data/site/examples/index.html +0 -2907
- data/site/examples/mcp-server/index.html +0 -3018
- data/site/examples/multi-robot-network/index.html +0 -3131
- data/site/examples/rails-application/index.html +0 -3329
- data/site/examples/tool-usage/index.html +0 -3085
- data/site/getting-started/configuration/index.html +0 -3745
- data/site/getting-started/index.html +0 -2572
- data/site/getting-started/installation/index.html +0 -2981
- data/site/getting-started/quick-start/index.html +0 -2942
- data/site/guides/building-robots/index.html +0 -4290
- data/site/guides/creating-networks/index.html +0 -3858
- data/site/guides/index.html +0 -2586
- data/site/guides/mcp-integration/index.html +0 -3581
- data/site/guides/memory/index.html +0 -3586
- data/site/guides/rails-integration/index.html +0 -4019
- data/site/guides/streaming/index.html +0 -3157
- data/site/guides/using-tools/index.html +0 -3802
- data/site/index.html +0 -2671
- data/site/search/search_index.json +0 -1
- data/site/sitemap.xml +0 -183
- data/site/sitemap.xml.gz +0 -0
- 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)
|