Hook System¶
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.
Hook Families¶
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.
| Family | Hook names | Fires during |
|---|---|---|
:run |
before_run, around_run, after_run, on_error |
every robot.run(...) call |
: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) |
:tool_call |
before_tool_call, around_tool_call, after_tool_call |
each tool invocation |
:network_run |
before_network_run, around_network_run, after_network_run, on_error |
every network.run(...) call |
:task |
before_task, around_task, after_task, on_error |
each robot task within a network run |
:compaction |
before_compaction, around_compaction, after_compaction, on_compaction |
when conversation history is about to be compressed |
:learn |
before_learn, around_learn, after_learn, on_learn |
every robot.learn(text) call that carries non-empty text |
Within a single robot.run(...) that triggers two tool calls and one compaction, the firing order is:
before_run
around_run {
before_llm_generation
around_llm_generation {
before_compaction
around_compaction {
on_compaction # only fires if a handler is registered
[compress history]
}
after_compaction
[LLM call 1]
}
after_llm_generation
before_tool_call
around_tool_call { [tool invocation] }
after_tool_call
before_llm_generation
around_llm_generation { [LLM call 2] }
after_llm_generation
}
after_run
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.
The :learn family fires synchronously inside robot.learn(text), once per call. Hooks do not fire when text is blank.
Handler Classes¶
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.
class MyHook < RobotLab::Hook
class << self
def before_run(ctx)
# fires before every robot.run call
end
def after_run(ctx)
# fires after every robot.run call
end
def on_error(ctx)
# fires when an unhandled exception escapes a run
end
end
end
Namespace auto-derivation¶
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:
| Class name | Auto namespace |
|---|---|
TimerHook |
:timer_hook |
AuditHook |
:audit_hook |
PerfMonitor |
:perf_monitor |
MyExt::Tracer |
:tracer |
Override the auto-derived namespace at the class level:
RobotLab::Hook base class¶
class Hook
class << self
attr_writer :namespace
def namespace
return @namespace if @namespace
return nil if self == Hook
name.split('::').last
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.downcase.to_sym
end
def call(hook_name, context, &block)
if singleton_class.public_method_defined?(hook_name)
block ? public_send(hook_name, context, &block) : public_send(hook_name, context)
elsif block
block.call
end
end
end
end
Registration Levels¶
Hooks are registered on three objects and can optionally be scoped to a single call:
| Level | Registration | Scope |
|---|---|---|
| Global | RobotLab.on(...) |
Every robot, every network |
| Network | network.on(...) |
Only robots inside that network |
| Robot | robot.on(...) |
Only that robot |
| Per-run | robot.run("msg", hooks: ...) |
A single run call |
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.
The on Method¶
All three registration objects share the same signature:
RobotLab.on(handler_class, context: nil)
network.on(handler_class, context: nil)
robot.on(handler_class, context: nil)
| Parameter | Type | Description |
|---|---|---|
handler_class |
Class | A subclass of RobotLab::Hook |
context: |
Hash|nil | Default state pre-populated into the handler's namespace DotState before each callback fires |
handler_class must be a subclass of RobotLab::Hook. The namespace is read from handler_class.namespace — there is no namespace: parameter on on.
Namespaces and ctx.local¶
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.
class TimerHook < RobotLab::Hook
self.namespace = :timer
def self.before_run(ctx)
ctx.local.start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def self.after_run(ctx)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.start_time
puts "run took #{elapsed.round(3)}s"
end
end
DotState is an open struct-like object. Keys are written and read with dot notation. Any key can be set; there is no schema.
To read another handler's state from within a hook, use ctx.ext(:other_namespace):
class ReporterHook < RobotLab::Hook
self.namespace = :reporter
def self.after_run(ctx)
timer_data = ctx.ext(:timer)
puts "elapsed since start: #{timer_data.start_time}"
end
end
The context: Parameter — Default State¶
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:
class CounterHook < RobotLab::Hook
self.namespace = :counter
def self.before_run(ctx)
ctx.local.count += 1
puts "run ##{ctx.local.count}"
end
end
RobotLab.on(CounterHook, context: { count: 0 })
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.
Around Hooks¶
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:
class PerfHook < RobotLab::Hook
self.namespace = :perf
def self.around_run(ctx, &block)
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = block.call # MUST call — this is the actual run
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
puts "#{ctx.request.inspect} — #{elapsed}ms"
result # MUST return — callers expect the real result
end
end
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.
Around hooks registered across different handler classes are chained: each wraps the next, with the actual operation at the innermost layer.
around_tool_call is different¶
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.
To let the tool execute normally, call block.call — it sets ctx.tool_result — and then do any post-processing:
class ToolTimingHook < RobotLab::Hook
self.namespace = :timing
def self.around_tool_call(ctx, &block)
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
block.call # executes the tool and populates ctx.tool_result
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
$stderr.puts "[tool] #{ctx.tool_name} — #{ms}ms"
end
end
To short-circuit the tool (skip execution entirely), skip block.call and set ctx.tool_result directly:
class ToolGuardHook < RobotLab::Hook
self.namespace = :guard
ALLOWED_TOOLS = %w[web_search calculator].freeze
def self.around_tool_call(ctx, &block)
if ALLOWED_TOOLS.include?(ctx.tool_name)
block.call
else
ctx.tool_result = "Operation not permitted: #{ctx.tool_name}"
end
end
end
on_compaction — Replacing the Core Strategy¶
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.
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:
class SemanticCompressor < RobotLab::Hook
self.namespace = :semantic_compressor
def self.on_compaction(ctx)
ctx.compacted_messages = MyCompressor.run(ctx.messages_before, ctx.robot)
end
end
robot.on(SemanticCompressor)
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.
Note:
on_compactionfires inside the core block ofHooks.run(:compaction). It is not a standardbefore_*/around_*/after_*hook — it does not compose with around handlers or produce a chainable result. Usearound_compactionif you need to wrap the entire process including observation of the final result.
on_learn — Implementing Long-Term Persistence¶
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.
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:
class DurableMemoryHook < RobotLab::Hook
self.namespace = :durable
def self.on_learn(ctx)
return unless ctx.stored # skip deduplicated-away learnings
LongTermStore.write(
text: ctx.text,
robot: ctx.robot.name,
domain: ctx.local.domain
)
end
end
robot.on(DurableMemoryHook, context: { domain: "customer_support" })
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.
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.
Context Objects¶
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).
RunHookContext¶
Passed to :run hooks (before_run, around_run, after_run, on_error).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :run |
robot |
Robot | The robot executing |
network |
Network|nil | Present when running inside a network |
task |
Task|nil | Present when running as a network task |
request |
String|nil | The message passed to run |
response |
RobotResult|nil | Set after the run completes; readable in after_run and on_error |
error |
Exception|nil | Set when on_error fires |
metadata |
ExtensionState | Namespace-isolated state (backing store for ctx.local / ctx.ext) |
LlmGenerationHookContext¶
Extends RunHookContext and is passed to :llm_generation hooks. All RunHookContext attributes are present, plus:
| Attribute | Type | Notes |
|---|---|---|
generation_response |
RubyLLM::Message|nil | Set after the LLM responds; readable in after_llm_generation |
iteration |
Integer | Which LLM call within this run, 0-based |
ToolCallHookContext¶
Passed to :tool_call hooks (before_tool_call, around_tool_call, after_tool_call).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :tool_call |
tool |
Tool | The tool instance being called |
tool_name |
String | |
tool_args |
Hash | Arguments passed to the tool |
tool_result |
Object|nil | Set after the tool executes; readable in after_tool_call |
tool_error |
Exception|nil | Set if the tool raised an exception |
metadata |
ExtensionState |
NetworkRunHookContext¶
Passed to :network_run hooks (before_network_run, around_network_run, after_network_run, on_error).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :network_run |
network |
Network | |
context |
Hash | The run parameters |
result |
Object|nil | Set after the network completes |
error |
Exception|nil | Set when on_error fires |
metadata |
ExtensionState |
TaskHookContext¶
Passed to :task hooks (before_task, around_task, after_task, on_error).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :task |
network |
Network|nil | |
task |
Task | |
task_name |
Symbol | |
robot |
Robot | The robot executing this task |
result |
Object|nil | Set after the task completes |
error |
Exception|nil | Set when on_error fires |
metadata |
ExtensionState |
CompactionHookContext¶
Passed to :compaction hooks (before_compaction, around_compaction, after_compaction, on_compaction).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :compaction |
robot |
Robot | The robot whose history is being compacted |
messages_before |
Array (frozen) | Snapshot of @chat.messages at the moment compaction was triggered |
config |
RunConfig | The robot's active configuration |
strategy |
Symbol | :context_window when triggered by the threshold check; :custom when triggered by a Proc |
compacted_messages |
Array|nil | Set by the core algorithm after compaction, or set by an on_compaction handler to replace the core algorithm |
error |
Exception|nil | Set if compaction raises |
metadata |
ExtensionState |
ctx.handled?¶
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 below.
LearnHookContext¶
Passed to :learn hooks (before_learn, around_learn, after_learn, on_learn).
| Attribute | Type | Notes |
|---|---|---|
event |
Symbol | :learn |
robot |
Robot | The robot learning |
text |
String | The stripped, non-empty learning text passed to robot.learn |
learnings_before |
Array (frozen) | Snapshot of robot.learnings at the moment learn was called |
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. |
error |
Exception|nil | Set if the learn block raises |
metadata |
ExtensionState |
ctx.stored¶
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.
Per-Run Hooks¶
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:
Per-run hooks fire after robot-level hooks, in the order supplied.
Error Hooks¶
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:
class AlertingHook < RobotLab::Hook
self.namespace = :alerting
def self.on_error(ctx)
if ctx.respond_to?(:robot)
puts "ERROR in #{ctx.robot.name}: #{ctx.error.class} — #{ctx.error.message}"
Alerting.notify(ctx.error, robot: ctx.robot.name, request: ctx.request)
else
puts "Network error: #{ctx.error.message}"
end
end
end
RobotLab.on(AlertingHook)
on_error does not suppress the exception. The error continues to propagate after all on_error hooks finish.
Writing an Extension¶
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.
class MyExtension < RobotLab::Hook
self.namespace = :my_ext
class << self
attr_writer :logger
def logger
@logger ||= Logger.new($stdout)
end
def before_run(ctx)
ctx.local.call_count = (ctx.local.call_count || 0) + 1
logger.info("run ##{ctx.local.call_count} starting: #{ctx.request.inspect}")
end
def after_run(ctx)
logger.info("run done: #{ctx.response&.reply.to_s[0, 80]}")
end
def on_error(ctx)
logger.error("run failed: #{ctx.error.class} — #{ctx.error.message}")
end
end
end
To attach globally:
To attach only to a specific network:
To attach only to a specific robot:
With per-registration default state:
Extension Guidelines¶
- Set
self.namespace = :my_nameexplicitly so callers can read the namespace without relying on class naming conventions. - Use
context:on theon(...)call to declare default state rather than guarding againstnilinside callbacks. - Around hooks (
around_run,around_llm_generation,around_network_run,around_task,around_compaction) must callblock.calland return its value — omitting either causes the run to returnnil.around_tool_callis the exception: the result is carried inctx.tool_result, so callblock.callto let the tool run or setctx.tool_resultdirectly to short-circuit. - Keep error hooks non-raising. Exceptions from hook callbacks propagate and can mask the original error.
- Test each callback method in isolation by constructing a context object directly and calling the class method.
Common Patterns¶
Performance Timer¶
class PerfHook < RobotLab::Hook
self.namespace = :perf
def self.around_run(ctx, &block)
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = block.call
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
$stderr.puts "[perf] #{ctx.robot.name} #{ms}ms"
result
end
end
RobotLab.on(PerfHook)
Request/Response Tracer¶
class TraceHook < RobotLab::Hook
self.namespace = :trace
def self.before_run(ctx)
puts "→ #{ctx.robot.name}: #{ctx.request.inspect}"
end
def self.after_run(ctx)
puts "← #{ctx.robot.name}: #{ctx.response&.reply.to_s[0, 120]}"
end
end
robot.on(TraceHook)
LLM Response Cache¶
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:
class LlmCacheHook < RobotLab::Hook
self.namespace = :cache
def self.around_llm_generation(ctx, &block)
ctx.local.hits ||= 0
cached = ResponseCache.get(ctx.request)
if cached
ctx.local.hits += 1
cached # return cached value — block.call (LLM) is skipped
else
result = block.call # LLM call happens here
ResponseCache.set(ctx.request, result)
result
end
end
end
robot.on(LlmCacheHook, context: { hits: 0 })
Tool Call Audit Log¶
class ToolAuditHook < RobotLab::Hook
self.namespace = :audit
def self.before_tool_call(ctx)
AuditLog.write(
robot: ctx.robot&.name,
tool: ctx.tool_name,
args: ctx.tool_args,
timestamp: Time.now.utc
)
end
end
RobotLab.on(ToolAuditHook)
Learn Audit Log¶
Record every learning attempt — including the ones that were deduplicated away — for debugging or analytics:
class LearnAuditHook < RobotLab::Hook
self.namespace = :learn_audit
def self.after_learn(ctx)
status = ctx.stored ? "stored" : "skipped (covered)"
$stderr.puts "[learn] #{ctx.robot.name}: #{status} — #{ctx.text.inspect}"
end
end
RobotLab.on(LearnAuditHook)
Long-Term Memory Promotion via on_learn¶
Persist new learnings to durable storage. on_learn fires after session storage so the session state is already updated when the extension runs:
class DurableLearnHook < RobotLab::Hook
self.namespace = :durable
def self.on_learn(ctx)
return unless ctx.stored
DurableStore.promote(
text: ctx.text,
robot: ctx.robot.name,
domain: ctx.local.domain,
timestamp: Time.now.utc
)
end
end
robot.on(DurableLearnHook, context: { domain: "finance" })
Blocking Unauthorised Learnings¶
Use around_learn to gate what a robot is allowed to learn — useful when the robot's system prompt comes from untrusted input:
class LearnGuardHook < RobotLab::Hook
self.namespace = :learn_guard
BLOCKED_PATTERN = /ignore previous instructions|forget everything/i
def self.around_learn(ctx, &block)
if BLOCKED_PATTERN.match?(ctx.text)
$stderr.puts "[learn_guard] Blocked: #{ctx.text.inspect}"
# Do not call block.call — the learning is silently dropped
else
block.call
end
end
end
RobotLab.on(LearnGuardHook)
Compaction Observability¶
Log when and why history compression fires, and how much it reduced the message count:
class CompactionLoggerHook < RobotLab::Hook
self.namespace = :compaction_logger
def self.before_compaction(ctx)
ctx.local.before_count = ctx.messages_before.size
ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def self.after_compaction(ctx)
after_count = ctx.compacted_messages&.size || ctx.local.before_count
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
dropped = ctx.local.before_count - after_count
$stderr.puts "[compaction] #{ctx.robot.name} #{ctx.strategy}: " \
"#{ctx.local.before_count} → #{after_count} messages " \
"(-#{dropped}) in #{elapsed_ms}ms"
end
end
RobotLab.on(CompactionLoggerHook)
Promote Session Learnings Before Compaction¶
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:
class LearningPromotionHook < RobotLab::Hook
self.namespace = :learning_promotion
def self.before_compaction(ctx)
ctx.local.message_ids_before = ctx.messages_before.map(&:object_id).to_set
end
def self.after_compaction(ctx)
return unless ctx.compacted_messages
surviving_ids = ctx.compacted_messages.map(&:object_id).to_set
dropped = ctx.messages_before.reject { |m| surviving_ids.include?(m.object_id) }
LongTermStore.promote(dropped, domain: ctx.robot.name) if dropped.any?
end
end
robot.on(LearningPromotionHook)
Custom Compaction Strategy¶
Replace the built-in TF-IDF compressor with a domain-specific algorithm using on_compaction:
class SummarizerCompactor < RobotLab::Hook
self.namespace = :summarizer_compactor
KEEP_RECENT = 4 # always keep the last N user+assistant pairs verbatim
def self.on_compaction(ctx)
messages = ctx.messages_before
pinned = messages.select { |m| %i[system tool tool_result].include?(m.role) }
scoreable = messages.reject { |m| %i[system tool tool_result].include?(m.role) }
recent = scoreable.last(KEEP_RECENT * 2)
older = scoreable.first([scoreable.size - KEEP_RECENT * 2, 0].max)
summary_text = ctx.robot.run("Summarize in two sentences: #{older.map(&:content).join(' ')}")
.reply
summary_msg = OpenStruct.new(role: :assistant, content: summary_text,
tool_calls: nil, stop_reason: :stop)
ctx.compacted_messages = pinned + [summary_msg] + recent
end
end
robot.on(SummarizerCompactor)
Run Counter Per Robot¶
class MetricsHook < RobotLab::Hook
self.namespace = :metrics
def self.before_run(ctx)
ctx.local.counts ||= {}
name = ctx.robot.name
ctx.local.counts[name] = (ctx.local.counts[name] || 0) + 1
end
end
RobotLab.on(MetricsHook, context: { counts: {} })
Application Use Cases¶
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.
Observability and Distributed Tracing¶
Instrument every LLM call with a trace ID and structured log entry for ingestion into OpenTelemetry, Datadog, or a custom logging pipeline:
class ObservabilityHook < RobotLab::Hook
self.namespace = :trace
def self.before_run(ctx)
ctx.local.trace_id = SecureRandom.hex(8)
ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def self.after_run(ctx)
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
Logger.info(
event: "robot.run.completed",
trace_id: ctx.local.trace_id,
robot: ctx.robot.name,
elapsed_ms: elapsed_ms,
reply: ctx.response&.reply.to_s[0, 120]
)
end
def self.on_error(ctx)
Logger.error(
event: "robot.run.failed",
trace_id: ctx.local.trace_id,
robot: ctx.robot.name,
error: ctx.error.class.to_s,
message: ctx.error.message
)
end
end
RobotLab.on(ObservabilityHook)
Cost Enforcement¶
Cap total spending across all robots in a session and raise before an expensive run would push you over budget:
class BudgetHook < RobotLab::Hook
self.namespace = :budget
COST_PER_INPUT_TOKEN = 0.80 / 1_000_000
COST_PER_OUTPUT_TOKEN = 4.00 / 1_000_000
SESSION_BUDGET_USD = 0.50
def self.before_run(ctx)
ctx.local.total_cost ||= 0.0
if ctx.local.total_cost >= SESSION_BUDGET_USD
raise RobotLab::Error, "Session budget of $#{SESSION_BUDGET_USD} exceeded"
end
end
def self.after_run(ctx)
r = ctx.response
ctx.local.total_cost +=
r.input_tokens * COST_PER_INPUT_TOKEN +
r.output_tokens * COST_PER_OUTPUT_TOKEN
end
end
RobotLab.on(BudgetHook, context: { total_cost: 0.0 })
Tool Access Control¶
Allow only approved tools to execute for a given network, without hard-coding restrictions in the tool itself:
class AccessControlHook < RobotLab::Hook
self.namespace = :access_control
ALLOWED_TOOLS = %w[web_search calculator].freeze
def self.around_tool_call(ctx, &block)
if ALLOWED_TOOLS.include?(ctx.tool_name)
block.call
else
ctx.tool_result = "[blocked] Tool '#{ctx.tool_name}' is not permitted in this network."
end
end
end
network.on(AccessControlHook)
Audit Logging for Compliance¶
Record every tool invocation with its arguments and result for security audits or regulatory compliance:
class ComplianceAuditHook < RobotLab::Hook
self.namespace = :audit
def self.after_tool_call(ctx)
AuditLog.append(
robot: ctx.robot&.name,
tool: ctx.tool_name,
args: ctx.tool_args,
result: ctx.tool_result.to_s[0, 500],
timestamp: Time.now.utc.iso8601
)
end
end
RobotLab.on(ComplianceAuditHook)
Retry with Exponential Backoff¶
Wrap around_run to retry on transient LLM failures without changing any robot code:
class RetryHook < RobotLab::Hook
self.namespace = :retry
MAX_RETRIES = 3
BACKOFF_BASE = 0.5
def self.around_run(ctx, &block)
attempts = 0
begin
attempts += 1
block.call
rescue RobotLab::InferenceError
raise if attempts >= MAX_RETRIES
sleep(BACKOFF_BASE * (2**(attempts - 1)))
retry
end
end
end
RobotLab.on(RetryHook)
Test Doubles Without Network Calls¶
Inject canned LLM responses in tests by short-circuiting around_llm_generation. No VCR cassettes, no HTTP mocks:
class TestDoubleHook < RobotLab::Hook
self.namespace = :test_double
CANNED_RESPONSES = {
"classify this" => FakeResponse.new(content: "positive"),
"summarize" => FakeResponse.new(content: "short summary")
}.freeze
def self.around_llm_generation(ctx, &block)
canned = CANNED_RESPONSES[ctx.request]
canned ? canned : block.call
end
end
# In test setup:
RobotLab.on(TestDoubleHook)
Per-Network Latency Metrics¶
Collect timing data scoped to a specific network without touching global hooks:
class NetworkMetricsHook < RobotLab::Hook
self.namespace = :metrics
def self.before_network_run(ctx)
ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def self.after_network_run(ctx)
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at) * 1000).round(1)
Metrics.histogram("network.run.duration_ms", elapsed_ms, tags: { network: ctx.network.name })
end
end
network.on(NetworkMetricsHook)
Sensitive Data Redaction¶
Scrub PII from requests before they are logged or sent to the LLM:
class RedactionHook < RobotLab::Hook
self.namespace = :redaction
PII_PATTERN = /\b[A-Z]{2}\d{6,9}\b/ # example: passport numbers
def self.before_run(ctx)
ctx.request = ctx.request&.gsub(PII_PATTERN, "[REDACTED]")
end
end
RobotLab.on(RedactionHook)
See Also¶
- examples/35_hooks.rb — full demo with xyzzy extension, perf timer, LLM response cache, and tracer hooks
- examples/xyzzy.rb — single-file reference extension (
RobotLab::Xyzzy < RobotLab::Hook) that registers for every hook family - Robot Execution
- Observability & Safety