robot_lab 0.0.8 → 0.0.11

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +106 -4
  4. data/Rakefile +2 -1
  5. data/docs/api/core/robot.md +336 -1
  6. data/docs/api/mcp/client.md +1 -0
  7. data/docs/api/mcp/server.md +27 -8
  8. data/docs/api/mcp/transports.md +21 -6
  9. data/docs/architecture/core-concepts.md +1 -1
  10. data/docs/architecture/robot-execution.md +20 -2
  11. data/docs/concepts.md +4 -0
  12. data/docs/guides/building-robots.md +18 -0
  13. data/docs/guides/creating-networks.md +39 -0
  14. data/docs/guides/index.md +10 -0
  15. data/docs/guides/knowledge.md +182 -0
  16. data/docs/guides/mcp-integration.md +180 -2
  17. data/docs/guides/memory.md +2 -0
  18. data/docs/guides/observability.md +486 -0
  19. data/docs/guides/ractor-parallelism.md +364 -0
  20. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  21. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  22. data/examples/14_rusty_circuit/.gitignore +1 -0
  23. data/examples/14_rusty_circuit/open_mic.rb +1 -1
  24. data/examples/19_token_tracking.rb +128 -0
  25. data/examples/20_circuit_breaker.rb +153 -0
  26. data/examples/21_learning_loop.rb +164 -0
  27. data/examples/22_context_compression.rb +179 -0
  28. data/examples/23_convergence.rb +137 -0
  29. data/examples/24_structured_delegation.rb +150 -0
  30. data/examples/25_history_search/conversation.jsonl +30 -0
  31. data/examples/25_history_search.rb +136 -0
  32. data/examples/26_document_store/api_versioning_adr.md +52 -0
  33. data/examples/26_document_store/incident_postmortem.md +46 -0
  34. data/examples/26_document_store/postgres_runbook.md +49 -0
  35. data/examples/26_document_store/redis_caching_guide.md +48 -0
  36. data/examples/26_document_store/sidekiq_guide.md +51 -0
  37. data/examples/26_document_store.rb +147 -0
  38. data/examples/27_incident_response/incident_response.rb +244 -0
  39. data/examples/28_mcp_discovery.rb +112 -0
  40. data/examples/29_ractor_tools.rb +243 -0
  41. data/examples/30_ractor_network.rb +256 -0
  42. data/examples/README.md +136 -0
  43. data/examples/prompts/skill_with_mcp_test.md +9 -0
  44. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  45. data/examples/prompts/skill_with_tools_test.md +6 -0
  46. data/lib/robot_lab/bus_poller.rb +149 -0
  47. data/lib/robot_lab/convergence.rb +69 -0
  48. data/lib/robot_lab/delegation_future.rb +93 -0
  49. data/lib/robot_lab/document_store.rb +155 -0
  50. data/lib/robot_lab/error.rb +25 -0
  51. data/lib/robot_lab/history_compressor.rb +205 -0
  52. data/lib/robot_lab/mcp/client.rb +23 -9
  53. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  54. data/lib/robot_lab/mcp/server.rb +26 -3
  55. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  56. data/lib/robot_lab/mcp/transports/base.rb +10 -2
  57. data/lib/robot_lab/mcp/transports/stdio.rb +58 -26
  58. data/lib/robot_lab/memory.rb +103 -6
  59. data/lib/robot_lab/network.rb +44 -9
  60. data/lib/robot_lab/ractor_boundary.rb +42 -0
  61. data/lib/robot_lab/ractor_job.rb +37 -0
  62. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  63. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  64. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  65. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  66. data/lib/robot_lab/robot/history_search.rb +69 -0
  67. data/lib/robot_lab/robot/mcp_management.rb +61 -4
  68. data/lib/robot_lab/robot.rb +351 -11
  69. data/lib/robot_lab/robot_result.rb +26 -5
  70. data/lib/robot_lab/run_config.rb +1 -1
  71. data/lib/robot_lab/text_analysis.rb +103 -0
  72. data/lib/robot_lab/tool.rb +42 -3
  73. data/lib/robot_lab/tool_config.rb +1 -1
  74. data/lib/robot_lab/version.rb +1 -1
  75. data/lib/robot_lab/waiter.rb +49 -29
  76. data/lib/robot_lab.rb +25 -0
  77. data/mkdocs.yml +1 -0
  78. metadata +71 -2
@@ -13,31 +13,42 @@ server = RobotLab::MCP::Server.new(
13
13
  name: "filesystem",
14
14
  transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
15
15
  )
16
+
17
+ # With custom timeout
18
+ server = RobotLab::MCP::Server.new(
19
+ name: "slow_server",
20
+ transport: { type: "stdio", command: "heavy-mcp-server" },
21
+ timeout: 30
22
+ )
16
23
  ```
17
24
 
18
25
  ## Constructor
19
26
 
20
27
  ```ruby
21
- Server.new(name:, transport:)
28
+ Server.new(name:, transport:, timeout: nil, **_extra)
22
29
  ```
23
30
 
24
31
  **Parameters:**
25
32
 
26
- | Name | Type | Description |
27
- |------|------|-------------|
28
- | `name` | `String` | Unique server identifier |
29
- | `transport` | `Hash` | Transport configuration (must include `type`) |
33
+ | Name | Type | Default | Description |
34
+ |------|------|---------|-------------|
35
+ | `name` | `String` | **required** | Unique server identifier |
36
+ | `transport` | `Hash` | **required** | Transport configuration (must include `type`) |
37
+ | `timeout` | `Numeric`, `nil` | `15` | Request timeout in seconds. Values >= 1000 are auto-converted from milliseconds. Minimum 1 second |
30
38
 
31
39
  **Raises:** `ArgumentError` if:
32
40
  - The transport type is not one of the valid types
33
41
  - A stdio transport is missing the `:command` key
34
42
  - A network transport (ws, websocket, sse, streamable-http, http) is missing the `:url` key
35
43
 
36
- ## Valid Transport Types
44
+ ## Constants
37
45
 
38
46
  ```ruby
39
47
  RobotLab::MCP::Server::VALID_TRANSPORT_TYPES
40
48
  # => ["stdio", "sse", "ws", "websocket", "streamable-http", "http"]
49
+
50
+ RobotLab::MCP::Server::DEFAULT_TIMEOUT
51
+ # => 15 (seconds)
41
52
  ```
42
53
 
43
54
  ## Attributes
@@ -58,6 +69,14 @@ server.transport # => Hash
58
69
 
59
70
  The normalized transport configuration hash (keys are symbols, type is downcased).
60
71
 
72
+ ### timeout
73
+
74
+ ```ruby
75
+ server.timeout # => Numeric
76
+ ```
77
+
78
+ Request timeout in seconds. Defaults to `DEFAULT_TIMEOUT` (15). Values >= 1000 passed to the constructor are auto-converted from milliseconds to seconds. The minimum is 1 second.
79
+
61
80
  ## Methods
62
81
 
63
82
  ### transport_type
@@ -71,10 +90,10 @@ Returns the transport type string (e.g., `"stdio"`, `"ws"`, `"sse"`).
71
90
  ### to_h
72
91
 
73
92
  ```ruby
74
- server.to_h # => { name: "...", transport: { ... } }
93
+ server.to_h # => { name: "...", transport: { ... }, timeout: 15 }
75
94
  ```
76
95
 
77
- Converts the server configuration to a hash representation.
96
+ Converts the server configuration to a hash representation (includes `timeout`).
78
97
 
79
98
  ## Transport Configuration Options
80
99
 
@@ -21,7 +21,10 @@ All transports inherit from `RobotLab::MCP::Transports::Base` and implement:
21
21
 
22
22
  ```ruby
23
23
  class RobotLab::MCP::Transports::Base
24
- attr_reader :config # => Hash (symbolized keys)
24
+ DEFAULT_TIMEOUT = 15 # seconds
25
+
26
+ attr_reader :config # => Hash (symbolized keys, :timeout removed)
27
+ attr_reader :timeout # => Numeric (seconds, extracted from config)
25
28
 
26
29
  def connect # Establish connection, returns self
27
30
  def send_request(message) # Send JSON-RPC message, returns Hash response
@@ -30,11 +33,13 @@ class RobotLab::MCP::Transports::Base
30
33
  end
31
34
  ```
32
35
 
36
+ The `timeout` is extracted from the config hash during initialization (and removed from `config`). If not provided, it defaults to `DEFAULT_TIMEOUT` (15 seconds). The timeout is propagated from `MCP::Server` through `MCP::Client` to the transport.
37
+
33
38
  ## Stdio Transport
34
39
 
35
40
  **Class:** `RobotLab::MCP::Transports::Stdio`
36
41
 
37
- Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (one per line). Automatically sends MCP `initialize` and `notifications/initialized` on connect.
42
+ Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (one per line). Automatically sends MCP `initialize` and `notifications/initialized` on connect. All blocking I/O is wrapped with `Timeout.timeout` so a missing or hung server cannot block the caller forever.
38
43
 
39
44
  ### Configuration
40
45
 
@@ -43,7 +48,8 @@ Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (o
43
48
  type: "stdio",
44
49
  command: "mcp-server-filesystem", # Required: executable command
45
50
  args: ["--root", "/data"], # Optional: command arguments
46
- env: { "DEBUG" => "true" } # Optional: environment variables
51
+ env: { "DEBUG" => "true" }, # Optional: environment variables
52
+ timeout: 10 # Optional: request timeout in seconds (default: 15)
47
53
  }
48
54
  ```
49
55
 
@@ -52,14 +58,18 @@ Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (o
52
58
  | `command` | `String` | Yes | Executable command to spawn |
53
59
  | `args` | `Array<String>` | No | Command arguments |
54
60
  | `env` | `Hash` | No | Environment variables (merged with current env) |
61
+ | `timeout` | `Numeric` | No | Request timeout in seconds (default: 15) |
55
62
 
56
63
  ### Behavior
57
64
 
58
65
  - Uses `Open3.popen3` to spawn the subprocess
66
+ - Verifies the process actually started (raises `MCPError` if it exits immediately)
59
67
  - Writes JSON-RPC messages to stdin (one per line)
60
68
  - Reads responses from stdout, skipping notifications (messages without `id`)
69
+ - All blocking reads are wrapped with `Timeout.timeout` — raises `MCPError` if the server does not respond within the timeout period
61
70
  - `connected?` returns `true` when the subprocess is alive
62
- - `close` terminates stdin, stdout, stderr, and kills the subprocess
71
+ - `close` calls `cleanup_process` to reliably close stdin, stdout, stderr and kill the subprocess
72
+ - Handles `Errno::ENOENT` (command not found), `Errno::EPIPE` / `IOError` (broken pipe / connection lost), and `Timeout::Error` (hung server) with clear error messages
63
73
 
64
74
  ### Example
65
75
 
@@ -67,7 +77,8 @@ Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (o
67
77
  transport = RobotLab::MCP::Transports::Stdio.new(
68
78
  command: "mcp-server-filesystem",
69
79
  args: ["--root", "/data"],
70
- env: { "DEBUG" => "true" }
80
+ env: { "DEBUG" => "true" },
81
+ timeout: 10
71
82
  )
72
83
 
73
84
  transport.connect
@@ -243,7 +254,11 @@ end
243
254
  Specific error cases:
244
255
  - **Not connected** -- calling `send_request` before `connect` raises `MCPError`
245
256
  - **Missing gem** -- WebSocket, SSE, and HTTP transports raise `MCPError` with a `LoadError` message if required gems are not installed
246
- - **No response** -- Stdio transport raises `MCPError` if the subprocess produces no output
257
+ - **No response** -- Stdio transport raises `MCPError` if the subprocess produces no output (EOF on stdout)
258
+ - **Command not found** -- Stdio transport raises `MCPError` with the original `Errno::ENOENT` message
259
+ - **Timeout** -- Stdio transport raises `MCPError` if the server does not respond within the configured timeout
260
+ - **Broken pipe** -- Stdio transport raises `MCPError` and marks itself disconnected on `Errno::EPIPE` or `IOError`
261
+ - **Immediate exit** -- Stdio transport raises `MCPError` if the server process exits immediately after spawn
247
262
 
248
263
  ## See Also
249
264
 
@@ -9,7 +9,7 @@ A Robot is the primary unit of computation in RobotLab. It is a subclass of `Rub
9
9
  - A unique identity (name, description)
10
10
  - A personality (system prompt and/or template)
11
11
  - Capabilities (tools, MCP connections)
12
- - Model and inference configuration
12
+ - Model, provider, and inference configuration
13
13
  - Inherent memory (key-value store)
14
14
 
15
15
  ### Robot Anatomy
@@ -164,7 +164,8 @@ def build_result(response, _memory)
164
164
  robot_name: @name,
165
165
  output: output,
166
166
  tool_calls: normalize_tool_calls(tool_calls),
167
- stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil
167
+ stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
168
+ raw: response
168
169
  )
169
170
  end
170
171
  ```
@@ -182,9 +183,12 @@ result.tool_calls # => [ToolResultMessage, ...]
182
183
  result.stop_reason # => "stop" or nil
183
184
  result.created_at # => Time
184
185
  result.id # => UUID string
186
+ result.duration # => Float or nil (elapsed seconds, set in pipeline execution)
187
+ result.raw # => raw LLM response object
185
188
 
186
189
  # Convenience methods
187
190
  result.last_text_content # => "Hi there!" (last text message content)
191
+ result.reply # => alias for last_text_content
188
192
  result.has_tool_calls? # => false
189
193
  result.stopped? # => true
190
194
  ```
@@ -275,17 +279,31 @@ sequenceDiagram
275
279
  Robot-->>SF: result.continue(robot_result)
276
280
  ```
277
281
 
278
- 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 and calls `run`:
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:
279
283
 
280
284
  ```ruby
281
285
  def call(result)
282
286
  run_context = extract_run_context(result)
283
287
  message = run_context.delete(:message)
288
+
289
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
290
  robot_result = run(message, **run_context)
291
+ robot_result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
285
292
 
286
293
  result
287
294
  .with_context(@name.to_sym, robot_result)
288
295
  .continue(robot_result)
296
+ rescue Exception => e
297
+ # Error is wrapped in a RobotResult with the elapsed duration
298
+ error_result = RobotResult.new(
299
+ robot_name: @name,
300
+ output: [TextMessage.new(role: 'assistant', content: "Error: #{e.class}: #{e.message}")]
301
+ )
302
+ error_result.duration = elapsed
303
+
304
+ result
305
+ .with_context(@name.to_sym, error_result)
306
+ .continue(error_result)
289
307
  end
290
308
  ```
291
309
 
data/docs/concepts.md CHANGED
@@ -12,6 +12,7 @@ Each robot has:
12
12
  - **Template**: A `.md` file with YAML front matter managed by prompt_manager, referenced by symbol
13
13
  - **System Prompt**: Inline instructions (can be used alone or combined with a template)
14
14
  - **Model**: The LLM model to use (defaults to `RobotLab.config.ruby_llm.model`)
15
+ - **Provider**: Optional LLM provider for local models (Ollama, GPUStack, etc.)
15
16
  - **Skills**: Composable template behaviors prepended before the main template
16
17
  - **Local Tools**: `RubyLLM::Tool` subclasses or `RobotLab::Tool` instances (with automatic error handling)
17
18
  - **Streaming**: Real-time content via stored `on_content` callback or per-call block
@@ -195,12 +196,15 @@ tool = RobotLab::Tool.create(
195
196
  result = robot.run("Hello!")
196
197
 
197
198
  result.last_text_content # => "Hi there!" (String or nil)
199
+ result.reply # => alias for last_text_content
198
200
  result.output # => [TextMessage, ...] array of output messages
199
201
  result.tool_calls # => [] array of tool call results
200
202
  result.robot_name # => "assistant"
201
203
  result.stop_reason # => "end_turn" or nil
202
204
  result.has_tool_calls? # => false
203
205
  result.checksum # => "a1b2c3d4..." (for dedup)
206
+ result.duration # => Float or nil (elapsed seconds, set in pipeline execution)
207
+ result.raw # => raw LLM response object
204
208
  ```
205
209
 
206
210
  ## Memory
@@ -50,6 +50,21 @@ robot = RobotLab.build(
50
50
  )
51
51
  ```
52
52
 
53
+ ### Provider
54
+
55
+ For local LLM providers (Ollama, GPUStack, LM Studio, etc.), use the `provider:` parameter. This tells RubyLLM to skip model validation and connect directly:
56
+
57
+ ```ruby
58
+ robot = RobotLab.build(
59
+ name: "local_bot",
60
+ model: "llama3.2",
61
+ provider: :ollama,
62
+ system_prompt: "You are a helpful assistant."
63
+ )
64
+ ```
65
+
66
+ When `provider:` is set, `assume_model_exists: true` is automatically applied. The provider is available via `robot.provider`.
67
+
53
68
  ### System Prompt
54
69
 
55
70
  An inline string that defines the robot's personality and behavior:
@@ -479,10 +494,13 @@ The `run` method returns a `RobotResult` with:
479
494
 
480
495
  ```ruby
481
496
  result.last_text_content # => "Hi there! How can I help?"
497
+ result.reply # => alias for last_text_content
482
498
  result.output # => Array of output messages
483
499
  result.tool_calls # => Array of tool call results
484
500
  result.robot_name # => "assistant"
485
501
  result.stop_reason # => stop reason from the LLM
502
+ result.duration # => Float (elapsed seconds, set in pipeline execution)
503
+ result.raw # => raw LLM response object
486
504
  ```
487
505
 
488
506
  ### With Runtime Memory
@@ -124,6 +124,7 @@ end
124
124
  | `memory` | Task-specific memory |
125
125
  | `config` | Per-task `RunConfig` (merged on top of network's config) |
126
126
  | `depends_on` | `:none`, `[:task1]`, or `:optional` |
127
+ | `poller_group` | Bus delivery group label (`:default`, `:slow`, etc.) |
127
128
 
128
129
  ## Conditional Routing
129
130
 
@@ -164,6 +165,26 @@ network = RobotLab.create_network(name: "support") do
164
165
  end
165
166
  ```
166
167
 
168
+ ## Poller Groups
169
+
170
+ Each network maintains a shared `BusPoller` that serializes TypedBus deliveries on a per-robot basis: if a robot is already processing a message, new deliveries are queued and drained after the current one completes. This prevents re-entrancy without blocking other robots.
171
+
172
+ Named **poller groups** let you label tasks so slow robots are identifiable in logs and monitoring without needing separate infrastructure:
173
+
174
+ ```ruby
175
+ network = RobotLab.create_network(name: "mixed_speed") do
176
+ # Fast robots on the default group
177
+ task :fetcher, fetcher_robot, depends_on: :none
178
+ task :summarize, summarizer, depends_on: [:fetcher]
179
+
180
+ # Slow robots with expensive LLM calls — label them :slow
181
+ task :analyst, analyst_robot, depends_on: [:fetcher], poller_group: :slow
182
+ task :writer, writer_robot, depends_on: [:analyst], poller_group: :slow
183
+ end
184
+ ```
185
+
186
+ Group labels are informational — there is no separate queue per group. In Async execution, robots naturally yield during LLM HTTP calls, so fast and slow robots interleave without explicit isolation.
187
+
167
188
  ## Running Networks
168
189
 
169
190
  ### Basic Run
@@ -291,6 +312,24 @@ network = RobotLab.create_network(name: "multi_analysis") do
291
312
  end
292
313
  ```
293
314
 
315
+ ### Pipeline Error Resilience
316
+
317
+ When a robot raises an exception during pipeline execution, the error is caught and wrapped in a `RobotResult` with the error message as content. This ensures one failing robot does not crash the entire network:
318
+
319
+ ```ruby
320
+ # If billing_robot raises an error, the network continues
321
+ # The error is available in the result context:
322
+ result = network.run(message: "Process this")
323
+ billing_result = result.context[:billing]
324
+
325
+ if billing_result&.last_text_content&.start_with?("Error:")
326
+ puts "Billing failed: #{billing_result.last_text_content}"
327
+ puts "Took: #{billing_result.duration}s"
328
+ end
329
+ ```
330
+
331
+ Each robot's `RobotResult` includes a `duration` field (elapsed seconds) that is set automatically during pipeline execution, even for errored results.
332
+
294
333
  ### Conditional Continuation
295
334
 
296
335
  A robot can halt execution early:
data/docs/guides/index.md CHANGED
@@ -38,6 +38,14 @@ If you're new to RobotLab, start here:
38
38
 
39
39
  Share data between robots with the memory system
40
40
 
41
+ - [:octicons-pulse-24: **Observability & Safety**](observability.md)
42
+
43
+ Token tracking, circuit breakers, and learning accumulation
44
+
45
+ - [:material-cpu-64-bit: **Ractor Parallelism**](ractor-parallelism.md)
46
+
47
+ True CPU parallelism for tools and robot pipelines via Ruby Ractors
48
+
41
49
  </div>
42
50
 
43
51
  ## Framework Integration
@@ -61,3 +69,5 @@ If you're new to RobotLab, start here:
61
69
  | [Streaming](streaming.md) | Real-time responses | 5 min |
62
70
  | [Memory](memory.md) | Shared data store | 5 min |
63
71
  | [Rails Integration](rails-integration.md) | Rails application setup | 15 min |
72
+ | [Observability & Safety](observability.md) | Token tracking, circuit breaker, learning loop | 10 min |
73
+ | [Ractor Parallelism](ractor-parallelism.md) | CPU-parallel tools and robot pipelines | 15 min |
@@ -0,0 +1,182 @@
1
+ # Knowledge & Retrieval
2
+
3
+ Facilities for searching and retrieving knowledge from a robot's history and from external documents:
4
+
5
+ - **Chat History Search** — semantic search over accumulated conversation turns
6
+ - **Embedding-Based Document Store** — lightweight RAG: store arbitrary text, search by meaning
7
+
8
+ ---
9
+
10
+ ## Chat History Search
11
+
12
+ ### The Problem
13
+
14
+ Long-running robots accumulate many conversation turns. When you need to recall what was discussed earlier on a specific topic, re-sending the full history wastes tokens. `search_history` gives you a focused slice of the most relevant past messages without touching the LLM.
15
+
16
+ ### robot.search_history
17
+
18
+ ```ruby
19
+ results = robot.search_history(query, limit: 5)
20
+ ```
21
+
22
+ Scores every message in the robot's conversation history against `query` using stemmed term-frequency cosine similarity (via the `classifier` gem). Returns up to `limit` `HistoryResult` objects sorted by score descending.
23
+
24
+ ```ruby
25
+ results = robot.search_history("quarterly revenue", limit: 3)
26
+
27
+ results.each do |r|
28
+ puts "[#{r.role}] score=#{r.score.round(3)} idx=#{r.index}"
29
+ puts " #{r.text}"
30
+ end
31
+ ```
32
+
33
+ ### HistoryResult Fields
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `text` | String | The message text |
38
+ | `role` | Symbol | `:user`, `:assistant`, or `:system` |
39
+ | `score` | Float (0.0–1.0) | Cosine similarity with the query |
40
+ | `index` | Integer | Position in `@chat.messages` |
41
+
42
+ ### Typical Scores
43
+
44
+ | Relationship | Typical Score |
45
+ |---|---|
46
+ | Direct answer to the query | 0.50 – 0.80 |
47
+ | Same topic, different phrasing | 0.20 – 0.50 |
48
+ | Unrelated | < 0.10 |
49
+
50
+ ### Short Messages
51
+
52
+ Messages shorter than 20 characters are skipped — they produce no meaningful term vector.
53
+
54
+ ### Full Example
55
+
56
+ ```ruby
57
+ robot = RobotLab.build(name: "analyst", system_prompt: "You are a financial analyst.")
58
+
59
+ # … after several robot.run() calls …
60
+
61
+ hits = robot.search_history("customer acquisition cost")
62
+ hits.each { |r| puts "#{r.role} (#{r.score.round(2)}): #{r.text}" }
63
+ ```
64
+
65
+ ### RAG Pattern — Retrieve Then Generate
66
+
67
+ Use `search_history` to inject only the relevant past context into the next call:
68
+
69
+ ```ruby
70
+ hits = robot.search_history(user_query, limit: 3)
71
+ context = hits.map(&:text).join("\n")
72
+
73
+ robot.run("Recall context:\n#{context}\n\nNew question: #{user_query}")
74
+ ```
75
+
76
+ ### Optional Dependency
77
+
78
+ `search_history` requires the `classifier` gem:
79
+
80
+ ```ruby
81
+ gem "classifier", "~> 2.3"
82
+ ```
83
+
84
+ Without it, calling `search_history` raises `RobotLab::DependencyError` with an install hint.
85
+
86
+ ---
87
+
88
+ ## Embedding-Based Document Store
89
+
90
+ ### The Problem
91
+
92
+ Sometimes the knowledge you need isn't in the conversation history — it's in a README, a product spec, a changelog. `store_document` / `search_documents` embed arbitrary text with `fastembed` and retrieve the most relevant chunk at query time.
93
+
94
+ ### memory.store_document / memory.search_documents
95
+
96
+ ```ruby
97
+ memory.store_document(:readme, File.read("README.md"))
98
+ memory.store_document(:changelog, File.read("CHANGELOG.md"))
99
+
100
+ hits = memory.search_documents("how to configure redis", limit: 3)
101
+ hits.each { |h| puts "#{h[:key]} (#{h[:score].round(3)}): #{h[:text][0..80]}" }
102
+ ```
103
+
104
+ Each result hash contains:
105
+
106
+ | Key | Type | Description |
107
+ |-----|------|-------------|
108
+ | `:key` | Symbol | The key the document was stored under |
109
+ | `:text` | String | The full stored text |
110
+ | `:score` | Float (0.0–1.0) | Cosine similarity with the query |
111
+
112
+ ### Standalone DocumentStore
113
+
114
+ The `Memory` methods delegate to `RobotLab::DocumentStore`, which can also be used directly:
115
+
116
+ ```ruby
117
+ store = RobotLab::DocumentStore.new
118
+ store.store(:doc_a, "Ruby on Rails is a full-stack web framework.")
119
+ store.store(:doc_b, "Postgres is an advanced relational database.")
120
+
121
+ results = store.search("relational database SQL", limit: 2)
122
+ puts results.first[:key] # => :doc_b
123
+ ```
124
+
125
+ Management methods:
126
+
127
+ ```ruby
128
+ store.size # => 2
129
+ store.keys # => [:doc_a, :doc_b]
130
+ store.empty? # => false
131
+ store.delete(:doc_a)
132
+ store.clear
133
+ ```
134
+
135
+ ### Embedding Model
136
+
137
+ Default: `BAAI/bge-small-en-v1.5` (~23 MB, downloaded on first use, cached in `~/.cache/fastembed/`).
138
+
139
+ Documents are embedded with a `"passage: "` prefix and queries with `"query: "` prefix — the standard retrieval convention for BGE models.
140
+
141
+ Custom model:
142
+
143
+ ```ruby
144
+ store = RobotLab::DocumentStore.new(model_name: "BAAI/bge-base-en-v1.5")
145
+ ```
146
+
147
+ ### RAG Pattern
148
+
149
+ ```ruby
150
+ # 1. Index your knowledge base at startup
151
+ memory.store_document(:readme, File.read("README.md"))
152
+ memory.store_document(:changelog, File.read("CHANGELOG.md"))
153
+ memory.store_document(:api_docs, File.read("docs/api.md"))
154
+
155
+ # 2. At query time, retrieve the most relevant chunks
156
+ hits = memory.search_documents(user_query, limit: 3)
157
+ context = hits.map { |h| h[:text] }.join("\n\n")
158
+
159
+ # 3. Pass context to your robot
160
+ result = robot.run("Use the following context:\n#{context}\n\nQuestion: #{user_query}")
161
+ ```
162
+
163
+ ### Memory API Summary
164
+
165
+ | Method | Description |
166
+ |--------|-------------|
167
+ | `memory.store_document(key, text)` | Embed and store a document |
168
+ | `memory.search_documents(query, limit: 5)` | Search by semantic similarity |
169
+ | `memory.document_keys` | List stored keys |
170
+ | `memory.delete_document(key)` | Remove a document |
171
+
172
+ ### Dependency
173
+
174
+ `fastembed` is a core RobotLab dependency — no optional gem required. The ONNX model is downloaded on first use.
175
+
176
+ ---
177
+
178
+ ## See Also
179
+
180
+ - [Observability Guide](observability.md)
181
+ - [Example 25 — Chat History Search](../../examples/25_history_search.rb)
182
+ - [Example 26 — Embedding-Based Document Store](../../examples/26_document_store.rb)