robot_lab 0.3.0 → 0.3.1

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 (256) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +1 -1
  4. data/lib/robot_lab/mcp/transports/streamable_http.rb +1 -1
  5. data/lib/robot_lab/version.rb +1 -1
  6. metadata +3 -253
  7. data/.architecture/AGENTS.md +0 -32
  8. data/.architecture/config.yml +0 -8
  9. data/.architecture/members.yml +0 -60
  10. data/.architecture/reviews/feature-free-will.md +0 -490
  11. data/.architecture/reviews/overall-codebase.md +0 -427
  12. data/.claude/settings.local.json +0 -57
  13. data/.codex/config.toml +0 -2
  14. data/.envrc +0 -6
  15. data/.github/workflows/deploy-github-pages.yml +0 -52
  16. data/.irbrc +0 -6
  17. data/.loki +0 -65
  18. data/.quality/reek_baseline.txt +0 -43
  19. data/.rubocop.yml +0 -10
  20. data/Archspec.rb +0 -44
  21. data/CLAUDE.md +0 -141
  22. data/COMMITS.md +0 -196
  23. data/Rakefile +0 -193
  24. data/_typos.toml +0 -21
  25. data/agent2agent_review.md +0 -192
  26. data/agentf_improvements.md +0 -253
  27. data/agents.md +0 -14
  28. data/docs/api/core/index.md +0 -147
  29. data/docs/api/core/memory.md +0 -578
  30. data/docs/api/core/network.md +0 -553
  31. data/docs/api/core/result.md +0 -211
  32. data/docs/api/core/robot.md +0 -1667
  33. data/docs/api/core/state.md +0 -145
  34. data/docs/api/core/tool.md +0 -520
  35. data/docs/api/errors.md +0 -323
  36. data/docs/api/hooks.md +0 -469
  37. data/docs/api/index.md +0 -137
  38. data/docs/api/mcp/client.md +0 -387
  39. data/docs/api/mcp/index.md +0 -267
  40. data/docs/api/mcp/server.md +0 -252
  41. data/docs/api/mcp/transports.md +0 -339
  42. data/docs/api/messages/index.md +0 -126
  43. data/docs/api/messages/text-message.md +0 -170
  44. data/docs/api/messages/tool-call-message.md +0 -206
  45. data/docs/api/messages/tool-result-message.md +0 -242
  46. data/docs/api/messages/user-message.md +0 -232
  47. data/docs/api/skills.md +0 -423
  48. data/docs/api/streaming/context.md +0 -280
  49. data/docs/api/streaming/events.md +0 -185
  50. data/docs/api/streaming/index.md +0 -156
  51. data/docs/api/support.md +0 -846
  52. data/docs/architecture/core-concepts.md +0 -575
  53. data/docs/architecture/index.md +0 -252
  54. data/docs/architecture/message-flow.md +0 -366
  55. data/docs/architecture/network-orchestration.md +0 -478
  56. data/docs/architecture/robot-execution.md +0 -396
  57. data/docs/architecture/state-management.md +0 -410
  58. data/docs/assets/css/custom.css +0 -56
  59. data/docs/assets/images/robot_lab.jpg +0 -0
  60. data/docs/concepts.md +0 -641
  61. data/docs/examples/basic-chat.md +0 -258
  62. data/docs/examples/index.md +0 -425
  63. data/docs/examples/mcp-server.md +0 -403
  64. data/docs/examples/multi-robot-network.md +0 -476
  65. data/docs/examples/tool-usage.md +0 -468
  66. data/docs/getting-started/configuration.md +0 -645
  67. data/docs/getting-started/index.md +0 -56
  68. data/docs/getting-started/installation.md +0 -197
  69. data/docs/getting-started/quick-start.md +0 -293
  70. data/docs/guides/building-robots.md +0 -1187
  71. data/docs/guides/creating-networks.md +0 -736
  72. data/docs/guides/hooks.md +0 -1206
  73. data/docs/guides/index.md +0 -78
  74. data/docs/guides/knowledge.md +0 -219
  75. data/docs/guides/mcp-integration.md +0 -740
  76. data/docs/guides/memory.md +0 -458
  77. data/docs/guides/observability.md +0 -733
  78. data/docs/guides/streaming.md +0 -323
  79. data/docs/guides/using-tools.md +0 -780
  80. data/docs/index.md +0 -280
  81. data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
  82. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
  83. data/examples/.envrc +0 -3
  84. data/examples/01_simple_robot.rb +0 -31
  85. data/examples/02_tools.rb +0 -109
  86. data/examples/03_network.rb +0 -118
  87. data/examples/04_mcp.rb +0 -217
  88. data/examples/05_streaming.rb +0 -137
  89. data/examples/06_prompt_templates.rb +0 -325
  90. data/examples/07_network_memory.rb +0 -313
  91. data/examples/08_llm_config.rb +0 -162
  92. data/examples/09_chaining.rb +0 -251
  93. data/examples/10_memory.rb +0 -315
  94. data/examples/11_network_introspection.rb +0 -239
  95. data/examples/12_message_bus.rb +0 -67
  96. data/examples/13_spawn.rb +0 -85
  97. data/examples/14_rusty_circuit/.envrc +0 -1
  98. data/examples/14_rusty_circuit/.gitignore +0 -1
  99. data/examples/14_rusty_circuit/README.md +0 -1
  100. data/examples/14_rusty_circuit/comic.rb +0 -149
  101. data/examples/14_rusty_circuit/display.rb +0 -217
  102. data/examples/14_rusty_circuit/heckler.rb +0 -65
  103. data/examples/14_rusty_circuit/open_mic.rb +0 -132
  104. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
  105. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
  106. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
  107. data/examples/14_rusty_circuit/scout.rb +0 -165
  108. data/examples/14_rusty_circuit/show.log +0 -234
  109. data/examples/15_memory_network_and_bus/.envrc +0 -1
  110. data/examples/15_memory_network_and_bus/README.md +0 -66
  111. data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
  112. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
  113. data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
  114. data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
  115. data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
  116. data/examples/15_memory_network_and_bus/output/combined_article.md +0 -11
  117. data/examples/15_memory_network_and_bus/output/final_article.md +0 -8
  118. data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
  119. data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
  120. data/examples/15_memory_network_and_bus/output/memory.json +0 -13
  121. data/examples/15_memory_network_and_bus/output/revision_1.md +0 -29
  122. data/examples/15_memory_network_and_bus/output/revision_2.md +0 -50
  123. data/examples/15_memory_network_and_bus/output/revision_3.md +0 -8
  124. data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
  125. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
  126. data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
  127. data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
  128. data/examples/16_writers_room/.envrc +0 -1
  129. data/examples/16_writers_room/display.rb +0 -158
  130. data/examples/16_writers_room/output/.gitignore +0 -4
  131. data/examples/16_writers_room/output/README.md +0 -69
  132. data/examples/16_writers_room/output/opus_001.md +0 -263
  133. data/examples/16_writers_room/output/opus_001_notes.log +0 -470
  134. data/examples/16_writers_room/output/opus_002.md +0 -245
  135. data/examples/16_writers_room/output/opus_002_notes.log +0 -546
  136. data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
  137. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
  138. data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
  139. data/examples/16_writers_room/prompts/writer.md +0 -37
  140. data/examples/16_writers_room/room.rb +0 -186
  141. data/examples/16_writers_room/tools.rb +0 -173
  142. data/examples/16_writers_room/writer.rb +0 -121
  143. data/examples/16_writers_room/writers_room.rb +0 -256
  144. data/examples/17_skills.rb +0 -234
  145. data/examples/18_rails/.envrc +0 -3
  146. data/examples/18_rails/.gitignore +0 -5
  147. data/examples/18_rails/Gemfile +0 -11
  148. data/examples/18_rails/README.md +0 -67
  149. data/examples/18_rails/Rakefile +0 -4
  150. data/examples/18_rails/app/controllers/application_controller.rb +0 -4
  151. data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
  152. data/examples/18_rails/app/jobs/application_job.rb +0 -4
  153. data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
  154. data/examples/18_rails/app/models/application_record.rb +0 -5
  155. data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
  156. data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
  157. data/examples/18_rails/app/robots/chat_robot.rb +0 -25
  158. data/examples/18_rails/app/tools/time_tool.rb +0 -9
  159. data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
  160. data/examples/18_rails/app/views/chat/index.html.erb +0 -67
  161. data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
  162. data/examples/18_rails/bin/dev +0 -7
  163. data/examples/18_rails/bin/rails +0 -6
  164. data/examples/18_rails/bin/setup +0 -15
  165. data/examples/18_rails/config/application.rb +0 -33
  166. data/examples/18_rails/config/cable.yml +0 -2
  167. data/examples/18_rails/config/database.yml +0 -5
  168. data/examples/18_rails/config/environment.rb +0 -4
  169. data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
  170. data/examples/18_rails/config/routes.rb +0 -6
  171. data/examples/18_rails/config.ru +0 -4
  172. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
  173. data/examples/19_token_tracking.rb +0 -138
  174. data/examples/20_circuit_breaker.rb +0 -147
  175. data/examples/21_learning_loop.rb +0 -181
  176. data/examples/22_context_compression.rb +0 -172
  177. data/examples/23_convergence.rb +0 -133
  178. data/examples/24_structured_delegation.rb +0 -151
  179. data/examples/25_history_search/conversation.jsonl +0 -30
  180. data/examples/25_history_search.rb +0 -133
  181. data/examples/26_document_store/api_versioning_adr.md +0 -52
  182. data/examples/26_document_store/incident_postmortem.md +0 -46
  183. data/examples/26_document_store/postgres_runbook.md +0 -49
  184. data/examples/26_document_store/redis_caching_guide.md +0 -48
  185. data/examples/26_document_store/sidekiq_guide.md +0 -51
  186. data/examples/26_document_store.rb +0 -140
  187. data/examples/27_incident_response/README.md +0 -65
  188. data/examples/27_incident_response/incident_response.rb +0 -261
  189. data/examples/28_mcp_discovery.rb +0 -113
  190. data/examples/29_ractor_tools.rb +0 -240
  191. data/examples/30_ractor_network.rb +0 -252
  192. data/examples/31_launch_assessment.rb +0 -246
  193. data/examples/32_newsletter_reader.rb +0 -193
  194. data/examples/33_stock_generator.rb +0 -80
  195. data/examples/33_stock_predictor.rb +0 -327
  196. data/examples/34_agentskills.rb +0 -76
  197. data/examples/35_hooks.rb +0 -266
  198. data/examples/README.md +0 -550
  199. data/examples/common.rb +0 -180
  200. data/examples/prompts/assistant.md +0 -5
  201. data/examples/prompts/audit_trail.md +0 -8
  202. data/examples/prompts/billing.md +0 -10
  203. data/examples/prompts/classifier.md +0 -7
  204. data/examples/prompts/comedian.md +0 -6
  205. data/examples/prompts/comedy_critic.md +0 -10
  206. data/examples/prompts/configurable.md +0 -9
  207. data/examples/prompts/dispatcher.md +0 -12
  208. data/examples/prompts/entity_extractor.md +0 -14
  209. data/examples/prompts/escalation.md +0 -42
  210. data/examples/prompts/frontmatter_mcp_test.md +0 -9
  211. data/examples/prompts/frontmatter_named_test.md +0 -5
  212. data/examples/prompts/frontmatter_tools_test.md +0 -6
  213. data/examples/prompts/general.md +0 -7
  214. data/examples/prompts/github_assistant.md +0 -14
  215. data/examples/prompts/helper.md +0 -4
  216. data/examples/prompts/incident_responder.md +0 -18
  217. data/examples/prompts/keyword_extractor.md +0 -11
  218. data/examples/prompts/llm_config_demo.md +0 -20
  219. data/examples/prompts/order_support.md +0 -35
  220. data/examples/prompts/os_advocate.md +0 -13
  221. data/examples/prompts/os_chief.md +0 -13
  222. data/examples/prompts/os_editor.md +0 -13
  223. data/examples/prompts/parameterized_main_test.md +0 -6
  224. data/examples/prompts/pii_redactor.md +0 -7
  225. data/examples/prompts/product_support.md +0 -37
  226. data/examples/prompts/runbook_protocol.md +0 -12
  227. data/examples/prompts/sentiment_analyzer.md +0 -12
  228. data/examples/prompts/skill_a_test.md +0 -4
  229. data/examples/prompts/skill_b_test.md +0 -4
  230. data/examples/prompts/skill_config_test.md +0 -5
  231. data/examples/prompts/skill_cycle_a_test.md +0 -6
  232. data/examples/prompts/skill_cycle_b_test.md +0 -6
  233. data/examples/prompts/skill_description_test.md +0 -4
  234. data/examples/prompts/skill_leaf_test.md +0 -4
  235. data/examples/prompts/skill_nested_test.md +0 -6
  236. data/examples/prompts/skill_refs_main_test.md +0 -6
  237. data/examples/prompts/skill_self_ref_test.md +0 -6
  238. data/examples/prompts/skill_with_mcp_test.md +0 -9
  239. data/examples/prompts/skill_with_params_test.md +0 -6
  240. data/examples/prompts/skill_with_robot_name_test.md +0 -5
  241. data/examples/prompts/skill_with_tools_test.md +0 -6
  242. data/examples/prompts/sre_compliance.md +0 -10
  243. data/examples/prompts/structured_output.md +0 -7
  244. data/examples/prompts/synthesizer.md +0 -17
  245. data/examples/prompts/technical.md +0 -10
  246. data/examples/prompts/template_with_skills_test.md +0 -6
  247. data/examples/prompts/triage.md +0 -22
  248. data/examples/ruboruby.md +0 -423
  249. data/examples/run_all.rb +0 -60
  250. data/examples/xyzzy.rb +0 -97
  251. data/logfile +0 -8
  252. data/mkdocs.yml +0 -209
  253. data/robot_concurrency.md +0 -38
  254. data/simple_acp_review.md +0 -298
  255. data/temp.md +0 -6
  256. data/tool_manifest_plan.md +0 -155
@@ -1,733 +0,0 @@
1
- # Observability & Safety
2
-
3
- Facilities that help you monitor, control, improve, and scale robot behaviour:
4
-
5
- - **Token & Cost Tracking** — measure LLM usage per run and cumulatively
6
- - **Tool Loop Circuit Breaker** — guard against runaway tool call loops
7
- - **Doom Loop Detection** — catch cyclic or repetitive tool-call patterns before they spiral
8
- - **Automatic Context Compaction** — prevent context overflow with configurable auto-compression
9
- - **Learning Accumulation** — build up cross-run observations that guide future runs
10
- - **Context Window Compression** — prune irrelevant history to stay within token budgets
11
- - **Convergence Detection** — detect when independent agents reach the same conclusion
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
14
-
15
- ---
16
-
17
- ## Token & Cost Tracking
18
-
19
- ### Per-Run Counts
20
-
21
- Every `robot.run()` returns a `RobotResult` that carries the token usage for that call:
22
-
23
- ```ruby
24
- robot = RobotLab.build(
25
- name: "analyst",
26
- system_prompt: "You are a concise technical analyst.",
27
- model: "claude-haiku-4-5-20251001"
28
- )
29
-
30
- result = robot.run("What is the difference between a stack and a queue?")
31
-
32
- puts result.input_tokens # tokens sent to the model this run
33
- puts result.output_tokens # tokens generated this run
34
- puts result.input_tokens + result.output_tokens # total for this call
35
- ```
36
-
37
- Token counts are `0` for providers that do not report usage data.
38
-
39
- ### Cumulative Totals
40
-
41
- The robot accumulates totals across all `run()` calls:
42
-
43
- ```ruby
44
- 3.times { |i| robot.run("Question #{i + 1}") }
45
-
46
- puts robot.total_input_tokens # sum across all three runs
47
- puts robot.total_output_tokens
48
- ```
49
-
50
- ### Cost Estimation
51
-
52
- Use per-provider pricing constants to estimate cost:
53
-
54
- ```ruby
55
- HAIKU_INPUT_CPM = 0.80 # $ per 1M input tokens
56
- HAIKU_OUTPUT_CPM = 4.00 # $ per 1M output tokens
57
-
58
- def run_cost(input, output)
59
- (input * HAIKU_INPUT_CPM + output * HAIKU_OUTPUT_CPM) / 1_000_000.0
60
- end
61
-
62
- result = robot.run("Explain memoization.")
63
- puts "$#{"%.5f" % run_cost(result.input_tokens, result.output_tokens)}"
64
- ```
65
-
66
- ### Batch Accounting with reset_token_totals
67
-
68
- `reset_token_totals` clears the accounting counters without touching the chat history. Use it to isolate the cost of a specific task batch:
69
-
70
- ```ruby
71
- # Batch 1
72
- prompts_batch_1.each { |p| robot.run(p) }
73
- puts "Batch 1 cost: $#{"%.4f" % run_cost(robot.total_input_tokens, robot.total_output_tokens)}"
74
-
75
- robot.reset_token_totals # start fresh accounting
76
-
77
- # Batch 2 — totals start at zero, but chat history is still intact
78
- prompts_batch_2.each { |p| robot.run(p) }
79
- puts "Batch 2 cost: $#{"%.4f" % run_cost(robot.total_input_tokens, robot.total_output_tokens)}"
80
- ```
81
-
82
- > **Important:** Because the chat history keeps growing after a reset, the next run's `input_tokens` will be larger than the first batch's runs. This is expected — it is the real cost of sending the full accumulated context to the API. The counter reset tracks *accounting*, not context size.
83
-
84
- For a truly fresh context and fresh counters, build a new robot:
85
-
86
- ```ruby
87
- fresh = RobotLab.build(
88
- name: "analyst",
89
- system_prompt: "You are a concise technical analyst."
90
- )
91
- result = fresh.run("Explain memoization.")
92
- puts result.input_tokens # smallest possible — no prior history
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
-
130
- ---
131
-
132
- ## Tool Loop Circuit Breaker
133
-
134
- ### The Problem
135
-
136
- When a tool always instructs the LLM to call it again (e.g., a step-processor returning "more steps remain"), the robot loops indefinitely. Without a guard this consumes tokens, API quota, and time without bound.
137
-
138
- ### max_tool_rounds
139
-
140
- Set `max_tool_rounds:` on the robot to cap how many tool calls can happen in a single `run()`. When the limit is exceeded, `RobotLab::ToolLoopError` is raised.
141
-
142
- ```ruby
143
- robot = RobotLab.build(
144
- name: "runner",
145
- system_prompt: "Execute every step sequentially.",
146
- local_tools: [StepTool],
147
- max_tool_rounds: 10
148
- )
149
- ```
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
158
- begin
159
- robot.run("Run all steps.", tools: :inherit)
160
- rescue RobotLab::ToolLoopError => e
161
- puts e.message
162
- # => "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
163
- end
164
- ```
165
-
166
- `max_tool_rounds` can also be supplied via `RunConfig`:
167
-
168
- ```ruby
169
- config = RobotLab::RunConfig.new(max_tool_rounds: 10)
170
- robot = RobotLab.build(name: "runner", system_prompt: "...", config: config)
171
- ```
172
-
173
- ### Recovering After ToolLoopError
174
-
175
- After a `ToolLoopError` the chat contains a **dangling `tool_use` block** with no matching `tool_result`. Anthropic and most other providers will reject any subsequent request with that broken history:
176
-
177
- ```
178
- Error: tool_use ids were found without tool_result blocks immediately after
179
- ```
180
-
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:
182
-
183
- ```ruby
184
- begin
185
- robot.run("Keep calling the tool.", tools: :inherit)
186
- rescue RobotLab::ToolLoopError => e
187
- puts "Breaker fired: #{e.message}"
188
- end
189
-
190
- robot.clear_messages
191
- # Robot is healthy — config unchanged
192
- puts robot.config.max_tool_rounds # still 10
193
-
194
- result = robot.run("Start fresh with a simple question.")
195
- ```
196
-
197
- ### Normal Tool Use Is Unaffected
198
-
199
- `max_tool_rounds` is a safety net, not a tax. A robot that calls a tool once and terminates works identically with or without the guard:
200
-
201
- ```ruby
202
- unguarded = RobotLab.build(
203
- name: "calculator",
204
- system_prompt: "Use the provided tool to answer questions.",
205
- local_tools: [DoubleTool]
206
- )
207
- result = unguarded.run("Double the number 21 using the tool.", tools: :inherit)
208
- puts result.reply # "The result is 42."
209
- ```
210
-
211
- ---
212
-
213
- ## Doom Loop Detection
214
-
215
- ### The Problem
216
-
217
- `max_tool_rounds` stops a robot that loops forever, but it fires on quantity alone. A subtler failure is when a robot cycles through the same tool call sequence repeatedly — calling tool A, then B, then C, then A again — without hitting the round limit. This is a doom loop: the robot is working but not making progress.
218
-
219
- ### doom_loop_threshold
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
-
223
- ```ruby
224
- robot = RobotLab.build(
225
- name: "runner",
226
- system_prompt: "Execute all steps.",
227
- local_tools: [StepTool],
228
- doom_loop_threshold: 5 # tune the always-on detector; default is 3
229
- )
230
- ```
231
-
232
- The detector catches two patterns:
233
-
234
- - **Consecutive repetition** — `[A, A, A]` (same tool called N times in a row)
235
- - **Cyclic repetition** — `[A, B, C, A, B, C, A, B, C]` (same sequence repeated N times)
236
-
237
- When a doom loop is detected, a warning message is embedded directly in the tool result, prompting the LLM to try a fundamentally different approach. This avoids corrupting the Anthropic message format (no injected user messages between `tool_use`/`tool_result` pairs).
238
-
239
- `doom_loop_threshold` can also be supplied via `RunConfig`:
240
-
241
- ```ruby
242
- config = RobotLab::RunConfig.new(doom_loop_threshold: 3)
243
- robot = RobotLab.build(name: "runner", system_prompt: "...", config: config)
244
- ```
245
-
246
- ### Complementary to max_tool_rounds
247
-
248
- Use both together for comprehensive loop protection:
249
-
250
- ```ruby
251
- robot = RobotLab.build(
252
- name: "executor",
253
- system_prompt: "Execute every step.",
254
- local_tools: [StepTool],
255
- max_tool_rounds: 20, # hard ceiling on total tool calls
256
- doom_loop_threshold: 3 # catches repetitive patterns early
257
- )
258
- ```
259
-
260
- ---
261
-
262
- ## Automatic Context Compaction
263
-
264
- ### The Problem
265
-
266
- Long-running robots accumulate conversation history. Eventually, the cumulative token count approaches the model's context window limit, causing API errors or degraded performance. Manually calling `compress_history` at the right moment requires application-level bookkeeping.
267
-
268
- ### auto_compact
269
-
270
- `auto_compact` and `compact_threshold` are **`RunConfig` fields only** — they are not constructor keyword arguments. Build a `RunConfig` and pass it as `config:`:
271
-
272
- ```ruby
273
- # Compact when estimated token usage exceeds 80% of the model's context window
274
- config = RobotLab::RunConfig.new(auto_compact: :context_window)
275
-
276
- robot = RobotLab.build(
277
- name: "analyst",
278
- system_prompt: "You are a research analyst.",
279
- config: config
280
- )
281
- ```
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
-
288
- ### Tuning the Threshold
289
-
290
- `compact_threshold:` sets the fraction of the model's context window that triggers compaction. Defaults to `0.80` (80%):
291
-
292
- ```ruby
293
- config = RobotLab::RunConfig.new(
294
- auto_compact: :context_window,
295
- compact_threshold: 0.70 # compact earlier, at 70%
296
- )
297
-
298
- robot = RobotLab.build(
299
- name: "analyst",
300
- system_prompt: "You are a research analyst.",
301
- config: config
302
- )
303
- ```
304
-
305
- ### Application-Owned Compaction
306
-
307
- Pass a `Proc` to take full control — the proc decides both when and how to compact:
308
-
309
- ```ruby
310
- config = RobotLab::RunConfig.new(
311
- auto_compact: ->(r) {
312
- r.compress_history(recent_turns: 5) if r.chat.messages.size > 40
313
- }
314
- )
315
-
316
- robot = RobotLab.build(
317
- name: "analyst",
318
- system_prompt: "You are a research analyst.",
319
- config: config
320
- )
321
- ```
322
-
323
- The proc receives the robot instance and is called once per `run()` when messages are non-empty.
324
-
325
- ### Options
326
-
327
- | Value | Behaviour |
328
- |-------|-----------|
329
- | `nil` / `:none` | No automatic compaction (default) |
330
- | `:context_window` | Compact when estimated token usage exceeds `compact_threshold` fraction of model's context window |
331
- | `Proc` | Called with the robot; application decides when and how to compact |
332
-
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.
334
-
335
- ---
336
-
337
- ## Learning Accumulation
338
-
339
- ### The Problem
340
-
341
- A robot's inherent memory persists key-value data, but there is no built-in way to tell the LLM "here is what I've learned from previous interactions." Learning accumulation fills that gap.
342
-
343
- ### robot.learn
344
-
345
- ```ruby
346
- robot.learn(text)
347
- ```
348
-
349
- Records `text` as an observation. On every subsequent `run()`, active learnings are automatically prepended to the user message:
350
-
351
- ```
352
- LEARNINGS FROM PREVIOUS RUNS:
353
- - This codebase prefers map/collect over manual array accumulation
354
- - Explicit nil comparisons appear frequently here
355
-
356
- <original user message>
357
- ```
358
-
359
- This gives the LLM access to prior context without requiring a persistent conversation history.
360
-
361
- ### Bidirectional Deduplication
362
-
363
- Learnings deduplicate bidirectionally:
364
-
365
- - If the new text is already contained in an existing learning, it is dropped.
366
- - If an existing learning is contained in the new text (the new one is broader), the narrower one is replaced.
367
-
368
- ```ruby
369
- robot.learn("avoid using puts")
370
- robot.learn("avoid using puts and p in production code")
371
-
372
- robot.learnings.size # => 1 — broader learning replaced the narrower one
373
- robot.learnings.first # => "avoid using puts and p in production code"
374
- ```
375
-
376
- ### Accumulated Learnings
377
-
378
- ```ruby
379
- robot.learnings # => Array<String>
380
- ```
381
-
382
- Returns the current list of active learnings in insertion order.
383
-
384
- ### Full Example
385
-
386
- ```ruby
387
- reviewer = RobotLab.build(
388
- name: "reviewer",
389
- system_prompt: <<~PROMPT
390
- You are a concise Ruby code reviewer.
391
- Identify the main issue in one sentence and show the fix.
392
- PROMPT
393
- )
394
-
395
- snippets = [snippet_a, snippet_b, snippet_c]
396
- insights = [
397
- "This codebase prefers map/collect over manual accumulation",
398
- "Explicit nil comparisons appear frequently",
399
- "Cart logic tends to have missing edge cases around nil discounts"
400
- ]
401
-
402
- snippets.each_with_index do |code, i|
403
- result = reviewer.run("Review this snippet:\n\n#{code}")
404
- puts result.reply
405
-
406
- reviewer.learn(insights[i])
407
- puts "Added learning ##{reviewer.learnings.size}"
408
- end
409
- ```
410
-
411
- After all three runs, `reviewer.learnings` contains up to three insights (fewer if any are subsets of others).
412
-
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.
416
-
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:
423
-
424
- ```ruby
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" })
437
- ```
438
-
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.
440
-
441
- ### Memory Persistence
442
-
443
- Learnings are stored in `memory[:learnings]`. They survive a robot rebuild when the same `Memory` object is passed to the new robot:
444
-
445
- ```ruby
446
- shared_memory = original_robot.memory
447
-
448
- rebuilt = RobotLab.build(
449
- name: "reviewer",
450
- system_prompt: "You review code."
451
- )
452
- rebuilt.instance_variable_set(:@memory, shared_memory)
453
- persisted = shared_memory.get(:learnings)
454
- rebuilt.instance_variable_set(:@learnings, Array(persisted))
455
-
456
- puts rebuilt.learnings.size # same as original_robot.learnings.size
457
- ```
458
-
459
- ---
460
-
461
- ## Context Window Compression
462
-
463
- ### The Problem
464
-
465
- Long conversations accumulate turns that are no longer relevant to the current topic. Sending all of them to the LLM on every `run()` wastes tokens and money, and risks exceeding the model's context window.
466
-
467
- ### robot.compress_history
468
-
469
- ```ruby
470
- robot.compress_history(
471
- recent_turns: 3, # last N user+assistant pairs — always protected
472
- keep_threshold: 0.6, # score >= this → keep verbatim
473
- drop_threshold: 0.2, # score < this → drop
474
- summarizer: nil # optional lambda(text) -> String for medium tier
475
- )
476
- ```
477
-
478
- Internally, each old turn is scored against the mean of the recent turns using stemmed term-frequency cosine similarity (via the `classifier` gem). Turns that score high are kept; turns that score low are dropped; turns in the middle band are either summarized or dropped depending on whether a `summarizer` is provided.
479
-
480
- **Always preserved regardless of score:**
481
-
482
- - System messages
483
- - Tool call/result message pairs
484
- - All messages within the `recent_turns` window
485
-
486
- ### Thresholds
487
-
488
- ```
489
- score >= keep_threshold → keep verbatim
490
- score < drop_threshold → drop
491
- otherwise → summarize (if summarizer given) or drop
492
- ```
493
-
494
- A good starting point: `keep_threshold: 0.6, drop_threshold: 0.2`. Widen the drop band (raise `drop_threshold`) to compress more aggressively; raise `keep_threshold` to summarize more.
495
-
496
- ### Without a Summarizer (Drop Mode)
497
-
498
- ```ruby
499
- robot.compress_history(recent_turns: 3, keep_threshold: 0.6, drop_threshold: 0.2)
500
- ```
501
-
502
- Medium-relevance turns are dropped along with low-relevance ones. This is the simplest form — no extra LLM calls, no added latency.
503
-
504
- ### With an LLM Summarizer
505
-
506
- ```ruby
507
- summarizer_bot = RobotLab.build(
508
- name: "summarizer",
509
- system_prompt: "Summarize the following text in one sentence."
510
- )
511
-
512
- robot.compress_history(
513
- recent_turns: 3,
514
- keep_threshold: 0.6,
515
- drop_threshold: 0.2,
516
- summarizer: ->(text) { summarizer_bot.run("Summarize: #{text}").reply }
517
- )
518
- ```
519
-
520
- The summarizer replaces each medium-relevance turn with a one-sentence digest, preserving some context while reducing token count. The summary inherits the **original message's role** so the user/assistant alternation required by LLM APIs is maintained.
521
-
522
- ### Optional Dependency
523
-
524
- `compress_history` requires the `classifier` gem. Add it to your Gemfile:
525
-
526
- ```ruby
527
- gem "classifier", "~> 2.3"
528
- ```
529
-
530
- Without it, calling `compress_history` raises `RobotLab::DependencyError` with an install hint.
531
-
532
- ---
533
-
534
- ## Convergence Detection
535
-
536
- ### The Problem
537
-
538
- Multi-robot verification patterns (two independent reviewers, a debate network, a fact-checker) typically ask a reconciler robot to resolve any differences. But when both verifiers already agree, paying for that reconciler call is pure waste.
539
-
540
- ### RobotLab::Convergence
541
-
542
- ```ruby
543
- score = RobotLab::Convergence.similarity(text_a, text_b) # Float 0.0..1.0
544
- agreed = RobotLab::Convergence.detected?(text_a, text_b) # Boolean (threshold: 0.85)
545
- agreed = RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.6)
546
- ```
547
-
548
- Similarity is computed via L2-normalized stemmed term-frequency cosine similarity. Term frequencies (not TF-IDF) are used because fitting TF-IDF on a 2-document corpus suppresses shared terms to near-zero IDF, giving counter-intuitively low scores for texts that agree on the same topic.
549
-
550
- Texts shorter than 30 characters always return `0.0`.
551
-
552
- ### Typical Scores
553
-
554
- | Relationship | Typical Score |
555
- |---|---|
556
- | Identical | 1.000 |
557
- | Same conclusion, different phrasing | 0.60 – 0.75 |
558
- | Same topic, different emphasis | 0.45 – 0.60 |
559
- | Unrelated | < 0.15 |
560
-
561
- ### Reconciler Fast-Path Pattern
562
-
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:
564
-
565
- ```ruby
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
571
-
572
- return result if RobotLab::Convergence.detected?(a, b) # agree — reconciler stays dormant
573
-
574
- result.activate(:reconciler) # diverged — activate the branch
575
- end
576
- end
577
-
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
587
- ```
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
-
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.
597
-
598
- ### Optional Dependency
599
-
600
- `RobotLab::Convergence` requires the `classifier` gem (same as `compress_history`):
601
-
602
- ```ruby
603
- gem "classifier", "~> 2.3"
604
- ```
605
-
606
- ---
607
-
608
- ---
609
-
610
- ## Structured Delegation
611
-
612
- ### The Problem
613
-
614
- RobotLab has two existing patterns for one robot to involve another:
615
-
616
- - **Pipelines** — predefined sequences where robots share memory and run in order
617
- - **Bus messaging** — fire-and-forget pub/sub with no return value
618
-
619
- Neither gives you a synchronous call that returns a result with provenance and cost metadata. `delegate` fills that gap.
620
-
621
- ### Synchronous delegation
622
-
623
- Blocks until the delegatee finishes and returns a `RobotResult` annotated with provenance and timing:
624
-
625
- ```ruby
626
- result = manager.delegate(to: specialist, task: "Analyze this data: ...")
627
-
628
- puts result.reply # specialist's answer
629
- puts result.robot_name # => "specialist" (who did the work)
630
- puts result.delegated_by # => "manager" (who asked)
631
- puts result.duration # => 1.43 (wall-clock seconds)
632
- puts result.input_tokens # => 812
633
- puts result.output_tokens # => 94
634
- ```
635
-
636
- All keyword arguments are forwarded to the delegatee's `run()`:
637
-
638
- ```ruby
639
- result = manager.delegate(to: worker, task: "hello", company_name: "Acme")
640
- ```
641
-
642
- ### Asynchronous delegation — parallel fan-out
643
-
644
- Pass `async: true` to get a `DelegationFuture` back immediately. The delegatee runs in a background thread. Call `future.value` to block for the result, or `future.resolved?` to poll without blocking.
645
-
646
- ```ruby
647
- # Fire both delegations simultaneously
648
- f1 = manager.delegate(to: summarizer, task: "Summarize: #{doc}", async: true)
649
- f2 = manager.delegate(to: analyst, task: "Key metric: #{doc}", async: true)
650
-
651
- # Both are running in parallel here
652
- puts f1.resolved? # false (probably)
653
-
654
- # Collect when ready (optional timeout in seconds)
655
- summary = f1.value(timeout: 30)
656
- analysis = f2.value(timeout: 30)
657
- ```
658
-
659
- If the delegatee raises an error, `future.value` re-raises it. If `timeout:` expires before the result arrives, `DelegationFuture::DelegationTimeout` is raised.
660
-
661
- ### When to Use Each Pattern
662
-
663
- | Pattern | Return value | Concurrent | Use when |
664
- |---|---|---|---|
665
- | `pipeline` | shared memory | yes (parallel groups) | fixed workflow graph |
666
- | `bus` messaging | none (fire-and-forget) | yes | notify without waiting for a reply |
667
- | `delegate` | `RobotResult` with metadata | no | need the result back, one at a time |
668
- | `delegate(async: true)` | `DelegationFuture` | yes | parallel fan-out, collect results later |
669
-
670
- ### Full Example
671
-
672
- ```ruby
673
- manager = RobotLab.build(name: "manager", system_prompt: "You are a project manager.")
674
- summarizer = RobotLab.build(name: "summarizer", system_prompt: "Summarize in 1-2 sentences.")
675
- analyst = RobotLab.build(name: "analyst", system_prompt: "Identify the key metric.")
676
-
677
- # Parallel fan-out
678
- f1 = manager.delegate(to: summarizer, task: "Summarize: #{document}", async: true)
679
- f2 = manager.delegate(to: analyst, task: "Key metric: #{document}", async: true)
680
-
681
- summary = f1.value(timeout: 60)
682
- analysis = f2.value(timeout: 60)
683
-
684
- puts "#{summary.robot_name} (#{summary.duration.round(2)}s): #{summary.reply}"
685
- puts "#{analysis.robot_name} (#{analysis.duration.round(2)}s): #{analysis.reply}"
686
- ```
687
-
688
- ---
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
-
724
- ## See Also
725
-
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
733
- - [RunConfig reference](../getting-started/configuration.md#runconfig-shared-operational-defaults)