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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +210 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/result.md +123 -0
  6. data/docs/api/core/robot.md +182 -0
  7. data/docs/api/errors.md +185 -0
  8. data/docs/guides/building-robots.md +125 -0
  9. data/docs/guides/creating-networks.md +21 -0
  10. data/docs/guides/index.md +10 -0
  11. data/docs/guides/knowledge.md +182 -0
  12. data/docs/guides/mcp-integration.md +106 -0
  13. data/docs/guides/memory.md +2 -0
  14. data/docs/guides/observability.md +486 -0
  15. data/docs/guides/ractor-parallelism.md +364 -0
  16. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  17. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  18. data/examples/19_token_tracking.rb +128 -0
  19. data/examples/20_circuit_breaker.rb +153 -0
  20. data/examples/21_learning_loop.rb +164 -0
  21. data/examples/22_context_compression.rb +179 -0
  22. data/examples/23_convergence.rb +137 -0
  23. data/examples/24_structured_delegation.rb +150 -0
  24. data/examples/25_history_search/conversation.jsonl +30 -0
  25. data/examples/25_history_search.rb +136 -0
  26. data/examples/26_document_store/api_versioning_adr.md +52 -0
  27. data/examples/26_document_store/incident_postmortem.md +46 -0
  28. data/examples/26_document_store/postgres_runbook.md +49 -0
  29. data/examples/26_document_store/redis_caching_guide.md +48 -0
  30. data/examples/26_document_store/sidekiq_guide.md +51 -0
  31. data/examples/26_document_store.rb +147 -0
  32. data/examples/27_incident_response/incident_response.rb +244 -0
  33. data/examples/28_mcp_discovery.rb +112 -0
  34. data/examples/29_ractor_tools.rb +243 -0
  35. data/examples/30_ractor_network.rb +256 -0
  36. data/examples/README.md +136 -0
  37. data/examples/prompts/skill_with_mcp_test.md +9 -0
  38. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  39. data/examples/prompts/skill_with_tools_test.md +6 -0
  40. data/lib/robot_lab/bus_poller.rb +149 -0
  41. data/lib/robot_lab/convergence.rb +69 -0
  42. data/lib/robot_lab/delegation_future.rb +93 -0
  43. data/lib/robot_lab/document_store.rb +155 -0
  44. data/lib/robot_lab/error.rb +25 -0
  45. data/lib/robot_lab/history_compressor.rb +205 -0
  46. data/lib/robot_lab/mcp/client.rb +17 -5
  47. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  48. data/lib/robot_lab/mcp/server.rb +7 -2
  49. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  50. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  51. data/lib/robot_lab/memory.rb +103 -6
  52. data/lib/robot_lab/network.rb +44 -9
  53. data/lib/robot_lab/ractor_boundary.rb +42 -0
  54. data/lib/robot_lab/ractor_job.rb +37 -0
  55. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  56. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  57. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  58. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  59. data/lib/robot_lab/robot/history_search.rb +69 -0
  60. data/lib/robot_lab/robot.rb +228 -11
  61. data/lib/robot_lab/robot_result.rb +24 -5
  62. data/lib/robot_lab/run_config.rb +1 -1
  63. data/lib/robot_lab/text_analysis.rb +103 -0
  64. data/lib/robot_lab/tool.rb +42 -3
  65. data/lib/robot_lab/tool_config.rb +1 -1
  66. data/lib/robot_lab/version.rb +1 -1
  67. data/lib/robot_lab/waiter.rb +49 -29
  68. data/lib/robot_lab.rb +25 -0
  69. data/mkdocs.yml +1 -0
  70. metadata +72 -2
@@ -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
@@ -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: