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
@@ -46,6 +46,10 @@ result = network.run(message: "Analyze this quarterly data")
46
46
  Tasks can have per-task configuration that is deep-merged with network run params:
47
47
 
48
48
  ```ruby
49
+ # The allowlist below matches because the tool was attached as a CLASS.
50
+ billing_robot = RobotLab.build(name: "billing", system_prompt: "...",
51
+ local_tools: [RefundTool])
52
+
49
53
  network = RobotLab.create_network(name: "support") do
50
54
  task :classifier, classifier_robot, depends_on: :none
51
55
  task :billing, billing_robot,
@@ -59,6 +63,28 @@ network = RobotLab.create_network(name: "support") do
59
63
  end
60
64
  ```
61
65
 
66
+ !!! warning "An explicit `tools:` array must match the attachment form"
67
+ `ToolConfig.filter_tools` selects with `allowed_set.include?(tool_name(tool))`,
68
+ and `tool_name` is just `tool.name.to_s`. But `Class#name` and
69
+ `RubyLLM::Tool#name` return different strings, so the entry you list has to
70
+ match how the tool was attached:
71
+
72
+ | Attached as | Allowlist entry | Result |
73
+ |-------------|-----------------|--------|
74
+ | `local_tools: [RefundTool]` (class) | `tools: [RefundTool]` → `"RefundTool"` | matches |
75
+ | `local_tools: [RefundTool]` (class) | `tools: %w[refund]` | no match |
76
+ | `local_tools: [RefundTool.new]` (instance) | `tools: [RefundTool]` | no match |
77
+ | `local_tools: [RefundTool.new]` (instance) | `tools: %w[refund]` | matches |
78
+
79
+ Both forms work; mixing them silently yields an empty tool list. Pick one
80
+ convention per robot and keep the allowlist in the same form.
81
+
82
+ A task's `tools:` is *not* validated — it lands in `run_params` and reaches
83
+ the robot as a runtime value. The robot **constructor**'s `tools:` kwarg is
84
+ validated by `validate_tools_filter!` and raises `ArgumentError` for
85
+ anything that is not a String or Symbol, so the class form shown above is
86
+ usable only at the task/`run` level.
87
+
62
88
  ### Task Parameters
63
89
 
64
90
  | Parameter | Type | Description |
@@ -69,7 +95,11 @@ end
69
95
  | `mcp` | Symbol, Array | MCP server config (`:none`, `:inherit`, or array) |
70
96
  | `tools` | Symbol, Array | Tools config (`:none`, `:inherit`, or array) |
71
97
  | `memory` | Memory, Hash, nil | Task-specific memory |
98
+ | `config` | RunConfig, nil | Per-task RunConfig, merged into the network config the robot sees (`mcp`/`tools` only in practice) |
72
99
  | `depends_on` | Symbol, Array | Dependencies (`:none`, `:optional`, or task names) |
100
+ | `poller_group` | Symbol | Bus poller group label for this robot (default `:default`; purely organizational) |
101
+
102
+ `mcp:` and `tools:` default to `:none` here. Anything other than `:none` is written into `run_params` and reaches the robot as its **runtime** value, so it is resolved against the robot's build-time config exactly as if it had been passed to `run`.
73
103
 
74
104
  ## Execution Model
75
105
 
@@ -98,28 +128,33 @@ stateDiagram-v2
98
128
  Each robot implements the SimpleFlow step interface via `call(result)`:
99
129
 
100
130
  ```ruby
101
- # Inside Robot (simplified)
131
+ # Inside Robot (simplified -- timing and the rescue are elided)
102
132
  def call(result)
103
133
  run_context = extract_run_context(result)
104
134
  message = run_context.delete(:message)
105
135
 
106
136
  robot_result = run(message, **run_context)
137
+ robot_result.duration = ... # monotonic elapsed seconds
107
138
 
108
139
  result
109
- .with_context(@name.to_sym, robot_result)
140
+ .with_context(@name.to_sym, robot_result) # keyed by the ROBOT's name
110
141
  .continue(robot_result)
111
142
  end
112
143
  ```
113
144
 
145
+ The real method also wraps the body so that any exception — including non-`StandardError` ones — is turned into a `RobotResult` whose text is `"Error: <class>: <message>"`, so one failing robot does not crash the pipeline.
146
+
114
147
  ### extract_run_context
115
148
 
116
149
  The `extract_run_context` method pulls parameters from the SimpleFlow result:
117
150
 
118
- - Extracts `:mcp`, `:tools`, `:memory`, and `:network_memory` from `run_params`
119
- - Merges the current result value into the context
120
- - If the previous result value is a `RobotResult`, extracts its `last_text_content` as the message
151
+ - Deletes `:mcp`, `:tools`, `:memory`, `:network_memory`, `:network_config`, `:network`, and `:task` out of `run_params`, then re-attaches them as explicit keyword arguments to `run`
152
+ - `:mcp` and `:tools` default to `:none` when the task did not set them — matching `run`'s own defaults
153
+ - Merges the current result value into the remaining context
154
+ - If the previous result value is a `RobotResult`, uses its `last_text_content` as the message
121
155
  - If it is a String, uses it directly as the message
122
- - If it is a Hash, merges it with the run params
156
+ - If it is a Hash, merges it into the context
157
+ - Anything else is coerced with `to_s` and used as the message
123
158
 
124
159
  ## Task#call Interface
125
160
 
@@ -128,22 +163,36 @@ Each `Task` wraps a robot and enhances the SimpleFlow result before delegation:
128
163
  ```ruby
129
164
  # Inside Task (simplified)
130
165
  def call(result)
131
- # Deep merge task context with run_params
132
- run_params = deep_merge(
133
- result.context[:run_params] || {},
134
- @context
135
- )
136
-
137
- # Add task-specific config
138
- run_params[:mcp] = @mcp unless @mcp == :none
139
- run_params[:tools] = @tools unless @tools == :none
166
+ context = TaskHookContext.new(network: @network, task: self, robot: @robot,
167
+ memory: @memory || @network&.memory, config: @config)
168
+
169
+ RobotLab::Hooks.run(:task, context, registries: [RobotLab.hooks, @network&.hooks]) do
170
+ @robot.call(enhanced_result(result))
171
+ end
172
+ end
173
+
174
+ def enhanced_result(result)
175
+ run_params = deep_merge(result.context[:run_params] || {}, @context)
176
+
177
+ run_params[:mcp] = @mcp unless @mcp == :none
178
+ run_params[:tools] = @tools unless @tools == :none
140
179
  run_params[:memory] = @memory if @memory
141
180
 
142
- enhanced_result = result.with_context(:run_params, run_params)
143
- @robot.call(enhanced_result)
181
+ # Back-references the robot needs for hooks and config resolution
182
+ run_params[:task] = self
183
+ run_params[:network] = @network if @network
184
+
185
+ if @config
186
+ network_rc = run_params[:network_config]
187
+ run_params[:network_config] = network_rc ? network_rc.merge(@config) : @config
188
+ end
189
+
190
+ result.with_context(:run_params, run_params)
144
191
  end
145
192
  ```
146
193
 
194
+ Two things to note. The `:task` hook family is dispatched against `[RobotLab.hooks, network&.hooks]` only — a handler registered with `robot.on` never fires for task hooks. And the task's own `config:` is merged into `run_params[:network_config]`, which is the value `Robot#resolve_mcp_hierarchy` / `#resolve_tools_hierarchy` consult as the parent level.
195
+
147
196
  ## SimpleFlow::Result
148
197
 
149
198
  The result object flows through the pipeline:
@@ -151,30 +200,53 @@ The result object flows through the pipeline:
151
200
  ```ruby
152
201
  result.value # Current task's output (RobotResult)
153
202
  result.context # Accumulated context from all tasks
154
- result.halted? # Whether execution stopped early
155
- result.continued? # Whether execution continues
203
+ result.continue? # Whether execution continues (the only status predicate)
156
204
  ```
157
205
 
158
206
  ### Result Methods
159
207
 
208
+ This is the complete public API of `SimpleFlow::Result` (simple_flow 0.4):
209
+
160
210
  | Method | Description |
161
211
  |--------|-------------|
212
+ | `value` | Current value flowing through the pipeline |
213
+ | `context` | Accumulated context hash |
162
214
  | `continue(value)` | Continue to next tasks |
215
+ | `continue?` | Whether the pipeline is still continuing |
163
216
  | `halt(value)` | Stop pipeline execution |
164
217
  | `with_context(key, val)` | Add data to context |
218
+ | `with_error(key, message)` | Record an error (both arguments required) |
219
+ | `errors` | Recorded errors |
165
220
  | `activate(task_name)` | Enable an optional task |
221
+ | `activated_steps` | Optional tasks that have been activated |
222
+
223
+ There is no `halted?`, no `continued?`, and no `with_value` — use `continue?` for status and `continue(value)` to set a new value.
166
224
 
167
225
  ### Context Structure
168
226
 
169
227
  ```ruby
170
228
  {
171
- run_params: { message: "...", customer_id: 123, network_memory: memory },
172
- classifier: RobotResult, # Stored by Robot#call
229
+ run_params: { message: "...", customer_id: 123,
230
+ network_memory: memory, network: network, task: task },
231
+ classifier: RobotResult, # Stored by Robot#call under the ROBOT's name
173
232
  billing: RobotResult,
174
- # ... other task results
233
+ # ... other robot results
175
234
  }
176
235
  ```
177
236
 
237
+ `Robot#call` stores its output with `result.with_context(@name.to_sym, robot_result)` — the key is the **robot's** `name`, not the task name. The two coincide only when you name them identically:
238
+
239
+ ```ruby
240
+ worker = RobotLab.build(name: "worker_bot", system_prompt: "...")
241
+
242
+ net = RobotLab.create_network(name: "n") do
243
+ task :analysis, worker, depends_on: :none
244
+ end
245
+
246
+ res = net.run(message: "hi")
247
+ res.context.keys #=> [:run_params, :worker_bot] -- not :analysis
248
+ ```
249
+
178
250
  ## Optional Task Activation
179
251
 
180
252
  Optional tasks (those with `depends_on: :optional`) do not run automatically. They must be activated by a preceding task using `result.activate(:task_name)`.
@@ -236,16 +308,37 @@ All robots in a network share the network's memory during execution. The network
236
308
 
237
309
  ```ruby
238
310
  # Inside Network#run
239
- def run(**run_context)
311
+ def run(message = nil, **run_context)
312
+ run_context[:message] = message unless message.nil? # Runnable protocol
313
+
240
314
  run_context[:network_memory] = @memory
241
- initial_result = SimpleFlow::Result.new(
242
- run_context,
243
- context: { run_params: run_context }
244
- )
245
- @pipeline.call_parallel(initial_result)
315
+ run_context[:network] = self
316
+ run_context[:network_config] = @config unless @config.empty?
317
+
318
+ context = NetworkRunHookContext.new(network: self, context: run_context,
319
+ memory: @memory, config: @config)
320
+
321
+ RobotLab::Hooks.run(:network_run, context, registries: [RobotLab.hooks, @hooks]) do
322
+ if @parallel_mode == :ractor
323
+ run_with_ractor_scheduler(context.context)
324
+ else
325
+ initial_result = SimpleFlow::Result.new(
326
+ context.context,
327
+ context: { run_params: context.context }
328
+ )
329
+ @pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
330
+ end
331
+ end
246
332
  end
247
333
  ```
248
334
 
335
+ Beyond injecting the shared memory, this does four things worth knowing:
336
+
337
+ - It puts the network itself and (when non-empty) the network's `RunConfig` into `run_params`, which is how robots find the parent level for `mcp`/`tools` resolution.
338
+ - The whole run is wrapped in the `:network_run` hook, dispatched against `[RobotLab.hooks, network.hooks]`.
339
+ - `parallel_mode: :ractor` routes to `run_with_ractor_scheduler` instead of the SimpleFlow pipeline; it raises `RobotLab::DependencyError` unless the `robot_lab-ractor` gem is loaded.
340
+ - `max_concurrent:` comes from `@config.max_concurrent_robots` — the one `RunConfig` field the network itself consumes.
341
+
249
342
  Robots use the shared memory for inter-robot communication:
250
343
 
251
344
  ```ruby
@@ -278,7 +371,7 @@ network.broadcast(event: :pause, reason: "rate limit hit")
278
371
  network.broadcast(event: :phase_complete, phase: "analysis")
279
372
  ```
280
373
 
281
- Broadcasts are dispatched asynchronously and also written to memory at the `_network_broadcast` key, so robots can subscribe via `memory.subscribe(:_network_broadcast)`.
374
+ Each handler is invoked inside an `Async { }` block, and the message is also written to memory at the `_network_broadcast` key (`Network::BROADCAST_KEY`), so robots can subscribe via `memory.subscribe(:_network_broadcast)`. Outside a running reactor, `Async { }` runs the block synchronously on the caller's thread. The message handed to a handler is `{ payload:, network:, timestamp: }`, which is why the examples above reach for `message[:payload][:event]`.
282
375
 
283
376
  ## Parallel Execution
284
377
 
@@ -300,17 +393,23 @@ end
300
393
 
301
394
  ### Concurrency Modes
302
395
 
396
+ `create_network`'s `concurrency:` is passed straight to `SimpleFlow::Pipeline`:
397
+
303
398
  | Mode | Description |
304
399
  |------|-------------|
305
- | `:auto` | SimpleFlow chooses best mode |
400
+ | `:auto` | SimpleFlow chooses best mode (default) |
306
401
  | `:threads` | Use Ruby threads |
307
402
  | `:async` | Use async/fiber |
308
403
 
404
+ The number of tasks running at once is capped by the network config's `max_concurrent_robots`, which `Network#run` passes to `call_parallel` as `max_concurrent:`.
405
+
406
+ Separately, `Network.new(parallel_mode:)` selects the execution backend. It defaults to `:async` (the SimpleFlow pipeline above). Setting `parallel_mode: :ractor` bypasses SimpleFlow entirely for a Ractor-based scheduler and requires the `robot_lab-ractor` gem — without it, `run` raises `RobotLab::DependencyError`.
407
+
309
408
  ## Data Flow
310
409
 
311
- 1. **Initial Value**: `network.run(**params)` creates an initial `SimpleFlow::Result` with the run context
312
- 2. **Run Params**: Stored in `result.context[:run_params]`
313
- 3. **Task Results**: Each task adds its `RobotResult` to context under its task name
410
+ 1. **Initial Value**: `network.run(message, **params)` creates an initial `SimpleFlow::Result` with the run context
411
+ 2. **Run Params**: Stored in `result.context[:run_params]`, including `network_memory`, `network`, and (per task) `task`
412
+ 3. **Task Results**: Each robot adds its `RobotResult` to context under **its own `name`**
314
413
  4. **Final Value**: Last task's output becomes `result.value`
315
414
 
316
415
  ```ruby
@@ -319,12 +418,15 @@ result = network.run(
319
418
  customer_id: 123
320
419
  )
321
420
 
322
- result.context[:run_params] #=> { message: "...", customer_id: 123, network_memory: ... }
323
- result.context[:classifier] #=> RobotResult from classifier
324
- result.context[:billing] #=> RobotResult from billing robot
421
+ result.context[:run_params] #=> { message: "...", customer_id: 123,
422
+ # network_memory: ..., network: ..., task: ... }
423
+ result.context[:classifier] #=> RobotResult from the robot NAMED "classifier"
424
+ result.context[:billing] #=> RobotResult from the robot NAMED "billing"
325
425
  result.value #=> Final RobotResult
326
426
  ```
327
427
 
428
+ If a robot's `name:` differs from the task name it was registered under, look it up by the robot's name.
429
+
328
430
  ## Visualization
329
431
 
330
432
  Networks provide visualization methods via the underlying SimpleFlow pipeline:
@@ -346,12 +448,13 @@ puts network.execution_plan
346
448
  ## Network Inspection
347
449
 
348
450
  ```ruby
349
- # Get a robot by name
451
+ # Get a robot by its registration key
350
452
  network.robot(:classifier) #=> Robot
351
453
  network[:classifier] #=> Robot (alias)
352
454
 
353
455
  # List all robots
354
456
  network.available_robots #=> [Robot, Robot, ...]
457
+ network.crew #=> same array, via the Runnable protocol
355
458
 
356
459
  # Add a robot without a task
357
460
  network.add_robot(extra_robot)
@@ -362,9 +465,13 @@ network.remove_robot(:extra_robot)
362
465
 
363
466
  # Convert to hash
364
467
  network.to_h
365
- #=> { name: "support", robots: ["classifier", "billing"], tasks: [...], optional_tasks: [...] }
468
+ #=> { name: "support", robots: ["classifier", "billing"],
469
+ # tasks: ["classifier", "billing"], optional_tasks: [],
470
+ # config: { ... } } # :config present only when the network config is non-empty
366
471
  ```
367
472
 
473
+ The `@robots` hash is keyed **by task name** for robots registered with `task`, but **by `robot.name`** for robots added with `add_robot`. So for a robot built as `name: "worker_bot"` and registered as `task :analysis, worker`, `network.robot(:analysis)` returns it and `network.robot(:worker_bot)` returns `nil` — the opposite of how `result.context` is keyed. `to_h[:robots]` therefore lists task names for task-registered robots.
474
+
368
475
  ## Next Steps
369
476
 
370
477
  - [Memory Management](state-management.md) - Shared memory and reactive features