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
@@ -9,66 +9,95 @@ When you call `robot.run("message")`, several steps occur:
9
9
  ```mermaid
10
10
  sequenceDiagram
11
11
  participant App as Application
12
- participant Robot
12
+ participant Skills as AgentSkillMatching<br/>(prepended)
13
+ participant Robot as Robot (Hooking run)
14
+ participant Hooks as Hooks / HookRegistry
13
15
  participant Memory
16
+ participant Ledger as Budget::Ledger
14
17
  participant Chat as @chat (RubyLLM)
15
18
  participant LLM
16
19
 
17
- App->>Robot: run("message")
18
- Robot->>Memory: resolve_active_memory()
19
- Robot->>Robot: resolve_mcp_hierarchy()
20
- Robot->>Robot: resolve_tools_hierarchy()
21
- Robot->>Robot: ensure_mcp_clients()
22
- Robot->>Robot: filtered_tools()
23
- Robot->>Chat: with_tools(*filtered)
24
- Robot->>Chat: ask("message")
20
+ App->>Skills: run("message")
21
+ Skills->>Skills: match_agent_skills + inject (prompt & script tools)
22
+ Skills->>Robot: super
23
+
24
+ Robot->>Memory: resolve_run_memory()
25
+ Robot->>Memory: current_writer = name
26
+ Robot->>Hooks: Hooks.run(:run, RunHookContext)
27
+ Note over Hooks: before_run / around_run wrap everything below
28
+
29
+ Robot->>Robot: prepare_tools() — resolve_mcp_hierarchy,<br/>ensure_mcp_clients, resolve_tools_hierarchy,<br/>filtered_tools + cap_tools
30
+ Robot->>Chat: with_tools(*filtered, replace: true)
31
+ Robot->>Robot: rerender_template(kwargs) — when template + extra kwargs
32
+ Robot->>Ledger: reserve_budget! (raises BudgetExceeded if exhausted)
33
+
34
+ Robot->>Hooks: Hooks.run(:llm_generation, LlmGenerationHookContext)
35
+ Robot->>Robot: inject_learnings(request)
36
+ Robot->>Robot: maybe_compact — fires :compaction family
37
+ Robot->>Chat: install_circuit_breaker (when max_tool_rounds)
38
+ Robot->>Chat: install_doom_loop_detection (always)
39
+ Robot->>Chat: ask(message, &streaming_block)
25
40
  Chat->>LLM: API Request
26
41
 
27
- loop Tool Calls
42
+ loop Tool loop (inside :llm_generation)
28
43
  LLM-->>Chat: tool_call response
29
- Chat->>Chat: execute tool
44
+ Chat->>Hooks: Tool.call — Hooks.run(:tool_call)
30
45
  Chat->>LLM: tool result
31
46
  end
32
47
 
33
48
  LLM-->>Chat: final response
34
49
  Chat-->>Robot: RubyLLM::Response
35
- Robot->>Robot: build_result(response)
36
- Robot-->>App: RobotResult
50
+ Robot->>Robot: build_result(response) — result_text + token accounting
51
+ Robot->>Ledger: reconcile_budget!
52
+ Robot->>Robot: enforce_token_budget! / enforce_cost_budget!<br/>(raise InferenceError on overage)
53
+ Robot->>Hooks: after_run
54
+ Note over Robot,Memory: ensure: remove_doom_loop_detection,<br/>restore_tool_call_callback, restore current_writer
55
+ Robot-->>Skills: RobotResult
56
+ Skills->>Skills: ensure: restore_after_agent_skills
57
+ Skills-->>App: RobotResult
37
58
  ```
38
59
 
60
+ The steps are described individually below. Two things this diagram makes
61
+ explicit that the prose repeats: `Robot::AgentSkillMatching` is **prepended**, so
62
+ its `run` wraps `Robot::Hooking#run` (which is the `run` everything else calls);
63
+ and the `:llm_generation` family wraps the provider's *entire* tool loop, so
64
+ `:tool_call` hooks fire nested inside it rather than between generations.
65
+
39
66
  ## Step-by-Step Flow
40
67
 
41
68
  ### 1. Memory Resolution
42
69
 
43
- The robot determines which memory to use for this run:
70
+ `Robot#run` first resolves the memory this run will write to, via `resolve_run_memory`:
44
71
 
45
72
  ```ruby
46
- # Priority order:
73
+ # resolve_active_memory priority order:
47
74
  # 1. Explicit network_memory: parameter
48
75
  # 2. Network's memory (if running in a network)
49
76
  # 3. Robot's inherent @memory (standalone mode)
50
77
  run_memory = resolve_active_memory(network: network, network_memory: network_memory)
51
78
 
52
- # Merge runtime memory if provided
79
+ # A runtime memory: kwarg either replaces (Memory) or merges into (Hash) it
53
80
  case memory
54
- when Memory then run_memory = memory
55
- when Hash then run_memory.merge!(memory)
81
+ when Memory then memory
82
+ when Hash then run_memory.tap { |m| m.merge!(memory) }
83
+ else run_memory
56
84
  end
57
-
58
- # Track who is writing to memory
59
- run_memory.current_writer = @name
60
85
  ```
61
86
 
87
+ `run` then sets `run_memory.current_writer = @name` for the duration of the run and restores the previous writer in an `ensure` block, so subscription callbacks always see the robot that actually wrote a value.
88
+
89
+ The whole run body is wrapped in `RobotLab::Hooks.run(:run, context, ...)` against the registries `[RobotLab.hooks, network&.hooks, @hooks]`.
90
+
62
91
  ### 2. MCP Hierarchy Resolution
63
92
 
64
- MCP servers are resolved through a hierarchy: **runtime > robot build-time > network > global config**.
93
+ MCP servers and tools are resolved together in `prepare_tools`, through a hierarchy: **runtime (run/task) > robot build-time > network > global config**.
65
94
 
66
95
  ```ruby
67
- # Resolve build-time config against network/global
68
- parent_value = network&.network&.mcp || RobotLab.config.mcp
96
+ # Parent value: task/network RunConfig, then the network object, then global config
97
+ parent_value = network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp
69
98
  build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
70
99
 
71
- # Then resolve runtime override against build-time
100
+ # Then resolve the runtime override against the build-time value
72
101
  resolved_mcp = ToolConfig.resolve_mcp(runtime_mcp, parent_value: build_resolved)
73
102
  ```
74
103
 
@@ -78,50 +107,66 @@ Values at each level:
78
107
  - `:inherit` -- use parent level's MCP config
79
108
  - `Array` -- explicit list of server configurations
80
109
 
110
+ `run`'s default for `mcp:` is `:none`, so a plain `robot.run("...")` resolves to an empty server list and connects nothing, even when `mcp:` was supplied at build time. Pass `mcp: :inherit` to trigger the connection. (`robot.connect_mcp!` connects eagerly against the build-time config, which is useful for reporting connection status at startup.)
111
+
81
112
  ### 3. MCP Client Initialization
82
113
 
83
114
  If MCP servers need to be connected (or reconnected), the robot initializes clients:
84
115
 
85
116
  ```ruby
86
- # Connect to each MCP server
87
- mcp_servers.each do |server_config|
88
- client = MCP::Client.new(server_config)
89
- client.connect
90
-
91
- if client.connected?
92
- @mcp_clients[client.server.name] = client
93
- discover_mcp_tools(client, server_name) # Auto-discover tools
94
- end
117
+ # Connect to each MCP server (ensure_mcp_clients -> init_mcp_client)
118
+ client = MCP::Client.new(server_config) # config is POSITIONAL
119
+ client.connect
120
+
121
+ if client.connected?
122
+ @mcp_clients[client.server.name] = client
123
+ discover_mcp_tools(client, server_name) # each remote tool becomes a Tool.create wrapper
124
+ else
125
+ @failed_mcp_configs[server_name] = server_config
95
126
  end
96
127
  ```
97
128
 
129
+ Connection failures are logged and recorded, never raised — they surface through `robot.failed_mcp_server_names`. A later run retries only the servers that are still needed and still failed.
130
+
98
131
  ### 4. Tools Resolution
99
132
 
100
- Tools are resolved through the same hierarchy and filtered:
133
+ Tools are resolved through the same hierarchy, filtered, capped, and applied to the chat:
101
134
 
102
135
  ```ruby
103
- # Collect all available tools
104
- available = @local_tools + @mcp_tools
105
-
106
- # Apply whitelist if specified
107
- filtered = ToolConfig.filter_tools(available, allowed_names: resolved_tools)
136
+ # Explicit :none (or a literal []) means "send zero tools this turn"; otherwise
137
+ # filter the local + MCP tools by the resolved allowlist and clamp to max_tools.
138
+ filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
108
139
 
109
- # Apply tools to the persistent chat
110
- @chat.with_tools(*filtered) if filtered.any?
140
+ @chat.with_tools(*filtered, replace: true) if filtered.any? || explicit_none_tools?(tools)
111
141
  ```
112
142
 
143
+ Two details here are load-bearing:
144
+
145
+ - **`replace: true`** — RubyLLM's `with_tools` appends by default. On a persistent chat that would let tools accumulate across turns, so the chat is made to hold exactly this turn's resolved set. An explicit `:none` therefore clears the chat's tools to zero.
146
+ - **`cap_tools`** — the resolved list is clamped to `RunConfig#max_tools`, which defaults to `DEFAULT_MAX_TOOLS` (128) because most providers reject longer tool arrays. Setting `max_tools` to nil, 0, or a negative number falls back to 128; the cap cannot be disabled. Dropped tools are logged at `warn`.
147
+
148
+ Because `run` defaults to `tools: :none`, a plain `robot.run("...")` sends the LLM no tools at all. Pass `tools: :inherit` to send the robot's attached tools; pass an explicit array of names to use it as an allowlist.
149
+
113
150
  ### 5. LLM Inference
114
151
 
115
- The message is sent to the LLM via `Agent#ask`, which delegates to `@chat.ask`:
152
+ The message is sent to the LLM via `Agent#ask`, which delegates to `@chat.ask`. `invoke_ask` wraps that call in the `:llm_generation` hook and installs the per-run guards first:
116
153
 
117
154
  ```ruby
118
- # Robot#run calls Agent#ask
119
- response = ask(message, **kwargs)
120
-
121
- # Internally, Agent#ask calls:
122
- # @chat.ask(message)
155
+ RobotLab::Hooks.run(:llm_generation, generation_context, registries: ..., per_run_hooks: hooks) do
156
+ effective_message = inject_learnings(generation_context.request)
157
+ maybe_compact(network: context.network)
158
+ install_circuit_breaker if @config.max_tool_rounds
159
+ install_doom_loop_detection
160
+ ask(effective_message, **kwargs.slice(:with), &effective_streaming_block(block))
161
+ end
123
162
  ```
124
163
 
164
+ Notes on the surrounding behavior:
165
+
166
+ - `:llm_generation` fires **exactly once per `robot.run`** — the provider's tool loop happens inside the block, not once per API call.
167
+ - Only `:with` from `run`'s keyword arguments reaches `ask`. Every other unrecognized keyword is treated as template re-render context.
168
+ - Doom-loop detection is installed unconditionally on every run; `doom_loop_threshold:` (default 3) only tunes it.
169
+
125
170
  The persistent `@chat` (a `RubyLLM::Chat` instance) handles:
126
171
 
127
172
  - Maintaining conversation history
@@ -138,38 +183,41 @@ RubyLLM's `@chat` handles the tool loop automatically. When the LLM requests a t
138
183
  3. Sends the result back to the LLM
139
184
  4. Repeats until the LLM produces a final text response
140
185
 
141
- The `on_tool_call` and `on_tool_result` callbacks fire during this loop if configured:
186
+ The `on_tool_call` and `on_tool_result` callbacks fire during this loop if configured. They are read off the effective `RunConfig` and registered on `@chat` during `Robot#initialize`:
142
187
 
143
188
  ```ruby
144
- # These callbacks are registered on @chat during Robot#initialize
145
- @chat.on_tool_call(&@on_tool_call) if @on_tool_call
146
- @chat.on_tool_result(&@on_tool_result) if @on_tool_result
189
+ def register_chat_callbacks
190
+ @chat.on_tool_call(&@on_tool_call) if @on_tool_call
191
+ @chat.on_tool_result(&@on_tool_result) if @on_tool_result
192
+ setup_bus_channel if @bus
193
+ end
147
194
  ```
148
195
 
196
+ When `max_tool_rounds` is set, `install_circuit_breaker` temporarily wraps `on_tool_call` for the duration of the run and raises `ToolLoopError` once the round count is exceeded; the original callback is restored in `run`'s `ensure` block.
197
+
149
198
  ### 7. Result Construction
150
199
 
151
- After the LLM responds, a `RobotResult` is built:
200
+ After the LLM responds, `build_result` turns the response into a `RobotResult`:
152
201
 
153
202
  ```ruby
154
203
  def build_result(response, _memory)
155
- output = if response.respond_to?(:content) && response.content
156
- [TextMessage.new(role: 'assistant', content: response.content)]
157
- else
158
- []
159
- end
160
-
161
- tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
162
-
163
- RobotResult.new(
164
- robot_name: @name,
165
- output: output,
166
- tool_calls: normalize_tool_calls(tool_calls),
167
- stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
168
- raw: response
169
- )
204
+ text = result_text(response)
205
+ output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
206
+ # ... reads response.tokens (or input_tokens/output_tokens), accumulates them
207
+ # into the robot's running totals, and passes them to RobotResult
170
208
  end
171
209
  ```
172
210
 
211
+ `result_text` is deliberately more forgiving than `response.content`. It falls back, in order, to:
212
+
213
+ 1. `response.content`, when present and non-empty
214
+ 2. `response.thinking` — Ollama routes some models' (e.g. qwen3) entire output through `reasoning_content`, which RubyLLM surfaces as `thinking`
215
+ 3. the most recent non-empty assistant message in `@chat.messages`, scoped to messages *after* the last user message, for models that end a turn on a tool call with no trailing text
216
+
217
+ Scoping the history fallback to the current turn is what prevents a previous turn's answer from being returned when a thinking-mode model emits nothing in `content`.
218
+
219
+ `build_result` also does token accounting: it reads `response.tokens` (falling back to `input_tokens`/`output_tokens`), adds them to the robot's running totals, and stores them on the result. `run` then reconciles the budget reservation and enforces `token_budget` / `cost_budget`, raising **`RobotLab::InferenceError`** when this call's actual usage pushed cumulative usage over budget. (`RobotLab::BudgetExceeded` is the *other* budget error: it is raised by `reserve_budget!` **before** the call, when a prior call already exhausted the dimension. See [Budgets](../guides/observability.md#budgets-token-cost).)
220
+
173
221
  ## RobotResult
174
222
 
175
223
  The result object from a `robot.run` call:
@@ -178,12 +226,15 @@ The result object from a `robot.run` call:
178
226
  result = robot.run("Hello!")
179
227
 
180
228
  result.robot_name # => "assistant"
181
- result.output # => [TextMessage, ...]
182
- result.tool_calls # => [ToolResultMessage, ...]
183
- result.stop_reason # => "stop" or nil
229
+ result.output # => [TextMessage] synthesized from result_text
230
+ result.tool_calls # => [] in practice (see below)
231
+ result.stop_reason # => nil always (see below)
184
232
  result.created_at # => Time
185
233
  result.id # => UUID string
186
234
  result.duration # => Float or nil (elapsed seconds, set in pipeline execution)
235
+ result.input_tokens # => Integer
236
+ result.output_tokens # => Integer
237
+ result.checksum # => "sha256-hex"
187
238
  result.raw # => raw LLM response object
188
239
 
189
240
  # Convenience methods
@@ -191,19 +242,33 @@ result.last_text_content # => "Hi there!" (last text message content)
191
242
  result.reply # => alias for last_text_content
192
243
  result.has_tool_calls? # => false
193
244
  result.stopped? # => true
245
+ result.export # => Hash (excludes debug fields)
246
+ result.to_h / result.to_json
194
247
  ```
195
248
 
249
+ `stop_reason` is always `nil`. `build_result` assigns it with `response.respond_to?(:stop_reason) ? response.stop_reason : nil`, and `RubyLLM::Message` does not define `stop_reason`, so no `robot.run` result ever reports one. It is dropped from `export` by the `.compact`, and `stopped?` therefore reduces to `!has_tool_calls?`.
250
+
251
+ `output` is a single synthesized `TextMessage`, not a transcript of the turn, and `tool_calls` is read off the final assistant message — which no longer carries tool calls once RubyLLM's tool loop has finished, so it is effectively always empty. Observe tool usage through the `on_tool_call`/`on_tool_result` callbacks or the tool hooks instead.
252
+
196
253
  ## Streaming
197
254
 
198
- Robots support streaming by passing a block to `run`:
255
+ Robots support streaming by passing a block to `run`. The block receives a `RubyLLM::Chunk`; its text is in `content`:
199
256
 
200
257
  ```ruby
201
- result = robot.run("Tell me a story") do |event|
202
- print event.text if event.respond_to?(:text)
258
+ result = robot.run("Tell me a story") do |chunk|
259
+ print chunk.content
203
260
  end
204
261
  ```
205
262
 
206
- The block is forwarded to `Agent#ask` which passes it to `@chat.ask`. Streaming events are provider-specific but typically include text deltas.
263
+ `effective_streaming_block` merges the block with any stored `on_content:` callback. If both are present, both fire — the stored `on_content` first, then the runtime block. The merged block is forwarded to `Agent#ask`, which passes it to `@chat.ask`.
264
+
265
+ ```ruby
266
+ # on_content fires on every run, no block required
267
+ robot = RobotLab.build(name: "bot", system_prompt: "...",
268
+ on_content: ->(chunk) { print chunk.content })
269
+ ```
270
+
271
+ `on_content` is read from the robot's own config at construction — a network-level `config:` does not supply it.
207
272
 
208
273
  ## Template Resolution
209
274
 
@@ -213,31 +278,45 @@ When a robot has a `template:`, it is resolved during initialization:
213
278
  # 1. Parse the template via prompt_manager
214
279
  parsed = PM.parse(@template)
215
280
 
216
- # 2. Extract and apply front matter config
217
- # (model, temperature, top_p, etc.)
218
- apply_front_matter_config(parsed.metadata)
281
+ # 2. Apply the non-LLM front matter keys: robot_name, description, tools, mcp
282
+ # (constructor-provided values win, so these only fill in gaps)
283
+ apply_front_matter_extras(parsed.metadata)
219
284
 
220
- # 3. Render the template body with context
221
- rendered = parsed.to_s(**resolved_context)
285
+ # 3. Turn the LLM keys into a RunConfig. Front matter is the BASE;
286
+ # @config (constructor kwargs) merges over it and wins.
287
+ effective = RunConfig.from_front_matter(parsed.metadata).merge(@config)
288
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
222
289
 
223
- # 4. Set as system instructions on @chat
224
- @chat.with_instructions(rendered)
290
+ # 4. Render the template body with context and set it as system instructions
291
+ @chat.with_instructions(parsed.to_s(**resolved_context))
225
292
  ```
226
293
 
294
+ If required parameters are missing at build time, rendering is skipped and deferred: the first `run` that supplies context calls `rerender_template`, which re-renders and re-appends the inline `system_prompt`. When `skills:` are in play, each skill's body and front matter are accumulated the same way and the bodies are joined before being set as one instructions block.
295
+
227
296
  ### Front Matter Config Keys
228
297
 
229
- Templates can configure the chat via YAML front matter:
298
+ Templates parse these LLM keys into a `RunConfig`:
230
299
 
231
300
  | Key | Effect |
232
301
  |-----|--------|
233
302
  | `model` | Sets the LLM model |
234
303
  | `temperature` | Sets randomness |
235
- | `top_p` | Sets nucleus sampling |
236
- | `top_k` | Sets top-k sampling |
237
- | `max_tokens` | Sets max response tokens |
238
- | `presence_penalty` | Sets presence penalty |
239
- | `frequency_penalty` | Sets frequency penalty |
240
- | `stop` | Sets stop sequences |
304
+ | `top_p` | Parsed, but **not applied** — see below |
305
+ | `top_k` | Parsed, but **not applied** |
306
+ | `max_tokens` | Parsed, but **not applied** |
307
+ | `presence_penalty` | Parsed, but **not applied** |
308
+ | `frequency_penalty` | Parsed, but **not applied** |
309
+ | `stop` | Parsed, but **not applied** |
310
+
311
+ `RunConfig#apply_to` dispatches `chat.with_<field>` guarded by `respond_to?`. `RubyLLM::Chat` only defines `with_model` and `with_temperature`, so the other six are silently dropped when they come from front matter. To set them, use constructor kwargs or a `config:` RunConfig — that path goes through `apply_chat_params` → `with_params` and does take effect:
312
+
313
+ ```ruby
314
+ RobotLab.build(name: "bot", template: :report, max_tokens: 2000, top_p: 0.3)
315
+ ```
316
+
317
+ Front matter keys that are not LLM fields — `description`, `robot_name`, `tools`, `mcp`, `skills`, `parameters` — all work.
318
+
319
+ Template bodies render with ERB: use `<%= var %>`. `{{ var }}` is not interpolated and passes through verbatim.
241
320
 
242
321
  ## Model Selection
243
322
 
@@ -269,6 +348,7 @@ sequenceDiagram
269
348
  participant Chat as @chat
270
349
 
271
350
  SF->>Task: call(result)
351
+ Task->>Task: Hooks.run(:task, ...)
272
352
  Task->>Task: deep_merge(run_params, task_context)
273
353
  Task->>Robot: call(enhanced_result)
274
354
  Robot->>Robot: extract_run_context(result)
@@ -279,7 +359,9 @@ sequenceDiagram
279
359
  Robot-->>SF: result.continue(robot_result)
280
360
  ```
281
361
 
282
- The `Task` wrapper deep-merges per-task configuration (context, mcp, tools) before delegating to the robot's `call`. The base `Robot#call` extracts the message, calls `run`, and records the elapsed time in `RobotResult#duration`. If the robot raises any exception, the error is caught and wrapped in a `RobotResult` so one failing robot does not crash the entire pipeline:
362
+ The `Task` wrapper runs the `:task` hook family and deep-merges its per-task configuration (context, mcp, tools, memory, config) into `run_params` before delegating to the robot's `call`. Note that a task's `mcp:`/`tools:` land in `run_params` and are therefore consumed by the robot as *runtime* values, not as a separate tier between the network and the robot.
363
+
364
+ The base `Robot#call` extracts the message, calls `run`, and records the elapsed time in `RobotResult#duration`. Because the result is stored under `@name.to_sym`, the pipeline context is keyed by the **robot's** name, not the task name. If the robot raises any exception, the error is caught and wrapped in a `RobotResult` so one failing robot does not crash the entire pipeline:
283
365
 
284
366
  ```ruby
285
367
  def call(result)
@@ -1,6 +1,6 @@
1
1
  # Memory Management
2
2
 
3
- Memory in RobotLab is a reactive key-value store that provides persistent storage for runtime data, conversation history, and arbitrary user-defined values. It replaces the old `State` class with a unified system that supports both standalone robot usage and shared network execution.
3
+ Memory in RobotLab is a reactive key-value store that provides persistent storage for runtime data, conversation history, and arbitrary user-defined values. A single unified `Memory` class serves both standalone robot usage and shared network execution.
4
4
 
5
5
  ## Memory Structure
6
6
 
@@ -41,6 +41,8 @@ result2 = robot.run("What are my preferences?")
41
41
  robot.reset_memory
42
42
  ```
43
43
 
44
+ `reset_memory` resets only the key-value store. It does **not** touch the robot's chat history — that is `robot.clear_messages(keep_system: true)`. The two are independent.
45
+
44
46
  ## Network Shared Memory
45
47
 
46
48
  When robots execute within a network, they share the network's memory instead of using their own inherent memory. This enables inter-robot communication.
@@ -116,7 +118,7 @@ memory.data.category #=> "billing" (method-style via StateProxy)
116
118
 
117
119
  memory.results #=> []
118
120
  memory.session_id #=> nil
119
- memory.cache #=> RubyLLM::SemanticCache
121
+ memory.cache #=> RubyLLM::SemanticCache (the module itself), or nil when enable_cache: false
120
122
  ```
121
123
 
122
124
  ## StateProxy
@@ -141,7 +143,7 @@ Memory supports pub/sub semantics where robots can subscribe to key changes and
141
143
 
142
144
  ### Setting Values
143
145
 
144
- Use `memory.set(key, value)` to write a value and notify subscribers asynchronously:
146
+ Use `memory.set(key, value)` to write a value, wake any waiters, and notify subscribers. Subscriber callbacks are dispatched through `Async { }`: inside a running reactor they run as fibers, but **outside** one they run synchronously on the writer's thread.
145
147
 
146
148
  ```ruby
147
149
  memory.set(:sentiment, { score: 0.8, confidence: 0.95 })
@@ -164,7 +166,7 @@ memory.get(:sentiment)
164
166
  # Block indefinitely until value exists
165
167
  memory.get(:sentiment, wait: true)
166
168
 
167
- # Block up to 30 seconds, raise AwaitTimeout if exceeded
169
+ # Block up to 30 seconds, then raise RobotLab::AwaitTimeout
168
170
  memory.get(:sentiment, wait: 30)
169
171
 
170
172
  # Wait for multiple keys at once
@@ -172,6 +174,11 @@ results = memory.get(:sentiment, :entities, :keywords, wait: 60)
172
174
  #=> { sentiment: {...}, entities: [...], keywords: [...] }
173
175
  ```
174
176
 
177
+ Two things to be careful with:
178
+
179
+ - On expiry a blocking read **raises** `RobotLab::AwaitTimeout`; it does not return `nil`. Rescue it if a missing value is acceptable.
180
+ - With multiple keys the timeout is applied **per missing key**, not to the call as a whole. `get_multiple` waits on the missing keys one at a time in order, so the call raises as soon as the *first* one expires — it does not spend the full 60 s on each. The 180 s worst case is only reached when the earlier keys arrive just before their own deadlines.
181
+
175
182
  ### Subscriptions
176
183
 
177
184
  Subscribe to key changes with async callbacks. The callback receives a `MemoryChange` object:
@@ -237,11 +244,12 @@ Each `RobotResult` contains:
237
244
 
238
245
  ```ruby
239
246
  result.robot_name # Which robot produced this
240
- result.output # Array<Message> - response content
241
- result.tool_calls # Array<ToolResultMessage> - tools called
242
- result.stop_reason # Stop reason from LLM
247
+ result.output # [TextMessage] synthesized from the final response text
248
+ result.tool_calls # Array<ToolResultMessage> - effectively always empty (see Message Flow)
249
+ result.stop_reason # always nil for a robot.run result (see Message Flow)
243
250
  result.last_text_content # Convenience: last text content string
244
- result.has_tool_calls? # Whether any tools were called
251
+ result.input_tokens # Prompt tokens for the run
252
+ result.output_tokens # Completion tokens for the run
245
253
  result.created_at # When it was created
246
254
  ```
247
255
 
@@ -307,6 +315,8 @@ json = memory.to_json
307
315
  memory = Memory.from_hash(hash)
308
316
  ```
309
317
 
318
+ `to_h` is compacted, so nil-valued reserved keys (a missing `session_id`, for instance) are omitted entirely rather than serialized as `nil`.
319
+
310
320
  ## Semantic Cache
311
321
 
312
322
  Memory includes a semantic cache via `RubyLLM::SemanticCache` that reduces costs and latency by returning cached responses for semantically equivalent queries:
@@ -334,19 +344,28 @@ robot = RobotLab.build(name: "bot", system_prompt: "...", enable_cache: false)
334
344
  Memory defaults to a Hash-based backend but can use Redis for distributed scenarios:
335
345
 
336
346
  ```ruby
337
- # Auto-detect (uses Redis if available, falls back to Hash)
347
+ # Try Redis, fall back to Hash
338
348
  memory = Memory.new(backend: :auto)
339
349
 
340
- # Force Hash backend
350
+ # Hash backend — the only value that is honoured literally
341
351
  memory = Memory.new(backend: :hash)
342
352
 
343
- # Force Redis backend
353
+ # Same behaviour as :auto — try Redis, fall back to Hash
344
354
  memory = Memory.new(backend: :redis)
345
355
 
346
- # Check backend
356
+ # Check what you actually got
347
357
  memory.redis? #=> true/false
348
358
  ```
349
359
 
360
+ !!! note "`backend: :redis` does not force Redis"
361
+ `select_backend` special-cases only `:hash`; every other value (`:redis`,
362
+ `:auto`, anything else) takes the same branch —
363
+ `create_redis_backend || create_hash_backend`. `create_redis_backend`
364
+ returns `nil` when `redis` is unavailable and rescues `StandardError` to
365
+ `nil` on a connection failure, so an unreachable Redis silently downgrades
366
+ to a Hash with no exception and no warning. Always check `memory.redis?` if
367
+ the distinction matters.
368
+
350
369
  Redis is configured via `RobotLab.config.redis` or the `REDIS_URL` environment variable.
351
370
 
352
371
  ## Best Practices