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
@@ -20,13 +20,15 @@
20
20
  # In a bus-based SOG, writers receive many messages and each
21
21
  # triggers a run() call. When the LLM responds with only tool
22
22
  # calls (no text), RubyLLM appends an empty text content block
23
- # to the chat history. The Anthropic API rejects this on the
24
- # next call, permanently killing the writer.
23
+ # to the chat history. Several providers reject that on the next
24
+ # call, permanently killing the writer.
25
25
  #
26
- # Fix: reset the chat before each message. The writer doesn't
27
- # need persistent chat history shared memory is the single
28
- # source of truth. Each message is processed with a fresh chat
29
- # that has the system prompt, tools, and current memory context.
26
+ # Fix: reset the conversation before each message via the public
27
+ # Robot#clear_messages(keep_system: true). The writer doesn't need
28
+ # persistent chat history shared memory is the single source of
29
+ # truth. Rebuilding @chat by hand instead would drop the tool
30
+ # callbacks and chat params that Robot#initialize installed, so
31
+ # clear_messages is both shorter and safer.
30
32
  #
31
33
  class Writer < RobotLab::Robot
32
34
  attr_accessor :shared_memory, :display, :room
@@ -40,6 +42,7 @@ class Writer < RobotLab::Robot
40
42
 
41
43
  super(
42
44
  name: name,
45
+ **llm_opts,
43
46
  template: room.mode[:template],
44
47
  context: { writer_name: name },
45
48
  bus: bus,
@@ -69,24 +72,18 @@ class Writer < RobotLab::Robot
69
72
  ]
70
73
  end
71
74
 
75
+ # Room deliveries go to this robot's BusPoller, which serializes them
76
+ # behind any run() already in flight before invoking on_message.
72
77
  def setup_room_subscription
73
78
  @bus.subscribe(:room) do |delivery|
74
- handle_incoming_delivery(delivery)
79
+ enqueue_delivery(delivery)
75
80
  end
76
81
  end
77
82
 
78
- # Reset the chat to a clean state with system prompt and tools.
79
- # Prevents history corruption from tool-only LLM responses
80
- # (empty text content blocks that Anthropic rejects).
81
- def fresh_chat!
82
- resolved_model = @config&.model || RobotLab.config.ruby_llm.model
83
- @chat = RubyLLM.chat(model: resolved_model)
84
- apply_template_to_chat(@build_context) if @template
85
- @chat.with_instructions(@system_prompt) if @system_prompt
86
- @chat.with_temperature(@config.temperature) if @config&.temperature
87
-
88
- filtered = filtered_tools([])
89
- @chat.with_tools(*filtered) if filtered.any?
83
+ # Drop the turn history, keep the rendered system prompt. Model,
84
+ # temperature, tools and the tool callbacks all survive untouched.
85
+ def reset_conversation!
86
+ clear_messages(keep_system: true)
90
87
  end
91
88
 
92
89
  def setup_message_handler
@@ -98,8 +95,8 @@ class Writer < RobotLab::Robot
98
95
  log&.info("#{name} <- [#{message.from}] msg ##{@messages_processed} (#{message.content.to_s[0..80]}...)")
99
96
  @display&.incoming(name, message.from, message.content)
100
97
 
101
- # Fresh chat for each message — shared memory is our persistence
102
- fresh_chat!
98
+ # Fresh conversation for each message — shared memory is our persistence
99
+ reset_conversation!
103
100
 
104
101
  # Build prompt with current memory context
105
102
  memory_keys = shared_memory.keys
@@ -109,7 +106,10 @@ class Writer < RobotLab::Robot
109
106
  log&.info("#{name} -> run() starting (prompt: #{prompt.length} chars)")
110
107
 
111
108
  begin
112
- result = run(prompt)
109
+ # tools: :inherit — run() defaults to :none. Without it the writer
110
+ # would have all seven tools attached and none of them offered to the
111
+ # model, so nothing would ever be written to shared memory.
112
+ result = run(prompt, tools: :inherit)
113
113
  reply_text = result.respond_to?(:reply) ? result.reply.to_s[0..120] : result.to_s[0..120]
114
114
  log&.info("#{name} <- run() finished (reply: #{reply_text}...)")
115
115
  rescue => e
@@ -125,6 +125,8 @@ FileUtils.mkdir_p(OUTPUT_DIR)
125
125
 
126
126
  display = Display.new(log_path: log_path)
127
127
 
128
+ # RunConfig has no `provider` field, so the writers get provider+model via
129
+ # **llm_opts and inherit the rest of their operational defaults from here.
128
130
  shared_config = RobotLab::RunConfig.new(
129
131
  model: LLM[:default].model,
130
132
  temperature: 0.7
@@ -29,7 +29,7 @@
29
29
  # 6. incident_responder — "you are the on-call analyst for..."
30
30
  #
31
31
  # Usage:
32
- # ANTHROPIC_API_KEY=your_key ruby examples/17_skills.rb
32
+ # ruby examples/17_skills.rb
33
33
  #
34
34
  # Templates:
35
35
  # examples/prompts/
@@ -136,7 +136,7 @@ puts
136
136
  # - sre_compliance: Recursive skill — auto-expands to include pii_redactor
137
137
  # and audit_trail before its own compliance instructions
138
138
  responder = RobotLab.build(
139
- model: LLM[:default].model,
139
+ **llm_opts,
140
140
  name: "incident_responder",
141
141
  template: :incident_responder,
142
142
  skills: [:runbook_protocol, :structured_output, :sre_compliance],
@@ -147,17 +147,20 @@ responder = RobotLab.build(
147
147
  }
148
148
  )
149
149
 
150
- # Show what the robot got
150
+ # Show what the robot got.
151
+ #
152
+ # `skills` is the DECLARED list. The expanded list (with sre_compliance's
153
+ # nested skills spliced in ahead of it) has no public reader — but you don't
154
+ # need one, because the expansion is visible in the assembled system prompt
155
+ # printed below, which is the thing that actually reaches the model.
151
156
  puts "Robot: #{responder.name}"
152
- puts "Skills: #{responder.skills.inspect}"
153
- puts "Expanded skills: #{responder.instance_variable_get(:@expanded_skills).inspect}"
157
+ puts "Declared skills: #{responder.skills.inspect}"
154
158
  puts "Model: #{responder.model}"
155
159
  puts
156
160
 
157
- # Display the assembled system prompt
158
- chat = responder.instance_variable_get(:@chat)
159
- messages = chat.instance_variable_get(:@messages)
160
- system_msg = messages.find { |m| m.role.to_s == "system" }
161
+ # Display the assembled system prompt.
162
+ # Robot#messages is a public reader over the chat's message list.
163
+ system_msg = responder.messages.find { |m| m.role.to_s == "system" }
161
164
  if system_msg
162
165
  prompt = system_msg.content
163
166
  hr
@@ -174,7 +177,7 @@ end
174
177
  # =============================================================================
175
178
 
176
179
  plain_responder = RobotLab.build(
177
- model: LLM[:default].model,
180
+ **llm_opts,
178
181
  name: "plain_responder",
179
182
  template: :incident_responder,
180
183
  context: {
@@ -184,9 +187,7 @@ plain_responder = RobotLab.build(
184
187
  }
185
188
  )
186
189
 
187
- plain_chat = plain_responder.instance_variable_get(:@chat)
188
- plain_messages = plain_chat.instance_variable_get(:@messages)
189
- plain_msg = plain_messages.find { |m| m.role.to_s == "system" }
190
+ plain_msg = plain_responder.messages.find { |m| m.role.to_s == "system" }
190
191
  if plain_msg
191
192
  puts
192
193
  puts "For comparison — plain responder (no skills): #{plain_msg.content.length} chars"
@@ -11,7 +11,15 @@ Minimal Rails 8 app that demonstrates RobotLab's full Rails integration:
11
11
  ## Prerequisites
12
12
 
13
13
  - Ruby 3.2+
14
- - An LLM API key (e.g. `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in your env)
14
+ - [Ollama](https://ollama.com) running locally with the demo model pulled:
15
+
16
+ ```bash
17
+ ollama serve
18
+ ollama pull qwen3.6
19
+ ```
20
+
21
+ No API keys. Override the model with `ROBOT_LAB_PROVIDER` / `ROBOT_LAB_MODEL`,
22
+ and the endpoint with `OLLAMA_API_BASE`.
15
23
 
16
24
  ## Setup
17
25
 
@@ -33,6 +41,17 @@ bin/dev
33
41
  2. Refresh the page — conversation history is preserved
34
42
  3. Check `db/development.sqlite3` to see persisted threads and results
35
43
 
44
+ ## Two things worth copying
45
+
46
+ **`RobotLab::RailsIntegration::Job` is the real superclass.** There is no
47
+ `RobotLab::Job` alias, so `class RobotRunJob < RobotLab::Job` raises `NameError`
48
+ at boot.
49
+
50
+ **`tools: :inherit` has to reach `run`.** `Robot#run` defaults to `tools: :none`,
51
+ which hands the provider an empty tool list. The job splats any extra keywords
52
+ into `robot.run`, so the controller passes `tools: :inherit` at enqueue time —
53
+ without it TimeTool is never offered and the model just guesses the time.
54
+
36
55
  ## Architecture
37
56
 
38
57
  ```
@@ -24,10 +24,14 @@ class ChatController < ApplicationController
24
24
  checksum: Digest::SHA256.hexdigest(message)
25
25
  )
26
26
 
27
+ # tools: :inherit is forwarded by the job straight through to robot.run.
28
+ # Without it run() defaults to tools: :none and TimeTool is never offered
29
+ # to the model, so "What time is it?" gets a guess instead of a tool call.
27
30
  RobotRunJob.perform_later(
28
31
  robot_class: "ChatRobot",
29
32
  message: message,
30
- thread_id: thread_id
33
+ thread_id: thread_id,
34
+ tools: :inherit
31
35
  )
32
36
 
33
37
  respond_to do |format|
@@ -2,18 +2,24 @@
2
2
 
3
3
  # Generic background job for executing any robot asynchronously.
4
4
  #
5
- # Inherits from RobotLab::Job — Turbo Stream wiring, thread persistence,
6
- # and completion/error broadcasting are all handled by the base class.
5
+ # Inherits from RobotLab::RailsIntegration::Job — Turbo Stream wiring, thread
6
+ # persistence, and completion/error broadcasting are all handled by the base
7
+ # class. (Note the full namespace: robot_lab-rails defines the class as
8
+ # RobotLab::RailsIntegration::Job and registers no RobotLab::Job alias.)
7
9
  #
8
- # Pass robot_class: at enqueue time to select which robot to run.
10
+ # Pass robot_class: at enqueue time to select which robot to run. Any extra
11
+ # keywords are forwarded straight to robot.run — which is how `tools:` gets
12
+ # through, since run defaults to :none and would otherwise send the model an
13
+ # empty tool list.
9
14
  #
10
15
  # @example Enqueue from a controller
11
16
  # RobotRunJob.perform_later(
12
17
  # robot_class: "ChatRobot",
13
18
  # message: params[:message],
14
- # thread_id: session_id
19
+ # thread_id: session_id,
20
+ # tools: :inherit
15
21
  # )
16
22
  #
17
- class RobotRunJob < RobotLab::Job
23
+ class RobotRunJob < RobotLab::RailsIntegration::Job
18
24
  queue_as :default
19
25
  end
@@ -1,11 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Factory for the chat robot. RobotLab::RailsIntegration::Job calls
4
+ # ChatRobot.build(on_content:, on_tool_call:) to wire Turbo streaming.
5
+ #
6
+ # The model is served locally by Ollama. Ollama models are absent from
7
+ # RubyLLM's registry, so provider: must accompany model: — that is what makes
8
+ # RubyLLM skip the registry lookup.
3
9
  class ChatRobot
4
10
  SYSTEM_PROMPT = "You are a friendly assistant. Be concise."
5
11
 
12
+ PROVIDER = ENV.fetch("ROBOT_LAB_PROVIDER", "ollama")
13
+ MODEL = ENV.fetch("ROBOT_LAB_MODEL", "qwen3.6:latest")
14
+
6
15
  def self.build(**options)
7
16
  RobotLab.build(
8
17
  name: "chat",
18
+ provider: PROVIDER,
19
+ model: MODEL,
9
20
  system_prompt: SYSTEM_PROMPT,
10
21
  local_tools: [TimeTool],
11
22
  **options
@@ -1,3 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RobotLab.config.logger = Rails.logger
4
+
5
+ # Runs against a local Ollama server — no API keys required.
6
+ # ollama serve
7
+ # ollama pull qwen3.6
8
+ RubyLLM.configure do |c|
9
+ c.logger = Rails.logger
10
+ c.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
11
+ end
@@ -14,24 +14,30 @@
14
14
  # naturally increase as conversation context accumulates.
15
15
  # Use a fresh robot.build when you need a genuinely fresh context.
16
16
  #
17
- # Anthropic and OpenAI both return token counts in every response.
18
- # Token counts are zero for providers that don't report usage data.
17
+ # Ollama reports usage counts like the hosted providers do, so the tracking
18
+ # API behaves identically only the price per token differs. Token counts are
19
+ # zero for providers that don't report usage data.
19
20
  #
20
21
  # Usage:
21
- # ANTHROPIC_API_KEY=your_key ruby examples/19_token_tracking.rb
22
+ # ruby examples/19_token_tracking.rb
22
23
 
23
24
  require_relative "common"
24
25
 
25
- # Anthropic claude-haiku-4-5 pricing (as of early 2026)
26
- HAIKU_INPUT_CPM = 0.80 # $ per 1M input tokens
27
- HAIKU_OUTPUT_CPM = 4.00 # $ per 1M output tokens
26
+ # Cost model. A local Ollama model bills nothing, so the interesting number is
27
+ # what the same traffic WOULD have cost on a hosted model — set RATE_INPUT_CPM
28
+ # and RATE_OUTPUT_CPM to your provider's $-per-1M-token rates to see it.
29
+ # Defaults are zero: local inference is free.
30
+ RATE_INPUT_CPM = ENV.fetch("RATE_INPUT_CPM", "0").to_f
31
+ RATE_OUTPUT_CPM = ENV.fetch("RATE_OUTPUT_CPM", "0").to_f
28
32
 
29
33
  def token_summary(input, output)
30
34
  "in=#{input} out=#{output} total=#{input + output}"
31
35
  end
32
36
 
33
37
  def run_cost(input, output)
34
- dollars = (input * HAIKU_INPUT_CPM + output * HAIKU_OUTPUT_CPM) / 1_000_000.0
38
+ return "$0.00000 (local)" if RATE_INPUT_CPM.zero? && RATE_OUTPUT_CPM.zero?
39
+
40
+ dollars = (input * RATE_INPUT_CPM + output * RATE_OUTPUT_CPM) / 1_000_000.0
35
41
  "$#{"%.5f" % dollars}"
36
42
  end
37
43
 
@@ -41,8 +47,18 @@ end
41
47
 
42
48
  banner "Per-Robot Token & Cost Tracking"
43
49
 
50
+ puts "Model: #{LLM[:default].provider}/#{LLM[:default].model}"
51
+ if RATE_INPUT_CPM.zero? && RATE_OUTPUT_CPM.zero?
52
+ puts "Rates: none set — local inference is free."
53
+ puts " Set RATE_INPUT_CPM / RATE_OUTPUT_CPM to price the same traffic"
54
+ puts " against a hosted provider."
55
+ else
56
+ puts "Rates: $#{RATE_INPUT_CPM}/1M in, $#{RATE_OUTPUT_CPM}/1M out"
57
+ end
58
+ puts
59
+
44
60
  robot = RobotLab.build(
45
- model: LLM[:default].model,
61
+ **llm_opts,
46
62
  name: "analyst",
47
63
  system_prompt: "You are a concise technical analyst. Keep every reply under 40 words."
48
64
  )
@@ -106,7 +122,7 @@ puts "Fresh robot — genuinely zero context:"
106
122
  puts
107
123
 
108
124
  fresh = RobotLab.build(
109
- model: LLM[:default].model,
125
+ **llm_opts,
110
126
  name: "analyst2",
111
127
  system_prompt: "You are a concise technical analyst. Keep every reply under 40 words."
112
128
  )
@@ -12,7 +12,7 @@
12
12
  #
13
13
  # Key behaviour when the breaker fires:
14
14
  # The chat history contains a dangling tool_use with no tool_result.
15
- # Anthropic (and most providers) reject any subsequent request with that
15
+ # Most providers reject any subsequent request with that
16
16
  # broken history. You MUST call robot.clear_messages before reusing the
17
17
  # same robot instance — or simply build a fresh robot.
18
18
  #
@@ -23,7 +23,7 @@
23
23
  # - Contrast: robot without a circuit breaker on a task that terminates
24
24
  #
25
25
  # Usage:
26
- # ANTHROPIC_API_KEY=your_key ruby examples/20_circuit_breaker.rb
26
+ # ruby examples/20_circuit_breaker.rb
27
27
 
28
28
  require_relative "common"
29
29
 
@@ -69,14 +69,14 @@ TASK = "Run the batch process from step 1 using the MultiStepProcessor tool. " \
69
69
  # Part 1: Circuit breaker fires — rescue ToolLoopError, then recover
70
70
  #
71
71
  # After ToolLoopError the chat contains a dangling tool_use block with no
72
- # matching tool_result. Anthropic rejects any follow-up request with that
72
+ # matching tool_result. Providers reject any follow-up request with that
73
73
  # history. Call clear_messages to flush the broken context before reuse.
74
74
  # -------------------------------------------------------------------------
75
75
 
76
76
  section "Part 1: Circuit Breaker Fires (max_tool_rounds: 5)"
77
77
 
78
78
  robot = RobotLab.build(
79
- model: LLM[:default].model,
79
+ **llm_opts,
80
80
  name: "process_runner",
81
81
  system_prompt: "You are a process runner. Execute tasks exactly as instructed.",
82
82
  local_tools: [MultiStepProcessor],
@@ -84,7 +84,10 @@ robot = RobotLab.build(
84
84
  )
85
85
 
86
86
  begin
87
- robot.run(TASK)
87
+ # tools: :inherit — run() defaults to :none, which sends the provider an
88
+ # empty tool list. With no tool to loop on there would be nothing for the
89
+ # breaker to trip.
90
+ robot.run(TASK, tools: :inherit)
88
91
  puts "Process completed (unexpected for this demo)."
89
92
  rescue RobotLab::ToolLoopError => e
90
93
  puts "Circuit breaker fired!"
@@ -130,13 +133,13 @@ class SingleStep < RubyLLM::Tool
130
133
  end
131
134
 
132
135
  unguarded = RobotLab.build(
133
- model: LLM[:default].model,
136
+ **llm_opts,
134
137
  name: "calculator",
135
138
  system_prompt: "Use the provided tool to answer questions.",
136
139
  local_tools: [SingleStep]
137
140
  )
138
141
 
139
- result = unguarded.run("Double the number 21 using the tool.")
142
+ result = unguarded.run("Double the number 21 using the tool.", tools: :inherit)
140
143
  puts "Result: #{result.reply&.strip}"
141
144
  puts
142
145
 
@@ -14,10 +14,12 @@
14
14
  # - robot.learnings — read the accumulated list
15
15
  # - Learnings injected as "LEARNINGS FROM PREVIOUS RUNS:" prefix
16
16
  # - Superset dedup: a broader learning replaces narrower earlier ones
17
- # - Memory persistence: learnings survive rebuilding with the same Memory
17
+ # - robot.memory[:learnings] where the list is actually stored
18
+ # - Scope: learnings are per-robot and in-process; see robot_lab-durable
19
+ # (and example 33) for cross-session persistence
18
20
  #
19
21
  # Usage:
20
- # ANTHROPIC_API_KEY=your_key ruby examples/21_learning_loop.rb
22
+ # ruby examples/21_learning_loop.rb
21
23
 
22
24
  require_relative "common"
23
25
 
@@ -67,7 +69,7 @@ SNIPPETS = [
67
69
  banner "Learning Accumulation Loop"
68
70
 
69
71
  robot = RobotLab.build(
70
- model: LLM[:default].model,
72
+ **llm_opts,
71
73
  name: "code_reviewer",
72
74
  system_prompt: <<~PROMPT
73
75
  You are a concise Ruby code reviewer. For each snippet:
@@ -120,7 +122,7 @@ puts
120
122
  # Demonstrate superset dedup: a broader learning replaces narrower ones
121
123
  # ---------------------------------------------------------------
122
124
  section "Deduplication Demo"
123
- robot2 = RobotLab.build(name: "reviewer2", system_prompt: "You review code.")
125
+ robot2 = RobotLab.build(**llm_opts, name: "reviewer2", system_prompt: "You review code.")
124
126
 
125
127
  robot2.learn("avoid using puts")
126
128
  robot2.learn("avoid using puts and p in production code") # covers the first
@@ -130,26 +132,50 @@ robot2.learnings.each_with_index { |l, i| puts " #{i + 1}. #{l}" }
130
132
  puts
131
133
 
132
134
  # ---------------------------------------------------------------
133
- # Demonstrate persistence: learnings survive a robot rebuild using
134
- # the same Memory object
135
+ # Where learnings actually live
136
+ #
137
+ # learn() writes through to the robot's own Memory under :learnings.
138
+ # Memory is exposed by the public `memory` reader, so you can inspect
139
+ # (or serialize) the list without touching instance variables.
135
140
  # ---------------------------------------------------------------
136
- section "Persistence Across Rebuild"
141
+ section "Learnings Are Backed by Robot Memory"
142
+
143
+ puts "robot.memory.get(:learnings):"
144
+ Array(robot.memory.get(:learnings)).each_with_index { |l, i| puts " #{i + 1}. #{l}" }
145
+ puts
146
+ puts "Same list as robot.learnings? #{robot.memory.get(:learnings) == robot.learnings}"
147
+ puts
148
+
149
+ # ---------------------------------------------------------------
150
+ # Scope: learnings do NOT survive a rebuild
151
+ #
152
+ # Robot#initialize always constructs a fresh Memory and there is no
153
+ # `memory:` constructor parameter, so a new robot — even with the same
154
+ # name — starts with an empty learning list. learn() is a within-process
155
+ # accumulator, not a persistence layer.
156
+ # ---------------------------------------------------------------
157
+ section "Scope: In-Process Only"
137
158
 
138
- shared_memory = robot.instance_variable_get(:@memory)
139
159
  rebuilt = RobotLab.build(
140
- model: LLM[:default].model,
160
+ **llm_opts,
141
161
  name: "code_reviewer",
142
162
  system_prompt: "You review code."
143
163
  )
144
- rebuilt.instance_variable_set(:@memory, shared_memory)
145
-
146
- # Trigger the memory restore path
147
- persisted = shared_memory.get(:learnings)
148
- rebuilt.instance_variable_set(:@learnings, Array(persisted))
149
164
 
150
- puts "Learnings on rebuilt robot (#{rebuilt.learnings.size}):"
151
- rebuilt.learnings.each_with_index { |l, i| puts " #{i + 1}. #{l}" }
165
+ puts "Learnings on a freshly built robot of the same name: #{rebuilt.learnings.size}"
166
+ puts "(Robot#initialize builds a new Memory every time — nothing carries over.)"
152
167
  puts
168
+ puts <<~PERSIST
169
+ To carry knowledge across processes, use the robot_lab-durable gem:
170
+
171
+ require "robot_lab/durable"
172
+ robot.setup_durable_learning(domain: "ruby code review")
173
+ # ... run the robot ...
174
+ robot.run_reflector # promotes learnings to ~/.robot_lab/durable/<domain>.yml
175
+
176
+ On the next boot setup_durable_learning seeds robot.learnings from that
177
+ YAML store. See examples/33_stock_predictor.rb for a working loop.
178
+ PERSIST
153
179
 
154
180
  hr
155
181
  puts "Learning loop demo complete."
@@ -3,14 +3,18 @@
3
3
 
4
4
  # Example 22: Context Window Compression
5
5
  #
6
- # Demonstrates robot.compress_history() for reducing token usage in long
7
- # conversations. Old turns are scored against the recent context using
8
- # stemmed term-frequency cosine similarity. High-relevance turns are kept
9
- # verbatim; irrelevant turns are dropped; medium-relevance turns can be
10
- # summarized by a second robot.
6
+ # Drives RobotLab::HistoryCompressor directly over a synthetic transcript, so
7
+ # the keep/drop/summarize decisions are visible without spending any tokens.
8
+ # Old turns are scored against the recent context using stemmed
9
+ # term-frequency cosine similarity. High-relevance turns are kept verbatim;
10
+ # irrelevant turns are dropped; medium-relevance turns can be summarized.
11
+ #
12
+ # Robot#compress_history is the same algorithm applied in place to a live
13
+ # robot's chat — it constructs a HistoryCompressor over robot.messages and
14
+ # replaces them with the result. The final section shows that call.
11
15
  #
12
16
  # Demonstrates:
13
- # - robot.compress_history() — drop/keep/summarize old turns in-place
17
+ # - HistoryCompressor.new(messages:, ...).callpure, testable core
14
18
  # - recent_turns: N — last N user+assistant pairs always protected
15
19
  # - keep_threshold: / drop_threshold: — tunable relevance bands
16
20
  # - summarizer: — optional lambda(text) -> String for medium-relevance
@@ -20,7 +24,7 @@
20
24
  # gem 'classifier', '~> 2.3' # add to your Gemfile
21
25
  #
22
26
  # Usage:
23
- # ANTHROPIC_API_KEY=your_key ruby examples/22_context_compression.rb
27
+ # ruby examples/22_context_compression.rb # no LLM calls
24
28
 
25
29
  require_relative "common"
26
30
 
@@ -47,23 +51,15 @@ end
47
51
 
48
52
  banner "Context Window Compression"
49
53
 
50
- # ---------------------------------------------------------------------------
51
- # Build a robot and simulate a long conversation on two topics
52
- # ---------------------------------------------------------------------------
53
- bot = RobotLab.build(
54
- model: LLM[:default].model,
55
- name: "assistant",
56
- system_prompt: "You are a concise Ruby expert. Reply in 2-3 sentences."
57
- )
58
-
59
- puts "Simulating a long conversation (no real LLM calls)..."
54
+ puts "Scoring a synthetic two-topic conversation (no LLM calls)..."
60
55
  puts
61
56
 
62
- # Simulate a conversation history with two distinct topics:
57
+ # A conversation history with two distinct topics:
63
58
  # older turns: Ruby metaprogramming (will become irrelevant)
64
59
  # recent turns: Rails routing (current topic)
65
-
66
- require "ostruct"
60
+ #
61
+ # HistoryCompressor only reads role/content and the message predicates, so a
62
+ # small Struct stands in for RubyLLM::Message and keeps the fixture readable.
67
63
 
68
64
  FakeMsg = Struct.new(:role, :content, :tool_calls, :stop_reason) do
69
65
  def text? = true
@@ -151,13 +147,17 @@ puts " Kept roles: #{result_b.map(&:role).join(', ')}"
151
147
  puts
152
148
 
153
149
  # ---------------------------------------------------------------------------
154
- # Show the LLM summarizer pattern (not executed — requires API key)
150
+ # The in-place form on a live robot (not executed here it would spend tokens
151
+ # summarizing, and the interesting scoring behaviour is already shown above)
155
152
  # ---------------------------------------------------------------------------
156
- section "LLM Summarizer Pattern (requires API key)"
153
+ section "In-Place Form: robot.compress_history"
154
+ puts "Same algorithm, applied to a live robot's own chat history. A second,"
155
+ puts "cheaper robot does the summarizing for the medium-relevance band."
157
156
  show_code <<~RUBY
158
157
  summarizer_bot = RobotLab.build(
159
- model: "gpt-5.4",
160
158
  name: "summarizer",
159
+ provider: "ollama",
160
+ model: "qwen2.5:7b",
161
161
  system_prompt: "Summarize the following text in one sentence."
162
162
  )
163
163
 
@@ -11,13 +11,14 @@
11
11
  # - Convergence.similarity(a, b) — 0.0..1.0 cosine similarity score
12
12
  # - Convergence.detected?(a, b) — boolean above default threshold (0.85)
13
13
  # - Convergence.detected?(a, b, threshold: 0.6) — custom threshold
14
- # - Router fast-path pattern: skip reconciler when verifiers agree
14
+ # - Fast-path pattern: skip the reconciler when verifiers agree, using
15
+ # SimpleFlow optional-task activation
15
16
  #
16
17
  # Requires:
17
18
  # gem 'classifier', '~> 2.3' # add to your Gemfile
18
19
  #
19
20
  # Usage:
20
- # ANTHROPIC_API_KEY=your_key ruby examples/23_convergence.rb
21
+ # ruby examples/23_convergence.rb # no LLM calls — pure similarity scoring
21
22
 
22
23
  require_relative "common"
23
24
 
@@ -68,27 +69,33 @@ end
68
69
  # ---------------------------------------------------------------------------
69
70
  # Router fast-path pattern
70
71
  # ---------------------------------------------------------------------------
71
- section "Router Fast-Path Pattern"
72
+ section "Reconciler Fast-Path Pattern"
73
+ puts "Networks route with SimpleFlow's optional-task activation, not a router"
74
+ puts "lambda: a task declared `depends_on: :optional` stays dormant until some"
75
+ puts "earlier task activates it. A gate robot compares the two verifier replies"
76
+ puts "and only activates the expensive reconciler when they disagree."
72
77
  show_code <<~RUBY
73
- # Two verifier robots run in parallel and store their replies in shared memory.
74
- # The router checks convergence before dispatching to the expensive reconciler.
78
+ # Two verifiers run in parallel; a gate compares their replies and decides
79
+ # whether the reconciler is worth an LLM call.
75
80
 
76
- router = ->(args) do
77
- a = args.context[:verifier_a]&.reply.to_s
78
- b = args.context[:verifier_b]&.reply.to_s
81
+ class ConvergenceGate < RobotLab::Robot
82
+ def call(result)
83
+ a = result.context[:verifier_a]&.reply.to_s
84
+ b = result.context[:verifier_b]&.reply.to_s
79
85
 
80
- if RobotLab::Convergence.detected?(a, b)
81
- nil # Both agree — skip reconciler, network halts here
82
- else
83
- ["reconciler"]
86
+ return result if RobotLab::Convergence.detected?(a, b) # agree — stop here
87
+
88
+ result.activate(:reconciler) # disagree — escalate
84
89
  end
85
90
  end
86
91
 
87
- network = RobotLab.create_network(
88
- name: "fact_check",
89
- robots: [verifier_a, verifier_b, reconciler],
90
- router: router
91
- )
92
+ network = RobotLab.create_network(name: "fact_check") do
93
+ task :verifier_a, verifier_a, depends_on: :none
94
+ task :verifier_b, verifier_b, depends_on: :none
95
+ task :gate, ConvergenceGate.new(name: "gate", system_prompt: "gate"),
96
+ depends_on: %i[verifier_a verifier_b]
97
+ task :reconciler, reconciler, depends_on: :optional
98
+ end
92
99
 
93
100
  result = network.run(message: "Is this claim accurate?")
94
101
  RUBY