robot_lab 0.2.1 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +185 -64
- data/Rakefile +28 -12
- data/docs/api/core/index.md +41 -14
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +285 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +636 -60
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +152 -14
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +83 -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 +118 -30
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +153 -38
- data/docs/architecture/robot-execution.md +173 -91
- 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 +299 -93
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +296 -50
- data/docs/guides/creating-networks.md +220 -31
- data/docs/guides/hooks.md +1084 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +37 -6
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +166 -51
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +300 -11
- data/docs/index.md +37 -5
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +23 -9
- 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 +266 -0
- data/examples/README.md +203 -42
- data/examples/common.rb +79 -7
- data/examples/xyzzy.rb +97 -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 +14 -9
- 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/names.rb +402 -0
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +90 -18
- 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 +18 -5
- data/lib/robot_lab/robot.rb +233 -89
- 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 +70 -15
- data/mkdocs.yml +7 -1
- metadata +41 -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
data/docs/guides/memory.md
CHANGED
|
@@ -86,7 +86,7 @@ Memory has reserved keys with special behavior:
|
|
|
86
86
|
| `:results` | Array | Accumulated robot results |
|
|
87
87
|
| `:messages` | Array | Conversation history |
|
|
88
88
|
| `:session_id` | String | Session identifier for history persistence |
|
|
89
|
-
| `:cache` | SemanticCache | Semantic cache (read-only after init) |
|
|
89
|
+
| `:cache` | `RubyLLM::SemanticCache` module, or `nil` | Semantic cache (read-only after init). Set at construction time; `nil` when built with `enable_cache: false` |
|
|
90
90
|
|
|
91
91
|
### The Data Hash
|
|
92
92
|
|
|
@@ -143,6 +143,16 @@ You can also clear just the custom keys without resetting reserved keys:
|
|
|
143
143
|
robot.memory.clear # Clears non-reserved keys only
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
> [!WARNING]
|
|
147
|
+
> `reset_memory` resets **only the key-value store**. It does not touch the
|
|
148
|
+
> robot's conversation history — the chat still holds every prior message, and
|
|
149
|
+
> the LLM will still see them. Clearing the transcript is a separate call:
|
|
150
|
+
>
|
|
151
|
+
> ```ruby
|
|
152
|
+
> robot.reset_memory # key-value store only
|
|
153
|
+
> robot.clear_messages(keep_system: true) # conversation history only
|
|
154
|
+
> ```
|
|
155
|
+
|
|
146
156
|
## Network Shared Memory
|
|
147
157
|
|
|
148
158
|
When robots run in a network, they share the network's memory instead of using their own inherent memory. This allows robots to communicate through shared state:
|
|
@@ -190,11 +200,40 @@ results = memory.get(:sentiment, :entities, :keywords, wait: 60)
|
|
|
190
200
|
# => { sentiment: {...}, entities: [...], keywords: [...] }
|
|
191
201
|
```
|
|
192
202
|
|
|
193
|
-
|
|
203
|
+
> [!WARNING]
|
|
204
|
+
> A blocking `get` that expires **raises `RobotLab::AwaitTimeout`** — it does
|
|
205
|
+
> not return `nil`. (A *non*-blocking `get`, the default, returns `nil` for a
|
|
206
|
+
> missing key.) Wrap it if a missing value is survivable:
|
|
207
|
+
>
|
|
208
|
+
> ```ruby
|
|
209
|
+
> begin
|
|
210
|
+
> memory.get(:sentiment, wait: 30)
|
|
211
|
+
> rescue RobotLab::AwaitTimeout => e
|
|
212
|
+
> # => "Timeout waiting for :sentiment after 30 seconds"
|
|
213
|
+
> nil
|
|
214
|
+
> end
|
|
215
|
+
> ```
|
|
216
|
+
|
|
217
|
+
> [!CAUTION]
|
|
218
|
+
> With multiple keys the timeout is applied **per missing key**, not to the call
|
|
219
|
+
> as a whole. `memory.get(:a, :b, :c, wait: 30)` awaits the missing keys
|
|
220
|
+
> sequentially, each with its own fresh 30-second budget.
|
|
221
|
+
>
|
|
222
|
+
> It does not, however, spend the whole 90 seconds before reporting: the first
|
|
223
|
+
> key whose wait expires **raises `AwaitTimeout` immediately**, aborting the call
|
|
224
|
+
> — so the keys that were already resolved are lost along with the ones not yet
|
|
225
|
+
> attempted. The 90 seconds is the worst case only for a call that *succeeds*
|
|
226
|
+
> (each key arriving just under its own deadline).
|
|
227
|
+
|
|
228
|
+
Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). The waiting
|
|
229
|
+
side calls `@read_io.wait_readable(timeout)`; `signal` writes one byte per
|
|
230
|
+
waiting caller so every blocked waiter wakes exactly once. `wait_readable`
|
|
231
|
+
yields to Ruby's Async fiber scheduler when one is installed — no mutex
|
|
232
|
+
contention or spurious wakeups.
|
|
194
233
|
|
|
195
234
|
### Subscriptions
|
|
196
235
|
|
|
197
|
-
Subscribe to key changes
|
|
236
|
+
Subscribe to key changes:
|
|
198
237
|
|
|
199
238
|
```ruby
|
|
200
239
|
# Subscribe to a single key
|
|
@@ -214,6 +253,24 @@ memory.subscribe_pattern("analysis:*") do |change|
|
|
|
214
253
|
end
|
|
215
254
|
```
|
|
216
255
|
|
|
256
|
+
> [!IMPORTANT]
|
|
257
|
+
> Subscription callbacks are dispatched through `Async { }`. Inside a running
|
|
258
|
+
> Async reactor that defers them; **outside one — which is the normal case for
|
|
259
|
+
> plain Ruby, Rails request threads, and tests — the block runs synchronously
|
|
260
|
+
> on the writer's thread**, before `memory[:key] = value` returns:
|
|
261
|
+
>
|
|
262
|
+
> ```ruby
|
|
263
|
+
> order = []
|
|
264
|
+
> memory.subscribe(:k) { |c| order << "callback" }
|
|
265
|
+
> order << "before-set"
|
|
266
|
+
> memory[:k] = 1
|
|
267
|
+
> order << "after-set"
|
|
268
|
+
> order # => ["before-set", "callback", "after-set"]
|
|
269
|
+
> ```
|
|
270
|
+
>
|
|
271
|
+
> Keep subscription callbacks fast, and never assume the writer has moved on by
|
|
272
|
+
> the time your callback runs.
|
|
273
|
+
|
|
217
274
|
### Unsubscribe
|
|
218
275
|
|
|
219
276
|
```ruby
|
|
@@ -243,6 +300,8 @@ Memory can be exported and reconstructed:
|
|
|
243
300
|
# Export to hash
|
|
244
301
|
hash = robot.memory.to_h
|
|
245
302
|
# => { data: {...}, results: [...], messages: [...], session_id: "...", custom: {...} }
|
|
303
|
+
# to_h is compacted: nil entries are dropped, so an unset :session_id (and the
|
|
304
|
+
# :cache key, which is never exported) simply will not appear.
|
|
246
305
|
|
|
247
306
|
# Export to JSON
|
|
248
307
|
json = robot.memory.to_json
|
|
@@ -279,34 +338,60 @@ memory[:stage] = "response"
|
|
|
279
338
|
|
|
280
339
|
### Caching Expensive Operations
|
|
281
340
|
|
|
341
|
+
A tool reaches memory through its owning robot. Subclass `RobotLab::Tool` (which
|
|
342
|
+
has a `robot` accessor) and attach an **instance constructed with `robot:`** —
|
|
343
|
+
that is the only supported way for tool code to read and write robot memory:
|
|
344
|
+
|
|
282
345
|
```ruby
|
|
283
|
-
class FetchUser <
|
|
346
|
+
class FetchUser < RobotLab::Tool
|
|
284
347
|
description "Fetch user details by ID"
|
|
285
348
|
param :user_id, type: :string, desc: "User ID"
|
|
286
349
|
|
|
287
350
|
def execute(user_id:)
|
|
288
|
-
cache_key = "cache:user:#{user_id}"
|
|
351
|
+
cache_key = :"cache:user:#{user_id}"
|
|
289
352
|
|
|
290
|
-
|
|
291
|
-
# (In practice, you'd access memory through the robot's context)
|
|
292
|
-
cached = Thread.current[:robot_memory]&.[](cache_key.to_sym)
|
|
353
|
+
cached = robot&.memory&.[](cache_key)
|
|
293
354
|
return cached if cached
|
|
294
355
|
|
|
295
|
-
# Fetch and cache
|
|
296
356
|
user = User.find(user_id).to_h
|
|
297
|
-
|
|
357
|
+
robot&.memory&.[]=(cache_key, user)
|
|
298
358
|
user
|
|
299
359
|
end
|
|
300
360
|
end
|
|
361
|
+
|
|
362
|
+
robot = RobotLab.build(name: "support", system_prompt: "...")
|
|
363
|
+
robot.local_tools << FetchUser.new(robot: robot)
|
|
364
|
+
|
|
365
|
+
robot.run("Look up user 42", tools: :inherit)
|
|
301
366
|
```
|
|
302
367
|
|
|
368
|
+
> [!WARNING]
|
|
369
|
+
> There is **no thread-local memory handle** in RobotLab — nothing anywhere in
|
|
370
|
+
> the codebase ever assigns `Thread.current[:robot_memory]`. A tool that reads
|
|
371
|
+
> it will always see `nil` and silently cache nothing. Go through `robot.memory`
|
|
372
|
+
> as above, and remember that `FetchUser.new` without `robot:` leaves `robot`
|
|
373
|
+
> `nil`.
|
|
374
|
+
|
|
303
375
|
### Semantic Caching
|
|
304
376
|
|
|
305
|
-
Memory
|
|
377
|
+
Memory exposes a semantic cache for LLM response caching. It is on by default
|
|
378
|
+
and becomes `nil` when you opt out with `enable_cache: false`:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
RobotLab.create_memory.cache # => RubyLLM::SemanticCache
|
|
382
|
+
RobotLab.create_memory(enable_cache: false).cache # => nil
|
|
383
|
+
|
|
384
|
+
RobotLab.build(name: "x", system_prompt: "…").memory.cache
|
|
385
|
+
# => RubyLLM::SemanticCache
|
|
386
|
+
RobotLab.build(name: "x", system_prompt: "…", enable_cache: false).memory.cache
|
|
387
|
+
# => nil
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Guard for `nil` in any code that might run against a cache-disabled memory.
|
|
306
391
|
|
|
307
392
|
```ruby
|
|
308
393
|
# Access the semantic cache
|
|
309
|
-
cache = robot.memory.cache
|
|
394
|
+
cache = robot.memory.cache
|
|
310
395
|
|
|
311
396
|
# Use it to cache semantically similar queries
|
|
312
397
|
response = cache.fetch("What is Ruby?") do
|
|
@@ -314,6 +399,12 @@ response = cache.fetch("What is Ruby?") do
|
|
|
314
399
|
end
|
|
315
400
|
```
|
|
316
401
|
|
|
402
|
+
> [!NOTE]
|
|
403
|
+
> `memory.cache` is the `RubyLLM::SemanticCache` **module itself**, not a
|
|
404
|
+
> per-memory instance. Its cache store, vector store, and configuration are
|
|
405
|
+
> process-global — two memories with `enable_cache: true` share one cache. It
|
|
406
|
+
> also embeds every query, so `fetch` costs an embedding call.
|
|
407
|
+
|
|
317
408
|
## Best Practices
|
|
318
409
|
|
|
319
410
|
### 1. Use Descriptive Keys
|
|
@@ -10,6 +10,7 @@ Facilities that help you monitor, control, improve, and scale robot behaviour:
|
|
|
10
10
|
- **Context Window Compression** — prune irrelevant history to stay within token budgets
|
|
11
11
|
- **Convergence Detection** — detect when independent agents reach the same conclusion
|
|
12
12
|
- **Structured Delegation** — synchronous inter-robot calls with duration and token metadata
|
|
13
|
+
- **Live Narration** — an opt-in console feed of what a robot is doing as it happens
|
|
13
14
|
|
|
14
15
|
---
|
|
15
16
|
|
|
@@ -91,6 +92,41 @@ result = fresh.run("Explain memoization.")
|
|
|
91
92
|
puts result.input_tokens # smallest possible — no prior history
|
|
92
93
|
```
|
|
93
94
|
|
|
95
|
+
### Budgets (Token & Cost)
|
|
96
|
+
|
|
97
|
+
Where token/cost tracking above is purely observational, `token_budget:` and `cost_budget:` make it enforceable — a `Robot` refuses to keep spending once a configured limit is reached:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
robot = RobotLab.build(
|
|
101
|
+
name: "capped",
|
|
102
|
+
system_prompt: "You are a concise assistant.",
|
|
103
|
+
token_budget: 10_000, # cumulative input + output tokens
|
|
104
|
+
cost_budget: 0.50 # cumulative $ across all runs
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Enforcement happens in two layers, backed by a thread-safe `RobotLab::Budget::Ledger`:
|
|
109
|
+
|
|
110
|
+
- **Before the call** — `run()` reserves whatever remains of each configured dimension. If a *prior* call already exhausted the budget, the reservation raises `RobotLab::BudgetExceeded` immediately, refusing the call outright before spending anything on it.
|
|
111
|
+
- **After the call** — actual usage (tokens from the result, cost from the response, when the provider reports pricing) replaces the reservation. If *this* call's actual usage pushes cumulative usage over budget, `RobotLab::InferenceError` is raised (unavoidable for the call that causes the overage, since totals aren't known until the response comes back) — the same error `token_budget` alone has always raised.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
begin
|
|
115
|
+
robot.run("Another expensive task")
|
|
116
|
+
rescue RobotLab::BudgetExceeded => e
|
|
117
|
+
puts e.message # "budget exceeded for cost: 0.51 > 0.5" — refused before spending
|
|
118
|
+
rescue RobotLab::InferenceError => e
|
|
119
|
+
puts e.message
|
|
120
|
+
# "Cost budget exceeded: $0.523100 used, budget is $0.500000" — this call pushed it over
|
|
121
|
+
# (the cost message is formatted with %.6f; the token message is not formatted:
|
|
122
|
+
# "Token budget exceeded: 10412 tokens used, budget is 10000")
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A dimension with no configured limit (e.g. `cost_budget` when only `token_budget` is set) is treated as unlimited and never raises. Both fields are also available on `RunConfig` and cascade through the same global → network → robot hierarchy as other infrastructure fields (see [RunConfig](../getting-started/configuration.md#runconfig-shared-operational-defaults)).
|
|
127
|
+
|
|
128
|
+
This is a native alternative to the hand-rolled `BudgetHook` pattern in the [Hooks guide](hooks.md#cost-enforcement) for the common case of a per-robot token or dollar ceiling; reach for a hook instead when you need cross-robot session totals or custom accounting.
|
|
129
|
+
|
|
94
130
|
---
|
|
95
131
|
|
|
96
132
|
## Tool Loop Circuit Breaker
|
|
@@ -110,12 +146,20 @@ robot = RobotLab.build(
|
|
|
110
146
|
local_tools: [StepTool],
|
|
111
147
|
max_tool_rounds: 10
|
|
112
148
|
)
|
|
149
|
+
```
|
|
113
150
|
|
|
151
|
+
> [!WARNING]
|
|
152
|
+
> `run` defaults to `tools: :none`, so a plain `robot.run("...")` sends the LLM
|
|
153
|
+
> **no** tools — even when `local_tools:` were attached at build time — and the
|
|
154
|
+
> breaker can never fire. Pass `tools: :inherit` on the call to actually send the
|
|
155
|
+
> attached tools.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
114
158
|
begin
|
|
115
|
-
robot.run("Run all steps.")
|
|
159
|
+
robot.run("Run all steps.", tools: :inherit)
|
|
116
160
|
rescue RobotLab::ToolLoopError => e
|
|
117
|
-
puts
|
|
118
|
-
# => "Circuit breaker
|
|
161
|
+
puts e.message
|
|
162
|
+
# => "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
|
|
119
163
|
end
|
|
120
164
|
```
|
|
121
165
|
|
|
@@ -137,6 +181,8 @@ Error: tool_use ids were found without tool_result blocks immediately after
|
|
|
137
181
|
Call `clear_messages` to flush the corrupted history before reusing the robot. The system prompt and all configuration (tools, `max_tool_rounds`, etc.) are preserved:
|
|
138
182
|
|
|
139
183
|
```ruby
|
|
184
|
+
begin
|
|
185
|
+
robot.run("Keep calling the tool.", tools: :inherit)
|
|
140
186
|
rescue RobotLab::ToolLoopError => e
|
|
141
187
|
puts "Breaker fired: #{e.message}"
|
|
142
188
|
end
|
|
@@ -158,7 +204,7 @@ unguarded = RobotLab.build(
|
|
|
158
204
|
system_prompt: "Use the provided tool to answer questions.",
|
|
159
205
|
local_tools: [DoubleTool]
|
|
160
206
|
)
|
|
161
|
-
result = unguarded.run("Double the number 21 using the tool.")
|
|
207
|
+
result = unguarded.run("Double the number 21 using the tool.", tools: :inherit)
|
|
162
208
|
puts result.reply # "The result is 42."
|
|
163
209
|
```
|
|
164
210
|
|
|
@@ -172,16 +218,18 @@ puts result.reply # "The result is 42."
|
|
|
172
218
|
|
|
173
219
|
### doom_loop_threshold
|
|
174
220
|
|
|
221
|
+
Doom loop detection is **always on** — the detector is installed unconditionally on every `run()`. `doom_loop_threshold:` does not enable it; it only tunes the number of repetitions after which it fires. The default is `3`.
|
|
222
|
+
|
|
175
223
|
```ruby
|
|
176
224
|
robot = RobotLab.build(
|
|
177
225
|
name: "runner",
|
|
178
226
|
system_prompt: "Execute all steps.",
|
|
179
227
|
local_tools: [StepTool],
|
|
180
|
-
doom_loop_threshold: 3
|
|
228
|
+
doom_loop_threshold: 5 # tune the always-on detector; default is 3
|
|
181
229
|
)
|
|
182
230
|
```
|
|
183
231
|
|
|
184
|
-
|
|
232
|
+
The detector catches two patterns:
|
|
185
233
|
|
|
186
234
|
- **Consecutive repetition** — `[A, A, A]` (same tool called N times in a row)
|
|
187
235
|
- **Cyclic repetition** — `[A, B, C, A, B, C, A, B, C]` (same sequence repeated N times)
|
|
@@ -219,27 +267,38 @@ Long-running robots accumulate conversation history. Eventually, the cumulative
|
|
|
219
267
|
|
|
220
268
|
### auto_compact
|
|
221
269
|
|
|
222
|
-
|
|
270
|
+
`auto_compact` and `compact_threshold` are **`RunConfig` fields only** — they are not constructor keyword arguments. Build a `RunConfig` and pass it as `config:`:
|
|
223
271
|
|
|
224
272
|
```ruby
|
|
225
273
|
# Compact when estimated token usage exceeds 80% of the model's context window
|
|
274
|
+
config = RobotLab::RunConfig.new(auto_compact: :context_window)
|
|
275
|
+
|
|
226
276
|
robot = RobotLab.build(
|
|
227
277
|
name: "analyst",
|
|
228
278
|
system_prompt: "You are a research analyst.",
|
|
229
|
-
|
|
279
|
+
config: config
|
|
230
280
|
)
|
|
231
281
|
```
|
|
232
282
|
|
|
283
|
+
> [!WARNING]
|
|
284
|
+
> `Robot#initialize` has a closed keyword list — it takes no `**rest`. Passing
|
|
285
|
+
> `auto_compact:` or `compact_threshold:` directly to `RobotLab.build` raises
|
|
286
|
+
> `ArgumentError: unknown keyword`.
|
|
287
|
+
|
|
233
288
|
### Tuning the Threshold
|
|
234
289
|
|
|
235
290
|
`compact_threshold:` sets the fraction of the model's context window that triggers compaction. Defaults to `0.80` (80%):
|
|
236
291
|
|
|
237
292
|
```ruby
|
|
293
|
+
config = RobotLab::RunConfig.new(
|
|
294
|
+
auto_compact: :context_window,
|
|
295
|
+
compact_threshold: 0.70 # compact earlier, at 70%
|
|
296
|
+
)
|
|
297
|
+
|
|
238
298
|
robot = RobotLab.build(
|
|
239
299
|
name: "analyst",
|
|
240
300
|
system_prompt: "You are a research analyst.",
|
|
241
|
-
|
|
242
|
-
compact_threshold: 0.70 # compact earlier, at 70%
|
|
301
|
+
config: config
|
|
243
302
|
)
|
|
244
303
|
```
|
|
245
304
|
|
|
@@ -248,16 +307,20 @@ robot = RobotLab.build(
|
|
|
248
307
|
Pass a `Proc` to take full control — the proc decides both when and how to compact:
|
|
249
308
|
|
|
250
309
|
```ruby
|
|
251
|
-
|
|
252
|
-
name: "analyst",
|
|
253
|
-
system_prompt: "You are a research analyst.",
|
|
310
|
+
config = RobotLab::RunConfig.new(
|
|
254
311
|
auto_compact: ->(r) {
|
|
255
312
|
r.compress_history(recent_turns: 5) if r.chat.messages.size > 40
|
|
256
313
|
}
|
|
257
314
|
)
|
|
315
|
+
|
|
316
|
+
robot = RobotLab.build(
|
|
317
|
+
name: "analyst",
|
|
318
|
+
system_prompt: "You are a research analyst.",
|
|
319
|
+
config: config
|
|
320
|
+
)
|
|
258
321
|
```
|
|
259
322
|
|
|
260
|
-
The proc receives the robot instance and is called
|
|
323
|
+
The proc receives the robot instance and is called once per `run()` when messages are non-empty.
|
|
261
324
|
|
|
262
325
|
### Options
|
|
263
326
|
|
|
@@ -267,13 +330,6 @@ The proc receives the robot instance and is called before every `run()` when mes
|
|
|
267
330
|
| `:context_window` | Compact when estimated token usage exceeds `compact_threshold` fraction of model's context window |
|
|
268
331
|
| `Proc` | Called with the robot; application decides when and how to compact |
|
|
269
332
|
|
|
270
|
-
Via `RunConfig`:
|
|
271
|
-
|
|
272
|
-
```ruby
|
|
273
|
-
config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.75)
|
|
274
|
-
robot = RobotLab.build(name: "analyst", system_prompt: "...", config: config)
|
|
275
|
-
```
|
|
276
|
-
|
|
277
333
|
Requires the `classifier` gem (`~> 2.3`) when using `:context_window`. Without it, a `RobotLab::DependencyError` is caught and logged rather than raised, so the robot continues running uncompressed.
|
|
278
334
|
|
|
279
335
|
---
|
|
@@ -354,20 +410,33 @@ end
|
|
|
354
410
|
|
|
355
411
|
After all three runs, `reviewer.learnings` contains up to three insights (fewer if any are subsets of others).
|
|
356
412
|
|
|
357
|
-
### Durable Learning (learn
|
|
413
|
+
### Durable Learning (the `:learn` hook family)
|
|
414
|
+
|
|
415
|
+
Core RobotLab keeps learnings for the life of the process only. Cross-session persistence is supplied by the [`robot_lab-durable`](https://github.com/MadBomber/robot_lab-durable) gem, which registers a `RobotLab::Hook` on the `:learn` family — there is no `learn:` constructor shorthand.
|
|
358
416
|
|
|
359
|
-
|
|
417
|
+
> [!WARNING]
|
|
418
|
+
> `learn:` and `learn_domain:` are **not** constructor keyword arguments and do
|
|
419
|
+
> not exist anywhere in the codebase. `RobotLab.build(learn: true)` raises
|
|
420
|
+
> `ArgumentError: unknown keyword: :learn`.
|
|
421
|
+
|
|
422
|
+
The wiring is the ordinary hook registration described in the [Hooks guide](hooks.md) — an `on_learn` handler receives each learning after session-level deduplication and decides whether to persist it:
|
|
360
423
|
|
|
361
424
|
```ruby
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
425
|
+
class DurableLearnHook < RobotLab::Hook
|
|
426
|
+
self.namespace = :durable
|
|
427
|
+
|
|
428
|
+
def self.on_learn(ctx)
|
|
429
|
+
return unless ctx.stored
|
|
430
|
+
|
|
431
|
+
DurableStore.promote(text: ctx.text, robot: ctx.robot.name, domain: ctx.local.domain)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
reviewer = RobotLab.build(name: "reviewer", system_prompt: "You are a Ruby code reviewer.")
|
|
436
|
+
reviewer.on(DurableLearnHook, context: { domain: "ruby_review" })
|
|
368
437
|
```
|
|
369
438
|
|
|
370
|
-
|
|
439
|
+
The extension promotes durable insights to a YAML-backed store that persists across process restarts; see the gem's own README for its registration entry point.
|
|
371
440
|
|
|
372
441
|
### Memory Persistence
|
|
373
442
|
|
|
@@ -489,29 +558,41 @@ Texts shorter than 30 characters always return `0.0`.
|
|
|
489
558
|
| Same topic, different emphasis | 0.45 – 0.60 |
|
|
490
559
|
| Unrelated | < 0.15 |
|
|
491
560
|
|
|
492
|
-
###
|
|
561
|
+
### Reconciler Fast-Path Pattern
|
|
493
562
|
|
|
494
|
-
Skip the reconciler when verifiers agree:
|
|
563
|
+
Skip the reconciler when verifiers agree. RobotLab has no router object — routing is done by declaring the optional branch as a task with `depends_on: :optional` and having a preceding robot call `result.activate(:task_name)` on it. Subclass `RobotLab::Robot` and override `#call` to make the decision:
|
|
495
564
|
|
|
496
565
|
```ruby
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
566
|
+
class ConvergenceGate < RobotLab::Robot
|
|
567
|
+
def call(result)
|
|
568
|
+
# result.context is keyed by ROBOT name, not task name
|
|
569
|
+
a = result.context[:verifier_a]&.reply.to_s
|
|
570
|
+
b = result.context[:verifier_b]&.reply.to_s
|
|
500
571
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
["reconciler"] # diverged — send to reconciler
|
|
572
|
+
return result if RobotLab::Convergence.detected?(a, b) # agree — reconciler stays dormant
|
|
573
|
+
|
|
574
|
+
result.activate(:reconciler) # diverged — activate the branch
|
|
505
575
|
end
|
|
506
576
|
end
|
|
507
577
|
|
|
508
|
-
network = RobotLab.create_network(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
578
|
+
network = RobotLab.create_network(name: "fact_check") do
|
|
579
|
+
task :verifier_a, verifier_a, depends_on: :none
|
|
580
|
+
task :verifier_b, verifier_b, depends_on: :none
|
|
581
|
+
task :gate, ConvergenceGate.new(name: "gate"), depends_on: %i[verifier_a verifier_b]
|
|
582
|
+
task :reconciler, reconciler, depends_on: :optional
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
result = network.run(message: "Is the deployment healthy?")
|
|
586
|
+
result.activated_steps # => [] when they agreed, [:reconciler] when they diverged
|
|
513
587
|
```
|
|
514
588
|
|
|
589
|
+
The gate robot never calls the LLM — overriding `#call` replaces the default "run and continue" behaviour entirely, so the decision costs nothing.
|
|
590
|
+
|
|
591
|
+
> [!NOTE]
|
|
592
|
+
> `result.context` is keyed by the **robot's** name (`@name`), not the task name.
|
|
593
|
+
> The lookups above work because each verifier's `name:` matches its task label.
|
|
594
|
+
> If they differ, index by the robot name.
|
|
595
|
+
|
|
515
596
|
Tune `threshold:` to control how strictly "agreement" is defined. A lower threshold (e.g., `0.6`) accepts more variation between verifiers; a higher threshold (e.g., `0.9`) only fast-paths near-identical responses.
|
|
516
597
|
|
|
517
598
|
### Optional Dependency
|
|
@@ -606,13 +687,47 @@ puts "#{analysis.robot_name} (#{analysis.duration.round(2)}s): #{analysis.reply}
|
|
|
606
687
|
|
|
607
688
|
---
|
|
608
689
|
|
|
690
|
+
## Live Narration (`RobotLab::Narrator`)
|
|
691
|
+
|
|
692
|
+
`RobotLab::Narrator` is an opt-in [Hook](hooks.md) that narrates what a robot is doing as it happens, to `$stderr` (or any `IO`), so a run is never silent between events. It complements `RobotLab::Audit` (the `robot_lab-audit` gem, which records a persistent history for post-mortem analysis) with a live, human-facing console feed — the two are independent hooks and can both be registered at once.
|
|
693
|
+
|
|
694
|
+
Enable it globally (applies to every robot run, including networks):
|
|
695
|
+
|
|
696
|
+
```ruby
|
|
697
|
+
RobotLab::Narrator.enable! # narrate to $stderr
|
|
698
|
+
RobotLab::Narrator.enable!(output: $stdout) # or any IO
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
Or register it like any hook for a narrower scope:
|
|
702
|
+
|
|
703
|
+
```ruby
|
|
704
|
+
robot.on(RobotLab::Narrator)
|
|
705
|
+
network.on(RobotLab::Narrator)
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Once registered, a run prints a line per event:
|
|
709
|
+
|
|
710
|
+
```
|
|
711
|
+
· math_bot: thinking…
|
|
712
|
+
· → calculate operation="add"
|
|
713
|
+
· → calculate operation="multiply"
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
- Once per `run`: `"<robot name>: thinking…"` — Narrator hooks `before_llm_generation`, which fires exactly once per `robot.run`; the provider's tool loop happens *inside* that hook, so the line is not repeated per LLM API call
|
|
717
|
+
- Before each tool call: `"→ <tool name> <first arg>=<value>"` — only the *first* argument is shown, truncated to 80 characters
|
|
718
|
+
- After a tool call: `" ✗ <error message>"` — printed only when the tool raised; silent on success
|
|
719
|
+
|
|
720
|
+
Narrator uses `IO#puts` rather than `Kernel#warn`, since `warn` is silenced whenever Ruby warnings are disabled (`$VERBOSE` is `nil`, the common case under `bundle exec`). All three hooks rescue internally, so a narration failure never breaks the underlying run.
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
609
724
|
## See Also
|
|
610
725
|
|
|
611
|
-
- [Robot API](../api/core/robot.md#token
|
|
612
|
-
- [
|
|
613
|
-
- [
|
|
614
|
-
- [
|
|
615
|
-
- [
|
|
616
|
-
- [
|
|
617
|
-
- [
|
|
726
|
+
- [Robot API](../api/core/robot.md#token-cost-tracking)
|
|
727
|
+
- [examples/19_token_tracking.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/19_token_tracking.rb) — Token & Cost Tracking
|
|
728
|
+
- [examples/20_circuit_breaker.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/20_circuit_breaker.rb) — Tool Loop Circuit Breaker
|
|
729
|
+
- [examples/21_learning_loop.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/21_learning_loop.rb) — Learning Accumulation Loop
|
|
730
|
+
- [examples/22_context_compression.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/22_context_compression.rb) — Context Window Compression
|
|
731
|
+
- [examples/23_convergence.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/23_convergence.rb) — Convergence Detection
|
|
732
|
+
- [examples/24_structured_delegation.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/24_structured_delegation.rb) — Structured Delegation
|
|
618
733
|
- [RunConfig reference](../getting-started/configuration.md#runconfig-shared-operational-defaults)
|