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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. 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` | each LLM API call within a run (may fire multiple times when tool calls loop) |
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
- 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.
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 at the same level can override:
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 CounterHook < RobotLab::Hook
199
- self.namespace = :counter
227
+ class TagHook < RobotLab::Hook
228
+ self.namespace = :tagging
200
229
 
201
230
  def self.before_run(ctx)
202
- ctx.local.count += 1
203
- puts "run ##{ctx.local.count}"
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(CounterHook, context: { count: 0 })
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
- > **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.
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. 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.
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 | Which LLM call within this run, 0-based |
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
- ctx.local.call_count = (ctx.local.call_count || 0) + 1
491
- logger.info("run ##{ctx.local.call_count} starting: #{ctx.request.inspect}")
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: { call_count: 0 })
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`, `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.
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
- 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
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, context: { hits: 0 })
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 TF-IDF compressor with a domain-specific algorithm using `on_compaction`:
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
- 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
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, context: { counts: {} })
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
- 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"
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
- 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
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, context: { total_cost: 0.0 })
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--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.
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
 
@@ -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 `:system` |
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 with `fastembed` and retrieve the most relevant chunk at query time.
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 with a `"passage: "` prefix and queries with `"query: "` prefix — the standard retrieval convention for BGE models.
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
- This gem bundles `fastembed` for ONNX-based embeddings. The `BAAI/bge-small-en-v1.5` model (~23 MB) is downloaded on first use and cached in `~/.cache/fastembed/`. Without `robot_lab-document_store` loaded, calling `memory.store_document` or `memory.search_documents` raises `RobotLab::DependencyError`.
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