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.
- checksums.yaml +4 -4
- data/README.md +124 -64
- data/docs/api/core/index.md +41 -15
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +255 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +551 -61
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +110 -17
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +80 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +79 -31
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +145 -38
- data/docs/architecture/robot-execution.md +172 -90
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +284 -97
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +258 -50
- data/docs/guides/creating-networks.md +214 -30
- data/docs/guides/hooks.md +141 -54
- data/docs/guides/knowledge.md +35 -4
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +95 -47
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +237 -17
- data/docs/index.md +36 -4
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +12 -7
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +18 -8
- data/examples/README.md +199 -45
- data/examples/common.rb +79 -11
- data/examples/xyzzy.rb +8 -1
- data/lib/robot_lab/config.rb +10 -5
- data/lib/robot_lab/names.rb +402 -0
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/bus_messaging.rb +16 -8
- data/lib/robot_lab/robot/template_rendering.rb +16 -3
- data/lib/robot_lab/robot.rb +23 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +21 -15
- data/mkdocs.yml +6 -1
- 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
|
|
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->>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Robot->>
|
|
22
|
-
Robot->>
|
|
23
|
-
Robot->>
|
|
24
|
-
|
|
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
|
|
42
|
+
loop Tool loop (inside :llm_generation)
|
|
28
43
|
LLM-->>Chat: tool_call response
|
|
29
|
-
Chat->>
|
|
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
|
|
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
|
-
|
|
70
|
+
`Robot#run` first resolves the memory this run will write to, via `resolve_run_memory`:
|
|
44
71
|
|
|
45
72
|
```ruby
|
|
46
|
-
#
|
|
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
|
-
#
|
|
79
|
+
# A runtime memory: kwarg either replaces (Memory) or merges into (Hash) it
|
|
53
80
|
case memory
|
|
54
|
-
when Memory then
|
|
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
|
-
#
|
|
68
|
-
parent_value =
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
133
|
+
Tools are resolved through the same hierarchy, filtered, capped, and applied to the chat:
|
|
101
134
|
|
|
102
135
|
```ruby
|
|
103
|
-
#
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
145
|
-
@chat.on_tool_call(&@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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 # => [
|
|
183
|
-
result.stop_reason # =>
|
|
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 |
|
|
202
|
-
print
|
|
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
|
|
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.
|
|
217
|
-
# (
|
|
218
|
-
|
|
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.
|
|
221
|
-
|
|
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.
|
|
224
|
-
@chat.with_instructions(
|
|
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
|
|
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` |
|
|
236
|
-
| `top_k` |
|
|
237
|
-
| `max_tokens` |
|
|
238
|
-
| `presence_penalty` |
|
|
239
|
-
| `frequency_penalty` |
|
|
240
|
-
| `stop` |
|
|
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`.
|
|
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.
|
|
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
|
|
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
|
|
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 #
|
|
241
|
-
result.tool_calls # Array<ToolResultMessage> -
|
|
242
|
-
result.stop_reason #
|
|
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.
|
|
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
|
-
#
|
|
347
|
+
# Try Redis, fall back to Hash
|
|
338
348
|
memory = Memory.new(backend: :auto)
|
|
339
349
|
|
|
340
|
-
#
|
|
350
|
+
# Hash backend — the only value that is honoured literally
|
|
341
351
|
memory = Memory.new(backend: :hash)
|
|
342
352
|
|
|
343
|
-
#
|
|
353
|
+
# Same behaviour as :auto — try Redis, fall back to Hash
|
|
344
354
|
memory = Memory.new(backend: :redis)
|
|
345
355
|
|
|
346
|
-
# Check
|
|
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
|