robot_lab 0.0.9 → 0.0.12
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/CHANGELOG.md +53 -0
- data/README.md +210 -1
- data/Rakefile +2 -1
- data/docs/api/core/result.md +123 -0
- data/docs/api/core/robot.md +182 -0
- data/docs/api/errors.md +185 -0
- data/docs/guides/building-robots.md +125 -0
- data/docs/guides/creating-networks.md +21 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +106 -0
- data/docs/guides/memory.md +2 -0
- data/docs/guides/observability.md +486 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
- data/examples/19_token_tracking.rb +128 -0
- data/examples/20_circuit_breaker.rb +153 -0
- data/examples/21_learning_loop.rb +164 -0
- data/examples/22_context_compression.rb +179 -0
- data/examples/23_convergence.rb +137 -0
- data/examples/24_structured_delegation.rb +150 -0
- data/examples/25_history_search/conversation.jsonl +30 -0
- data/examples/25_history_search.rb +136 -0
- data/examples/26_document_store/api_versioning_adr.md +52 -0
- data/examples/26_document_store/incident_postmortem.md +46 -0
- data/examples/26_document_store/postgres_runbook.md +49 -0
- data/examples/26_document_store/redis_caching_guide.md +48 -0
- data/examples/26_document_store/sidekiq_guide.md +51 -0
- data/examples/26_document_store.rb +147 -0
- data/examples/27_incident_response/incident_response.rb +244 -0
- data/examples/28_mcp_discovery.rb +112 -0
- data/examples/29_ractor_tools.rb +243 -0
- data/examples/30_ractor_network.rb +256 -0
- data/examples/README.md +136 -0
- data/examples/prompts/skill_with_mcp_test.md +9 -0
- data/examples/prompts/skill_with_robot_name_test.md +5 -0
- data/examples/prompts/skill_with_tools_test.md +6 -0
- data/lib/robot_lab/bus_poller.rb +149 -0
- data/lib/robot_lab/convergence.rb +69 -0
- data/lib/robot_lab/delegation_future.rb +93 -0
- data/lib/robot_lab/document_store.rb +155 -0
- data/lib/robot_lab/error.rb +25 -0
- data/lib/robot_lab/history_compressor.rb +205 -0
- data/lib/robot_lab/mcp/client.rb +17 -5
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +7 -2
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
- data/lib/robot_lab/memory.rb +103 -6
- data/lib/robot_lab/network.rb +44 -9
- data/lib/robot_lab/ractor_boundary.rb +42 -0
- data/lib/robot_lab/ractor_job.rb +37 -0
- data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
- data/lib/robot_lab/ractor_worker_pool.rb +117 -0
- data/lib/robot_lab/robot/bus_messaging.rb +43 -65
- data/lib/robot_lab/robot/history_search.rb +69 -0
- data/lib/robot_lab/robot.rb +228 -11
- data/lib/robot_lab/robot_result.rb +24 -5
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/text_analysis.rb +103 -0
- data/lib/robot_lab/tool.rb +42 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +49 -29
- data/lib/robot_lab.rb +25 -0
- data/mkdocs.yml +1 -0
- metadata +72 -2
data/docs/api/errors.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Error Reference
|
|
2
|
+
|
|
3
|
+
All RobotLab exceptions inherit from `RobotLab::Error`, which inherits from `StandardError`. Rescue `RobotLab::Error` to catch any framework exception in one clause, or rescue specific subclasses for targeted handling.
|
|
4
|
+
|
|
5
|
+
## Hierarchy
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
StandardError
|
|
9
|
+
└── RobotLab::Error
|
|
10
|
+
├── RobotLab::ConfigurationError
|
|
11
|
+
│ └── RobotLab::DependencyError
|
|
12
|
+
├── RobotLab::InferenceError
|
|
13
|
+
│ └── RobotLab::ToolLoopError
|
|
14
|
+
├── RobotLab::ToolNotFoundError
|
|
15
|
+
├── RobotLab::MCPError
|
|
16
|
+
├── RobotLab::BusError
|
|
17
|
+
├── RobotLab::RactorBoundaryError
|
|
18
|
+
└── RobotLab::ToolError
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Additionally, `DelegationFuture` defines its own scoped error:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
StandardError
|
|
25
|
+
└── RobotLab::Error
|
|
26
|
+
└── RobotLab::DelegationFuture::DelegationTimeout
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## RobotLab::Error
|
|
32
|
+
|
|
33
|
+
Base class for all RobotLab errors. Rescue this to catch any framework exception.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
begin
|
|
37
|
+
robot.run("hello")
|
|
38
|
+
rescue RobotLab::Error => e
|
|
39
|
+
puts "RobotLab error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## RobotLab::ConfigurationError
|
|
46
|
+
|
|
47
|
+
Raised when configuration is invalid or missing required values.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Example: missing API key, invalid template path
|
|
51
|
+
rescue RobotLab::ConfigurationError => e
|
|
52
|
+
puts "Bad config: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## RobotLab::DependencyError < ConfigurationError
|
|
59
|
+
|
|
60
|
+
Raised when a required optional gem dependency is not installed.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Triggered by: Convergence, HistoryCompressor when 'classifier' gem is absent
|
|
64
|
+
rescue RobotLab::DependencyError => e
|
|
65
|
+
puts e.message # "Add gem 'classifier', '~> 2.3' to your Gemfile"
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## RobotLab::InferenceError
|
|
72
|
+
|
|
73
|
+
Raised when LLM inference fails (API errors, timeouts, rate limits).
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
rescue RobotLab::InferenceError => e
|
|
77
|
+
puts "LLM call failed: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## RobotLab::ToolLoopError < InferenceError
|
|
84
|
+
|
|
85
|
+
Raised when a robot's tool call count exceeds `max_tool_rounds:`. The chat history will contain a dangling `tool_use` block with no matching `tool_result`; call `robot.clear_messages` before reusing the robot.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
robot = RobotLab.build(
|
|
89
|
+
name: "runner",
|
|
90
|
+
system_prompt: "Execute every step.",
|
|
91
|
+
local_tools: [StepTool],
|
|
92
|
+
max_tool_rounds: 10
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
robot.run("Run all steps.")
|
|
97
|
+
rescue RobotLab::ToolLoopError => e
|
|
98
|
+
puts e.message # "Tool call limit of 10 exceeded"
|
|
99
|
+
robot.clear_messages # required before reuse
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## RobotLab::ToolNotFoundError
|
|
106
|
+
|
|
107
|
+
Raised when a tool name is referenced but cannot be found in the `ToolManifest`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## RobotLab::MCPError
|
|
112
|
+
|
|
113
|
+
Raised when MCP server communication fails (connection refused, timeout, protocol error).
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
rescue RobotLab::MCPError => e
|
|
117
|
+
puts "MCP failed: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## RobotLab::BusError
|
|
124
|
+
|
|
125
|
+
Raised when message bus communication fails (no bus configured, channel not found).
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
rescue RobotLab::BusError => e
|
|
129
|
+
puts "Bus error: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## RobotLab::RactorBoundaryError
|
|
136
|
+
|
|
137
|
+
Raised when a value cannot be made Ractor-shareable before crossing a Ractor boundary (e.g., a live `IO` object, a `Proc`, or an object with mutable state).
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
begin
|
|
141
|
+
RobotLab::RactorBoundary.freeze_deep({ io: StringIO.new })
|
|
142
|
+
rescue RobotLab::RactorBoundaryError => e
|
|
143
|
+
puts e.message # "Cannot make value Ractor-shareable: ..."
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Raised proactively by `RactorWorkerPool#submit` and `RactorMemoryProxy#set` before any Ractor is involved.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## RobotLab::ToolError
|
|
152
|
+
|
|
153
|
+
Raised when a tool fails during execution inside a Ractor worker (the pool unwraps `RactorJobError` and re-raises as `ToolError`).
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
begin
|
|
157
|
+
pool.submit("MyTool", { input: "bad" })
|
|
158
|
+
rescue RobotLab::ToolError => e
|
|
159
|
+
puts e.message # "Tool 'MyTool' failed in Ractor: ..."
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## RobotLab::DelegationFuture::DelegationTimeout
|
|
166
|
+
|
|
167
|
+
Raised by `DelegationFuture#value(timeout: N)` when the delegated task does not complete within `N` seconds.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
future = manager.delegate(to: analyst, task: "...", async: true)
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
result = future.value(timeout: 10)
|
|
174
|
+
rescue RobotLab::DelegationFuture::DelegationTimeout => e
|
|
175
|
+
puts e.message # "Delegation to 'analyst' timed out after 10s"
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Related
|
|
182
|
+
|
|
183
|
+
- [Building Robots](../guides/building-robots.md) — Tool loop circuit breaker, delegation
|
|
184
|
+
- [Ractor Parallelism](../guides/ractor-parallelism.md) — Ractor boundary and tool errors
|
|
185
|
+
- [MCP Integration](../guides/mcp-integration.md) — MCP connection errors
|
|
@@ -736,6 +736,131 @@ bot = RobotLab.build(name: "latecomer", system_prompt: "Hello.")
|
|
|
736
736
|
bot.with_bus(existing_bus) # now connected and can send/receive messages
|
|
737
737
|
```
|
|
738
738
|
|
|
739
|
+
## Context Window Compression
|
|
740
|
+
|
|
741
|
+
Long-running robots accumulate conversation history that can grow to fill the context window. `compress_history` prunes old turns using TF-IDF cosine similarity against the most recent context, keeping turns that are still relevant and discarding or summarizing those that aren't.
|
|
742
|
+
|
|
743
|
+
```ruby
|
|
744
|
+
# Default settings: protect 3 most-recent turn pairs, drop anything below 0.2
|
|
745
|
+
robot.compress_history
|
|
746
|
+
|
|
747
|
+
# Tune all thresholds
|
|
748
|
+
robot.compress_history(
|
|
749
|
+
recent_turns: 5, # number of recent user+assistant pairs to always keep
|
|
750
|
+
keep_threshold: 0.6, # turns with score >= this are kept verbatim (default 0.6)
|
|
751
|
+
drop_threshold: 0.2 # turns with score < this are dropped (default 0.2)
|
|
752
|
+
)
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
Medium-relevance turns (between thresholds) are dropped by default. Pass a `summarizer:` callable to replace them with a one-sentence summary instead:
|
|
756
|
+
|
|
757
|
+
```ruby
|
|
758
|
+
summarizer = RobotLab.build(name: "summarizer", system_prompt: "Summarize concisely in one sentence.")
|
|
759
|
+
|
|
760
|
+
robot.compress_history(
|
|
761
|
+
summarizer: ->(text) { summarizer.run("Summarize: #{text}").reply }
|
|
762
|
+
)
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
**What is always preserved regardless of score:**
|
|
766
|
+
- System messages
|
|
767
|
+
- Tool call/result message pairs (dropping half would corrupt the conversation)
|
|
768
|
+
- The most recent `recent_turns` user+assistant pairs
|
|
769
|
+
|
|
770
|
+
Requires the `classifier` gem (`~> 2.3`):
|
|
771
|
+
|
|
772
|
+
```ruby
|
|
773
|
+
gem "classifier", "~> 2.3"
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
## Convergence Detection
|
|
777
|
+
|
|
778
|
+
`RobotLab::Convergence` uses TF-IDF cosine similarity to detect when two independent agents have reached the same conclusion. The primary use case is a network router that skips an expensive reconciler robot when two verifiers already agree.
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
# Check the similarity score directly (returns Float 0.0..1.0)
|
|
782
|
+
score = RobotLab::Convergence.similarity(result_a.reply, result_b.reply)
|
|
783
|
+
|
|
784
|
+
# Boolean convergence check (default threshold: 0.85)
|
|
785
|
+
RobotLab::Convergence.detected?(result_a.reply, result_b.reply)
|
|
786
|
+
|
|
787
|
+
# Custom threshold
|
|
788
|
+
RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.75)
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
Wire it into a network router for the reconciler fast-path:
|
|
792
|
+
|
|
793
|
+
```ruby
|
|
794
|
+
verifier_a = RobotLab.build(name: "verifier_a", system_prompt: "Verify the answer.")
|
|
795
|
+
verifier_b = RobotLab.build(name: "verifier_b", system_prompt: "Independently verify the answer.")
|
|
796
|
+
reconciler = RobotLab.build(name: "reconciler", system_prompt: "Reconcile conflicting answers.")
|
|
797
|
+
|
|
798
|
+
router = lambda do |args|
|
|
799
|
+
a = args.context[:verifier_a]&.reply.to_s
|
|
800
|
+
b = args.context[:verifier_b]&.reply.to_s
|
|
801
|
+
|
|
802
|
+
# Skip reconciler when verifiers agree
|
|
803
|
+
RobotLab::Convergence.detected?(a, b) ? nil : ["reconciler"]
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
network = RobotLab.create_network(name: "verify", router: router) do
|
|
807
|
+
# ...
|
|
808
|
+
end
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
Requires the `classifier` gem (`~> 2.3`).
|
|
812
|
+
|
|
813
|
+
## Structured Delegation
|
|
814
|
+
|
|
815
|
+
A robot can delegate a task to another robot using `delegate(to:, task:)`. The result is a `RobotResult` annotated with `delegated_by`, `duration`, and token counts.
|
|
816
|
+
|
|
817
|
+
### Synchronous Delegation
|
|
818
|
+
|
|
819
|
+
The default: blocks the calling robot until the delegatee finishes.
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
analyst = RobotLab.build(name: "analyst", system_prompt: "Analyze data.")
|
|
823
|
+
manager = RobotLab.build(name: "manager", system_prompt: "Coordinate work.")
|
|
824
|
+
|
|
825
|
+
result = manager.delegate(to: analyst, task: "Summarize the Q3 report.")
|
|
826
|
+
puts result.reply
|
|
827
|
+
puts "Completed in %.2fs using %d tokens" % [result.duration, result.output_tokens]
|
|
828
|
+
puts result.delegated_by # => "manager"
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Asynchronous Delegation (Fan-out)
|
|
832
|
+
|
|
833
|
+
Pass `async: true` to get a `DelegationFuture` back immediately. Call `.value` to block for the result when you need it.
|
|
834
|
+
|
|
835
|
+
```ruby
|
|
836
|
+
writer = RobotLab.build(name: "writer", system_prompt: "Write clearly.")
|
|
837
|
+
analyst = RobotLab.build(name: "analyst", system_prompt: "Analyze data.")
|
|
838
|
+
manager = RobotLab.build(name: "manager", system_prompt: "Coordinate.")
|
|
839
|
+
|
|
840
|
+
# Fan out — both start immediately
|
|
841
|
+
f1 = manager.delegate(to: analyst, task: "Analyze Q3 numbers", async: true)
|
|
842
|
+
f2 = manager.delegate(to: writer, task: "Draft the intro paragraph", async: true)
|
|
843
|
+
|
|
844
|
+
# ... do other work here ...
|
|
845
|
+
|
|
846
|
+
# Collect results (blocks if not yet done)
|
|
847
|
+
analysis = f1.value
|
|
848
|
+
draft = f2.value
|
|
849
|
+
|
|
850
|
+
# With a timeout (raises DelegationFuture::DelegationTimeout)
|
|
851
|
+
result = f1.value(timeout: 30)
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
`DelegationFuture` API:
|
|
855
|
+
|
|
856
|
+
| Method | Description |
|
|
857
|
+
|--------|-------------|
|
|
858
|
+
| `resolved?` | Non-blocking poll — true if completed or errored |
|
|
859
|
+
| `value(timeout: nil)` | Block until done; re-raises any error from the delegatee |
|
|
860
|
+
| `wait` | Alias for `value` |
|
|
861
|
+
| `robot_name` | Name of the robot that was delegated to |
|
|
862
|
+
| `delegated_by` | Name of the robot that created this future |
|
|
863
|
+
|
|
739
864
|
## Configuration
|
|
740
865
|
|
|
741
866
|
RobotLab uses `MywayConfig` for configuration. Access the config object directly -- there is no `RobotLab.configure` block:
|
|
@@ -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
|
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)
|
|
@@ -310,6 +310,112 @@ client.list_resources # => Array of resource definitions
|
|
|
310
310
|
client.disconnect
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
+
## Connection Multiplexing
|
|
314
|
+
|
|
315
|
+
When a robot connects to several local (stdio) MCP servers, each client normally blocks independently while waiting for a response. `MCP::ConnectionPoller` replaces this with a single `IO.select` call across all registered stdout file descriptors, dispatching each response to the pending request for that client.
|
|
316
|
+
|
|
317
|
+
This is primarily useful in networks where many robots each have multiple stdio MCP servers. Async-based transports (SSE, WebSocket, StreamableHTTP) are unaffected — they already use the Async fiber scheduler.
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# Create a shared poller
|
|
321
|
+
poller = RobotLab::MCP::ConnectionPoller.new.start
|
|
322
|
+
|
|
323
|
+
# Pass the poller when building clients
|
|
324
|
+
client1 = RobotLab::MCP::Client.new(
|
|
325
|
+
{ name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } },
|
|
326
|
+
poller: poller
|
|
327
|
+
)
|
|
328
|
+
client2 = RobotLab::MCP::Client.new(
|
|
329
|
+
{ name: "github", transport: { type: "stdio", command: "mcp-server-github" } },
|
|
330
|
+
poller: poller
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
client1.connect # registers with poller
|
|
334
|
+
client2.connect # registers with poller
|
|
335
|
+
|
|
336
|
+
# Both clients share the IO.select loop
|
|
337
|
+
client1.list_tools
|
|
338
|
+
client2.list_tools
|
|
339
|
+
|
|
340
|
+
poller.stop
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Without a shared poller each client uses its own blocking `Timeout.timeout` call. With a poller, responses from any registered server wake the poller's select loop, which dispatches to the right waiting thread via a `Thread::Queue`.
|
|
344
|
+
|
|
345
|
+
!!! note
|
|
346
|
+
Only stdio clients are registered with the poller. SSE, WebSocket, and StreamableHTTP clients passed a `poller:` argument ignore it silently.
|
|
347
|
+
|
|
348
|
+
## Server Discovery
|
|
349
|
+
|
|
350
|
+
When a robot has many MCP servers configured, connecting to all of them upfront is wasteful — most servers will be irrelevant for any given user message. **Server Discovery** uses TF cosine similarity to select only the semantically relevant servers before the first `ensure_mcp_clients` call.
|
|
351
|
+
|
|
352
|
+
### Enabling Discovery
|
|
353
|
+
|
|
354
|
+
Add `description:` to each server config and set `mcp_discovery: true` on the robot:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
robot = RobotLab.build(
|
|
358
|
+
name: "assistant",
|
|
359
|
+
system_prompt: "You are a helpful assistant.",
|
|
360
|
+
mcp_discovery: true,
|
|
361
|
+
mcp: [
|
|
362
|
+
{
|
|
363
|
+
name: "filesystem",
|
|
364
|
+
description: "Read, write, and search local files and directories",
|
|
365
|
+
transport: { type: "stdio", command: "mcp-server-filesystem" }
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "github",
|
|
369
|
+
description: "GitHub repos, issues, pull requests, code search",
|
|
370
|
+
transport: { type: "stdio", command: "mcp-server-github" }
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "brew",
|
|
374
|
+
description: "Install, update, and manage macOS packages via Homebrew",
|
|
375
|
+
transport: { type: "stdio", command: "mcp-server-brew" }
|
|
376
|
+
}
|
|
377
|
+
]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Discovery connects only :brew for this message — filesystem and github are skipped
|
|
381
|
+
robot.run("install imagemagick")
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### How It Works
|
|
385
|
+
|
|
386
|
+
`MCP::ServerDiscovery.select(query, from:, threshold:)` computes TF cosine similarity between the user's query and each server's topic text (`name + description`). Servers scoring at or above `DEFAULT_THRESHOLD` (0.05) are returned; the rest are excluded.
|
|
387
|
+
|
|
388
|
+
The threshold is intentionally low — server descriptions are short, so raw cosine scores are naturally small even for on-topic queries.
|
|
389
|
+
|
|
390
|
+
Discovery only applies on the **first** `run()` call (before `@mcp_initialized`). Once a set of servers is connected they remain connected for the robot's lifetime, preserving tool continuity across a conversation.
|
|
391
|
+
|
|
392
|
+
### Fallback Behaviour
|
|
393
|
+
|
|
394
|
+
All servers are returned unchanged when any of the following apply:
|
|
395
|
+
|
|
396
|
+
| Condition | Reason |
|
|
397
|
+
|-----------|--------|
|
|
398
|
+
| No server has a `description` field | Nothing to score against |
|
|
399
|
+
| `classifier` gem unavailable | Raises `DependencyError`, caught internally |
|
|
400
|
+
| Query is blank or nil | Nothing to compare |
|
|
401
|
+
| No server scores ≥ threshold | Rather fall back than leave the robot with no tools |
|
|
402
|
+
|
|
403
|
+
### Using the API Directly
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
servers = [
|
|
407
|
+
{ name: "filesystem", description: "Read and write files", transport: { ... } },
|
|
408
|
+
{ name: "github", description: "GitHub repos and PRs", transport: { ... } }
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
relevant = RobotLab::MCP::ServerDiscovery.select(
|
|
412
|
+
"list open pull requests",
|
|
413
|
+
from: servers,
|
|
414
|
+
threshold: 0.05 # optional, default
|
|
415
|
+
)
|
|
416
|
+
# => only the :github entry
|
|
417
|
+
```
|
|
418
|
+
|
|
313
419
|
## Connection Resilience
|
|
314
420
|
|
|
315
421
|
### Eager Connection
|
data/docs/guides/memory.md
CHANGED
|
@@ -190,6 +190,8 @@ results = memory.get(:sentiment, :entities, :keywords, wait: 60)
|
|
|
190
190
|
# => { sentiment: {...}, entities: [...], keywords: [...] }
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
Each blocking wait is backed by an `IO.pipe` pair (`Waiter` class). Calling `signal` writes one byte per waiting caller, so all threads blocked on `IO.select` wake immediately. This design works cleanly with Ruby's Async fiber scheduler — no mutex contention or spurious wakeups.
|
|
194
|
+
|
|
193
195
|
### Subscriptions
|
|
194
196
|
|
|
195
197
|
Subscribe to key changes with asynchronous callbacks:
|