robot_lab 0.2.6 → 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.
- checksums.yaml +4 -4
- data/README.md +124 -64
- data/docs/api/core/index.md +41 -15
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +255 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +551 -61
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +110 -17
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +80 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +79 -31
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +145 -38
- data/docs/architecture/robot-execution.md +172 -90
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +284 -97
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +258 -50
- data/docs/guides/creating-networks.md +214 -30
- data/docs/guides/hooks.md +141 -54
- data/docs/guides/knowledge.md +35 -4
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +95 -47
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +237 -17
- data/docs/index.md +36 -4
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +12 -7
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +18 -8
- data/examples/README.md +199 -45
- data/examples/common.rb +79 -11
- data/examples/xyzzy.rb +8 -1
- data/lib/robot_lab/config.rb +10 -5
- data/lib/robot_lab/names.rb +402 -0
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/bus_messaging.rb +16 -8
- data/lib/robot_lab/robot/template_rendering.rb +16 -3
- data/lib/robot_lab/robot.rb +23 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +21 -15
- data/mkdocs.yml +6 -1
- metadata +7 -2
data/docs/guides/hooks.md
CHANGED
|
@@ -11,7 +11,7 @@ There are seven hook families. Each family has `before_*`, `around_*`, and `afte
|
|
|
11
11
|
| Family | Hook names | Fires during |
|
|
12
12
|
|--------|-----------|-------------|
|
|
13
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` |
|
|
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
15
|
| `:tool_call` | `before_tool_call`, `around_tool_call`, `after_tool_call` | each tool invocation |
|
|
16
16
|
| `:network_run` | `before_network_run`, `around_network_run`, `after_network_run`, `on_error` | every `network.run(...)` call |
|
|
17
17
|
| `:task` | `before_task`, `around_task`, `after_task`, `on_error` | each robot task within a network run |
|
|
@@ -32,19 +32,29 @@ before_run
|
|
|
32
32
|
}
|
|
33
33
|
after_compaction
|
|
34
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]
|
|
35
43
|
}
|
|
36
44
|
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
45
|
}
|
|
44
46
|
after_run
|
|
45
47
|
```
|
|
46
48
|
|
|
47
|
-
|
|
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.
|
|
48
58
|
|
|
49
59
|
The `:learn` family fires synchronously inside `robot.learn(text)`, once per call. Hooks do not fire when `text` is blank.
|
|
50
60
|
|
|
@@ -133,6 +143,16 @@ Hooks are registered on three objects and can optionally be scoped to a single c
|
|
|
133
143
|
|
|
134
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.
|
|
135
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
|
+
|
|
136
156
|
---
|
|
137
157
|
|
|
138
158
|
## The `on` Method
|
|
@@ -156,7 +176,16 @@ robot.on(handler_class, context: nil)
|
|
|
156
176
|
|
|
157
177
|
## Namespaces and `ctx.local`
|
|
158
178
|
|
|
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.
|
|
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.
|
|
160
189
|
|
|
161
190
|
```ruby
|
|
162
191
|
class TimerHook < RobotLab::Hook
|
|
@@ -192,23 +221,42 @@ end
|
|
|
192
221
|
|
|
193
222
|
## The `context:` Parameter — Default State
|
|
194
223
|
|
|
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
|
|
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:
|
|
196
225
|
|
|
197
226
|
```ruby
|
|
198
|
-
class
|
|
199
|
-
self.namespace = :
|
|
227
|
+
class TagHook < RobotLab::Hook
|
|
228
|
+
self.namespace = :tagging
|
|
200
229
|
|
|
201
230
|
def self.before_run(ctx)
|
|
202
|
-
ctx.local.
|
|
203
|
-
puts "
|
|
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}"
|
|
204
233
|
end
|
|
205
234
|
end
|
|
206
235
|
|
|
207
|
-
RobotLab.on(
|
|
236
|
+
RobotLab.on(TagHook, context: { environment: "staging" })
|
|
208
237
|
```
|
|
209
238
|
|
|
210
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`.
|
|
211
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
|
+
|
|
212
260
|
---
|
|
213
261
|
|
|
214
262
|
## Around Hooks
|
|
@@ -229,7 +277,15 @@ class PerfHook < RobotLab::Hook
|
|
|
229
277
|
end
|
|
230
278
|
```
|
|
231
279
|
|
|
232
|
-
>
|
|
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.
|
|
233
289
|
|
|
234
290
|
Around hooks registered across different handler classes are chained: each wraps the next, with the actual operation at the innermost layer.
|
|
235
291
|
|
|
@@ -288,7 +344,16 @@ end
|
|
|
288
344
|
robot.on(SemanticCompressor)
|
|
289
345
|
```
|
|
290
346
|
|
|
291
|
-
If `on_compaction` does not assign `ctx.compacted_messages`, the core algorithm runs as normal.
|
|
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.
|
|
292
357
|
|
|
293
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.
|
|
294
359
|
|
|
@@ -348,7 +413,7 @@ Extends `RunHookContext` and is passed to `:llm_generation` hooks. All `RunHookC
|
|
|
348
413
|
| Attribute | Type | Notes |
|
|
349
414
|
|-----------|------|-------|
|
|
350
415
|
| `generation_response` | RubyLLM::Message\|nil | Set after the LLM responds; readable in `after_llm_generation` |
|
|
351
|
-
| `iteration` | Integer |
|
|
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. |
|
|
352
417
|
|
|
353
418
|
### ToolCallHookContext
|
|
354
419
|
|
|
@@ -409,7 +474,7 @@ Passed to `:compaction` hooks (`before_compaction`, `around_compaction`, `after_
|
|
|
409
474
|
|
|
410
475
|
#### `ctx.handled?`
|
|
411
476
|
|
|
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.
|
|
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.
|
|
413
478
|
|
|
414
479
|
### LearnHookContext
|
|
415
480
|
|
|
@@ -486,9 +551,12 @@ class MyExtension < RobotLab::Hook
|
|
|
486
551
|
@logger ||= Logger.new($stdout)
|
|
487
552
|
end
|
|
488
553
|
|
|
554
|
+
attr_accessor :call_count # class-level: survives across runs
|
|
555
|
+
|
|
489
556
|
def before_run(ctx)
|
|
490
|
-
|
|
491
|
-
|
|
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}")
|
|
492
560
|
end
|
|
493
561
|
|
|
494
562
|
def after_run(ctx)
|
|
@@ -520,17 +588,19 @@ To attach only to a specific robot:
|
|
|
520
588
|
robot.on(MyExtension)
|
|
521
589
|
```
|
|
522
590
|
|
|
523
|
-
With per-registration default state:
|
|
591
|
+
With per-registration default state — re-applied at the start of every run, so use it for constants and configuration, not for accumulators:
|
|
524
592
|
|
|
525
593
|
```ruby
|
|
526
|
-
RobotLab.on(MyExtension, context: {
|
|
594
|
+
RobotLab.on(MyExtension, context: { service: "checkout" })
|
|
527
595
|
```
|
|
528
596
|
|
|
529
597
|
### Extension Guidelines
|
|
530
598
|
|
|
531
599
|
- 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
|
|
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.
|
|
534
604
|
- Keep error hooks non-raising. Exceptions from hook callbacks propagate and can mask the original error.
|
|
535
605
|
- Test each callback method in isolation by constructing a context object directly and calling the class method.
|
|
536
606
|
|
|
@@ -582,23 +652,28 @@ Use `around_llm_generation` to skip the LLM call entirely when a cached response
|
|
|
582
652
|
class LlmCacheHook < RobotLab::Hook
|
|
583
653
|
self.namespace = :cache
|
|
584
654
|
|
|
585
|
-
|
|
586
|
-
ctx.local
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
ctx.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
595
668
|
end
|
|
596
669
|
end
|
|
597
670
|
end
|
|
598
671
|
|
|
599
|
-
robot.on(LlmCacheHook
|
|
672
|
+
robot.on(LlmCacheHook)
|
|
600
673
|
```
|
|
601
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
|
+
|
|
602
677
|
### Tool Call Audit Log
|
|
603
678
|
|
|
604
679
|
```ruby
|
|
@@ -732,7 +807,7 @@ robot.on(LearningPromotionHook)
|
|
|
732
807
|
|
|
733
808
|
### Custom Compaction Strategy
|
|
734
809
|
|
|
735
|
-
Replace the built-in
|
|
810
|
+
Replace the built-in term-frequency compressor with a domain-specific algorithm using `on_compaction`:
|
|
736
811
|
|
|
737
812
|
```ruby
|
|
738
813
|
class SummarizerCompactor < RobotLab::Hook
|
|
@@ -763,18 +838,25 @@ robot.on(SummarizerCompactor)
|
|
|
763
838
|
|
|
764
839
|
### Run Counter Per Robot
|
|
765
840
|
|
|
841
|
+
Cross-run tallies belong on the handler class — `ctx.local` is wiped between runs:
|
|
842
|
+
|
|
766
843
|
```ruby
|
|
767
844
|
class MetricsHook < RobotLab::Hook
|
|
768
845
|
self.namespace = :metrics
|
|
769
846
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
847
|
+
class << self
|
|
848
|
+
def counts = @counts ||= Hash.new(0)
|
|
849
|
+
|
|
850
|
+
def before_run(ctx)
|
|
851
|
+
counts[ctx.robot.name] += 1
|
|
852
|
+
end
|
|
774
853
|
end
|
|
775
854
|
end
|
|
776
855
|
|
|
777
|
-
RobotLab.on(MetricsHook
|
|
856
|
+
RobotLab.on(MetricsHook)
|
|
857
|
+
|
|
858
|
+
# later
|
|
859
|
+
MetricsHook.counts # => { "classifier" => 12, "responder" => 12 }
|
|
778
860
|
```
|
|
779
861
|
|
|
780
862
|
---
|
|
@@ -825,6 +907,8 @@ RobotLab.on(ObservabilityHook)
|
|
|
825
907
|
|
|
826
908
|
Cap total spending across all robots in a session and raise before an expensive run would push you over budget:
|
|
827
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
|
+
|
|
828
912
|
```ruby
|
|
829
913
|
class BudgetHook < RobotLab::Hook
|
|
830
914
|
self.namespace = :budget
|
|
@@ -833,25 +917,28 @@ class BudgetHook < RobotLab::Hook
|
|
|
833
917
|
COST_PER_OUTPUT_TOKEN = 4.00 / 1_000_000
|
|
834
918
|
SESSION_BUDGET_USD = 0.50
|
|
835
919
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
|
840
927
|
end
|
|
841
|
-
end
|
|
842
928
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
848
935
|
end
|
|
849
936
|
end
|
|
850
937
|
|
|
851
|
-
RobotLab.on(BudgetHook
|
|
938
|
+
RobotLab.on(BudgetHook)
|
|
852
939
|
```
|
|
853
940
|
|
|
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
|
|
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.
|
|
855
942
|
|
|
856
943
|
### Tool Access Control
|
|
857
944
|
|
data/docs/guides/knowledge.md
CHANGED
|
@@ -21,6 +21,18 @@ results = robot.search_history(query, limit: 5)
|
|
|
21
21
|
|
|
22
22
|
Scores every message in the robot's conversation history against `query` using stemmed term-frequency cosine similarity (via the `classifier` gem). Returns up to `limit` `HistoryResult` objects sorted by score descending.
|
|
23
23
|
|
|
24
|
+
> [!NOTE]
|
|
25
|
+
> **Every** message in `@chat.messages` is scored — there is no role filter.
|
|
26
|
+
> Results routinely include the `:system` prompt and raw `:tool` result payloads
|
|
27
|
+
> (often JSON) alongside `:user` and `:assistant` turns. Filter by role yourself
|
|
28
|
+
> if you only want the conversation:
|
|
29
|
+
>
|
|
30
|
+
> ```ruby
|
|
31
|
+
> hits = robot.search_history(query, limit: 20)
|
|
32
|
+
> .select { |r| %i[user assistant].include?(r.role) }
|
|
33
|
+
> .first(3)
|
|
34
|
+
> ```
|
|
35
|
+
|
|
24
36
|
```ruby
|
|
25
37
|
results = robot.search_history("quarterly revenue", limit: 3)
|
|
26
38
|
|
|
@@ -35,7 +47,7 @@ end
|
|
|
35
47
|
| Field | Type | Description |
|
|
36
48
|
|-------|------|-------------|
|
|
37
49
|
| `text` | String | The message text |
|
|
38
|
-
| `role` | Symbol | `:user`, `:assistant`, or `:
|
|
50
|
+
| `role` | Symbol | The message's role — `:user`, `:assistant`, `:system`, or `:tool` |
|
|
39
51
|
| `score` | Float (0.0–1.0) | Cosine similarity with the query |
|
|
40
52
|
| `index` | Integer | Position in `@chat.messages` |
|
|
41
53
|
|
|
@@ -89,7 +101,7 @@ Without it, calling `search_history` raises `RobotLab::DependencyError` with an
|
|
|
89
101
|
|
|
90
102
|
### The Problem
|
|
91
103
|
|
|
92
|
-
Sometimes the knowledge you need isn't in the conversation history — it's in a README, a product spec, a changelog. `store_document` / `search_documents` embed arbitrary text
|
|
104
|
+
Sometimes the knowledge you need isn't in the conversation history — it's in a README, a product spec, a changelog. `store_document` / `search_documents` embed arbitrary text and retrieve the most relevant chunk at query time. Embeddings come from `fastembed` when it is installed, and from a built-in TF-IDF fallback when it is not (see [Embedding Model](#embedding-model)).
|
|
93
105
|
|
|
94
106
|
### memory.store_document / memory.search_documents
|
|
95
107
|
|
|
@@ -136,7 +148,7 @@ store.clear
|
|
|
136
148
|
|
|
137
149
|
Default: `BAAI/bge-small-en-v1.5` (~23 MB, downloaded on first use, cached in `~/.cache/fastembed/`).
|
|
138
150
|
|
|
139
|
-
Documents are embedded
|
|
151
|
+
Documents and queries are embedded asymmetrically: stored text goes through `Fastembed::TextEmbedding#passage_embed`, query text through `#query_embed`. RobotLab does **not** prepend any literal `"passage: "` / `"query: "` string of its own — it hands the raw text to the two methods and lets fastembed handle whatever instruction prefixing the model expects.
|
|
140
152
|
|
|
141
153
|
Custom model:
|
|
142
154
|
|
|
@@ -144,6 +156,23 @@ Custom model:
|
|
|
144
156
|
store = RobotLab::DocumentStore.new(model_name: "BAAI/bge-base-en-v1.5")
|
|
145
157
|
```
|
|
146
158
|
|
|
159
|
+
### TF-IDF Fallback
|
|
160
|
+
|
|
161
|
+
`DocumentStore` decides once, at load time, whether `fastembed` is available
|
|
162
|
+
(`RobotLab::DocumentStore::FASTEMBED_AVAILABLE`). When it is **not**, the store
|
|
163
|
+
still works — it silently falls back to a stemmed, stop-word-filtered,
|
|
164
|
+
L2-normalised term-frequency embedder and compares those sparse vectors by
|
|
165
|
+
cosine similarity. Nothing is downloaded and everything runs offline, which
|
|
166
|
+
makes it convenient for development and CI.
|
|
167
|
+
|
|
168
|
+
> [!WARNING]
|
|
169
|
+
> The fallback is **lexical, not semantic**. It matches shared word stems only,
|
|
170
|
+
> so a query and a document that mean the same thing in different words score
|
|
171
|
+
> `0.0`. There is no error, no warning, and no change to the API — only
|
|
172
|
+
> noticeably worse results. Check `RobotLab::DocumentStore::FASTEMBED_AVAILABLE`
|
|
173
|
+
> if you need to know which path you are on, and note that `model_name:` is
|
|
174
|
+
> ignored entirely when it is `false`.
|
|
175
|
+
|
|
147
176
|
### RAG Pattern
|
|
148
177
|
|
|
149
178
|
```ruby
|
|
@@ -177,7 +206,9 @@ The embedding-based document store requires the [`robot_lab-document_store`](htt
|
|
|
177
206
|
gem "robot_lab-document_store"
|
|
178
207
|
```
|
|
179
208
|
|
|
180
|
-
|
|
209
|
+
The gem uses `fastembed` for ONNX-based embeddings when it is installed: the `BAAI/bge-small-en-v1.5` model (~23 MB) is downloaded on first use and cached in `~/.cache/fastembed/`. If `fastembed` cannot be loaded, `DocumentStore` still works via the lexical [TF-IDF fallback](#tf-idf-fallback).
|
|
210
|
+
|
|
211
|
+
Without `robot_lab-document_store` itself loaded, calling `memory.store_document`, `memory.search_documents`, `memory.document_keys`, or `memory.delete_document` raises `RobotLab::DependencyError`.
|
|
181
212
|
|
|
182
213
|
---
|
|
183
214
|
|