robot_lab 0.0.8 → 0.0.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37a044eb81a0e5c56aa7c5c00f9b4eff600c56eecda8bb2deb18058919a18267
4
- data.tar.gz: a48253bbceb5ac99f1babf9c4538045886e038fb0c4a827b96b219b5363bf5c8
3
+ metadata.gz: c852fcf7f4aed4ce95fabdc5b0296723ca8aa10e780dabaa7759e618a22bc640
4
+ data.tar.gz: 1bcb205c958ede9967886dae78a1d1a6d47da42e4cd9bd29d7bdd3e094b0a088
5
5
  SHA512:
6
- metadata.gz: 9f84d7c82598d281e8c88a4e67b2b0c13bac1142e8dae9834a1b0e84d1055e16837c62b452398da615d477aec834b153ca74f6313c67b806fe174aacd3a0999b
7
- data.tar.gz: 549afa0eb2e622ad8caf0d928559bb881b3f1307d2e427074a2f96f943418784e580ef9d5b2a06534f9ec57873682189ec6890cd0ed1790e19b66c159fff572e
6
+ metadata.gz: 5620e7798ac04441cb23c6a7cc5f0cdad7447103825db35ef6f3a3987785b8ff5fb355ec03a309ef9c8a5ce5b0b7a29d9f5adef0e6a5d9de5cd66d3c94fb0469
7
+ data.tar.gz: 9300b1f5ed98e70226c7c670bcf2e3dee033310db6b2182b2705085f02474a1ea6157a011c93906da1d45ba38b4c9f8b9e62545cdb5fd304ca1550734f7dc043
data/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.0.9] - 2026-03-02
12
+
13
+ ### Added
14
+
15
+ - **Provider passthrough** — `provider:` parameter on Robot constructor for local LLM providers (Ollama, GPUStack, etc.)
16
+ - Automatically sets `assume_model_exists: true` when provider is specified
17
+ - Exposed via `robot.provider` accessor
18
+ - **MCP request timeouts** — configurable timeout for all MCP transports
19
+ - `MCP::Server` accepts `timeout:` parameter (default 15s); auto-converts millisecond values
20
+ - `MCP::Transports::Base` extracts and exposes `timeout` from config
21
+ - `MCP::Transports::Stdio` wraps all blocking I/O with `Timeout.timeout` — hung servers no longer block the caller forever
22
+ - Timeout propagated from `MCP::Server` through `MCP::Client` to transport layer
23
+ - **MCP connection resilience** — improved error handling and retry logic
24
+ - `ensure_mcp_clients` retries previously failed servers on subsequent calls
25
+ - `@failed_mcp_configs` tracks servers that failed to connect
26
+ - `robot.failed_mcp_server_names` — query which MCP servers are down
27
+ - `robot.connect_mcp!` — eagerly connect to MCP servers (normally lazy)
28
+ - `init_mcp_client` rescues `StandardError` so one bad server doesn't prevent others from connecting
29
+ - `cleanup_process` in Stdio transport for reliable resource cleanup
30
+ - Better error messages for command-not-found (`Errno::ENOENT`), broken pipe (`Errno::EPIPE`), and EOF conditions
31
+ - **`robot.inject_mcp!`** — inject pre-connected MCP clients and tools from an external host application
32
+ - **Conversation management APIs** on Robot
33
+ - `robot.chat` — access the underlying `RubyLLM::Chat` instance
34
+ - `robot.messages` — return conversation messages
35
+ - `robot.clear_messages(keep_system:)` — clear history, optionally preserving the system prompt
36
+ - `robot.replace_messages(messages)` — restore a saved conversation (checkpoint/restore)
37
+ - `robot.chat_provider` — query the provider name without reaching into chat internals
38
+ - `robot.mcp_client(server_name)` — find an MCP client by server name
39
+ - **`RobotResult#duration`** — elapsed seconds for a robot run, set automatically during pipeline execution
40
+ - **`RobotResult#raw`** — raw LLM response stored on every result (previously only settable via accessor)
41
+ - **Pipeline error resilience** — `Robot#call` (pipeline step) rescues all exceptions so one failing robot doesn't crash the entire network; error is captured in a `RobotResult` with the elapsed duration
42
+
43
+ ### Changed
44
+
45
+ - Bumped version to 0.0.9
46
+ - Display `scout_path` in Rusty Circuit example updated to use `output/` subdirectory
47
+ - Updated `onnxruntime` dependency to 0.11.0
48
+ - Updated Gemfile.lock dependencies (erb, minitest, rails-html-sanitizer, json_schemer)
49
+
11
50
  ## [0.0.8] - 2026-02-22
12
51
 
13
52
  ### Added
data/README.md CHANGED
@@ -20,7 +20,8 @@
20
20
  - <strong>Extensible Tools</strong> - Custom capabilities with graceful error handling<br>
21
21
  - <strong>Human-in-the-Loop</strong> - AskUser tool for interactive prompting<br>
22
22
  - <strong>Content Streaming</strong> - Stored callbacks, per-call blocks, or both<br>
23
- - <strong>MCP Integration</strong> - Connect to external tool servers<br>
23
+ - <strong>MCP Integration</strong> - Connect to external tool servers with timeouts and retry<br>
24
+ - <strong>Local LLM Providers</strong> - Ollama, GPUStack, LM Studio via provider passthrough<br>
24
25
  - <strong>Shared Memory</strong> - Reactive key-value store with subscriptions<br>
25
26
  - <strong>Message Bus</strong> - Bidirectional robot communication via TypedBus<br>
26
27
  - <strong>Dynamic Spawning</strong> - Robots create new robots at runtime<br>
@@ -71,6 +72,19 @@ puts result.last_text_content
71
72
  # => "The capital of France is Paris."
72
73
  ```
73
74
 
75
+ ### Local LLM Providers
76
+
77
+ For local LLM providers (Ollama, GPUStack, LM Studio, etc.), use the `provider:` parameter:
78
+
79
+ ```ruby
80
+ robot = RobotLab.build(
81
+ name: "local_bot",
82
+ model: "llama3.2",
83
+ provider: :ollama,
84
+ system_prompt: "You are a helpful assistant."
85
+ )
86
+ ```
87
+
74
88
  ### Configuration
75
89
 
76
90
  RobotLab uses [MywayConfig](https://github.com/MadBomber/myway_config) for layered configuration. There is no `configure` block. Configuration is loaded automatically from multiple sources in priority order:
@@ -443,14 +457,15 @@ puts result.value.last_text_content
443
457
  Connect to external tool servers via Model Context Protocol:
444
458
 
445
459
  ```ruby
446
- # Configure MCP server
460
+ # Configure MCP server (with optional timeout)
447
461
  filesystem_server = {
448
462
  name: "filesystem",
449
463
  transport: {
450
464
  type: "stdio",
451
465
  command: "mcp-server-filesystem",
452
466
  args: ["/path/to/allowed/directory"]
453
- }
467
+ },
468
+ timeout: 30 # seconds (default: 15)
454
469
  }
455
470
 
456
471
  # Create robot with MCP server - tools are auto-discovered
@@ -460,10 +475,18 @@ robot = RobotLab.build(
460
475
  mcp: [filesystem_server]
461
476
  )
462
477
 
478
+ # Optionally connect eagerly (default is lazy on first run)
479
+ robot.connect_mcp!
480
+
481
+ # Check connection status
482
+ puts "Failed: #{robot.failed_mcp_server_names}" if robot.failed_mcp_server_names.any?
483
+
463
484
  # Robot can now use filesystem tools
464
485
  result = robot.run("List the files in the current directory")
465
486
  ```
466
487
 
488
+ MCP connections are resilient: failed servers are automatically retried on subsequent `run()` calls, and one failing server does not prevent others from connecting.
489
+
467
490
  ## Message Bus
468
491
 
469
492
  Robots can communicate bidirectionally via an optional message bus, independent of the Network pipeline. This enables negotiation loops, convergence patterns, and cyclic workflows.
@@ -23,6 +23,7 @@ Robot.new(
23
23
  description: nil,
24
24
  local_tools: [],
25
25
  model: nil,
26
+ provider: nil,
26
27
  mcp_servers: [],
27
28
  mcp: :none,
28
29
  tools: :none,
@@ -54,6 +55,7 @@ Robot.new(
54
55
  | `description` | `String`, `nil` | `nil` | Human-readable description of what the robot does |
55
56
  | `local_tools` | `Array` | `[]` | Tools defined locally (`RubyLLM::Tool` subclasses or `RobotLab::Tool` instances) |
56
57
  | `model` | `String`, `nil` | `nil` | LLM model ID (falls back to `RobotLab.config.ruby_llm.model`) |
58
+ | `provider` | `String`, `Symbol`, `nil` | `nil` | LLM provider for local providers (e.g., `:ollama`, `:gpustack`). Automatically sets `assume_model_exists: true` |
57
59
  | `mcp_servers` | `Array` | `[]` | Legacy MCP server configurations |
58
60
  | `mcp` | `Symbol`, `Array` | `:none` | Hierarchical MCP config (`:none`, `:inherit`, or server array) |
59
61
  | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name array) |
@@ -101,6 +103,7 @@ If `name` is omitted, it defaults to `"robot"`.
101
103
  | `template` | `Symbol`, `nil` | Prompt template identifier |
102
104
  | `system_prompt` | `String`, `nil` | Inline system prompt |
103
105
  | `skills` | `Array<Symbol>`, `nil` | Constructor-provided skill template IDs (nil if none) |
106
+ | `provider` | `String`, `nil` | LLM provider name (e.g., `"ollama"`) — set when using local providers |
104
107
  | `local_tools` | `Array` | Locally defined tools |
105
108
  | `mcp_clients` | `Hash<String, MCP::Client>` | Connected MCP clients, keyed by server name |
106
109
  | `mcp_tools` | `Array<Tool>` | Tools discovered from MCP servers |
@@ -239,7 +242,9 @@ robot.call(result)
239
242
  # => SimpleFlow::Result
240
243
  ```
241
244
 
242
- SimpleFlow step interface. Extracts the message from `result.context[:run_params]`, calls `run`, and wraps the output in a continued `SimpleFlow::Result`.
245
+ SimpleFlow step interface. Extracts the message from `result.context[:run_params]`, calls `run`, and wraps the output in a continued `SimpleFlow::Result`. Automatically records `RobotResult#duration` (elapsed seconds).
246
+
247
+ If the robot raises any exception during 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 pipeline.
243
248
 
244
249
  Override this method in subclasses for custom routing logic (e.g., classifiers).
245
250
 
@@ -401,6 +406,142 @@ bot.with_bus(bus1) # joins bus1
401
406
  bot.with_bus(bus2) # leaves bus1, joins bus2
402
407
  ```
403
408
 
409
+ ### connect_mcp!
410
+
411
+ ```ruby
412
+ robot.connect_mcp!
413
+ # => self
414
+ ```
415
+
416
+ Eagerly connect to configured MCP servers and discover tools. Normally MCP connections are lazy (established on first `run`). Call this to connect early, e.g., to display connection status at startup.
417
+
418
+ **Returns:** `self`
419
+
420
+ ### failed_mcp_server_names
421
+
422
+ ```ruby
423
+ robot.failed_mcp_server_names
424
+ # => Array<String>
425
+ ```
426
+
427
+ Returns server names that failed to connect. Useful for displaying connection status or deciding whether to retry.
428
+
429
+ ### inject_mcp!
430
+
431
+ ```ruby
432
+ robot.inject_mcp!(clients: mcp_clients, tools: mcp_tools)
433
+ # => self
434
+ ```
435
+
436
+ Inject pre-connected MCP clients and their tools into this robot. Used by host applications that manage MCP connections externally and need to pass them to robots without re-connecting.
437
+
438
+ **Parameters:**
439
+
440
+ | Name | Type | Description |
441
+ |------|------|-------------|
442
+ | `clients` | `Hash<String, MCP::Client>` | Connected MCP clients keyed by server name |
443
+ | `tools` | `Array<Tool>` | Tools discovered from the MCP servers |
444
+
445
+ **Returns:** `self`
446
+
447
+ **Example:**
448
+
449
+ ```ruby
450
+ # Host app manages MCP connections
451
+ clients = { "github" => github_client }
452
+ tools = github_client.list_tools.map { |t| RobotLab::Tool.from_mcp(t) }
453
+
454
+ robot.inject_mcp!(clients: clients, tools: tools)
455
+ ```
456
+
457
+ ### chat
458
+
459
+ ```ruby
460
+ robot.chat
461
+ # => RubyLLM::Chat
462
+ ```
463
+
464
+ Access the underlying `RubyLLM::Chat` instance. Useful for checkpoint/restore operations that need direct access to conversation state.
465
+
466
+ ### messages
467
+
468
+ ```ruby
469
+ robot.messages
470
+ # => Array<RubyLLM::Message>
471
+ ```
472
+
473
+ Return the conversation messages from the underlying chat.
474
+
475
+ ### clear_messages
476
+
477
+ ```ruby
478
+ robot.clear_messages(keep_system: true)
479
+ # => self
480
+ ```
481
+
482
+ Clear conversation messages, optionally keeping the system prompt.
483
+
484
+ **Parameters:**
485
+
486
+ | Name | Type | Default | Description |
487
+ |------|------|---------|-------------|
488
+ | `keep_system` | `Boolean` | `true` | Whether to preserve the system message |
489
+
490
+ **Returns:** `self`
491
+
492
+ ### replace_messages
493
+
494
+ ```ruby
495
+ robot.replace_messages(messages)
496
+ # => self
497
+ ```
498
+
499
+ Replace conversation messages with a saved set. Useful for checkpoint/restore workflows.
500
+
501
+ **Parameters:**
502
+
503
+ | Name | Type | Description |
504
+ |------|------|-------------|
505
+ | `messages` | `Array<RubyLLM::Message>` | The messages to restore |
506
+
507
+ **Returns:** `self`
508
+
509
+ **Example:**
510
+
511
+ ```ruby
512
+ # Save a checkpoint
513
+ saved = robot.messages.dup
514
+
515
+ # ... later, restore it
516
+ robot.replace_messages(saved)
517
+ ```
518
+
519
+ ### chat_provider
520
+
521
+ ```ruby
522
+ robot.chat_provider
523
+ # => String or nil
524
+ ```
525
+
526
+ Return the provider for this robot's chat. Useful for displaying model/provider info without reaching into chat internals.
527
+
528
+ ### mcp_client
529
+
530
+ ```ruby
531
+ robot.mcp_client("github")
532
+ # => MCP::Client or nil
533
+ ```
534
+
535
+ Find an MCP client by server name.
536
+
537
+ **Parameters:**
538
+
539
+ | Name | Type | Description |
540
+ |------|------|-------------|
541
+ | `server_name` | `String` | The MCP server name |
542
+
543
+ **Returns:** `MCP::Client` or `nil`
544
+
404
545
  ### disconnect
405
546
 
406
547
  ```ruby
@@ -653,6 +794,18 @@ robot = RobotLab.build(
653
794
  result = robot.run("What is 15 * 7?")
654
795
  ```
655
796
 
797
+ ### Robot with Local Provider
798
+
799
+ ```ruby
800
+ robot = RobotLab.build(
801
+ name: "local_bot",
802
+ model: "llama3.2",
803
+ provider: :ollama,
804
+ system_prompt: "You are helpful."
805
+ )
806
+ result = robot.run("Hello!")
807
+ ```
808
+
656
809
  ### Robot with MCP
657
810
 
658
811
  ```ruby
@@ -36,6 +36,7 @@ Accepts either a `Server` instance or a Hash configuration. When a Hash is provi
36
36
  |-----|------|----------|-------------|
37
37
  | `name` | `String` | Yes | Server identifier |
38
38
  | `transport` | `Hash` | Yes | Transport configuration (must include `type`) |
39
+ | `timeout` | `Numeric` | No | Request timeout in seconds (default: 15). Propagated to the transport layer |
39
40
 
40
41
  **Raises:** `ArgumentError` if the config is neither a `Server` nor a `Hash`.
41
42
 
@@ -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
@@ -291,6 +291,24 @@ network = RobotLab.create_network(name: "multi_analysis") do
291
291
  end
292
292
  ```
293
293
 
294
+ ### Pipeline Error Resilience
295
+
296
+ 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:
297
+
298
+ ```ruby
299
+ # If billing_robot raises an error, the network continues
300
+ # The error is available in the result context:
301
+ result = network.run(message: "Process this")
302
+ billing_result = result.context[:billing]
303
+
304
+ if billing_result&.last_text_content&.start_with?("Error:")
305
+ puts "Billing failed: #{billing_result.last_text_content}"
306
+ puts "Took: #{billing_result.duration}s"
307
+ end
308
+ ```
309
+
310
+ Each robot's `RobotResult` includes a `duration` field (elapsed seconds) that is set automatically during pipeline execution, even for errored results.
311
+
294
312
  ### Conditional Continuation
295
313
 
296
314
  A robot can halt execution early:
@@ -101,6 +101,26 @@ Global (RobotLab.config.mcp)
101
101
  -> Runtime (robot.run("msg", mcp: [...]))
102
102
  ```
103
103
 
104
+ ## Timeout Configuration
105
+
106
+ All transports support a configurable request timeout. The default is 15 seconds. Set a custom timeout at the server level:
107
+
108
+ ```ruby
109
+ robot = RobotLab.build(
110
+ name: "patient_bot",
111
+ system_prompt: "You help with slow operations.",
112
+ mcp: [
113
+ {
114
+ name: "heavy_server",
115
+ transport: { type: "stdio", command: "heavy-mcp-server" },
116
+ timeout: 60 # seconds
117
+ }
118
+ ]
119
+ )
120
+ ```
121
+
122
+ Values >= 1000 are auto-converted from milliseconds to seconds. The minimum timeout is 1 second.
123
+
104
124
  ## Transport Types
105
125
 
106
126
  ### Stdio Transport
@@ -290,6 +310,50 @@ client.list_resources # => Array of resource definitions
290
310
  client.disconnect
291
311
  ```
292
312
 
313
+ ## Connection Resilience
314
+
315
+ ### Eager Connection
316
+
317
+ By default, MCP connections are lazy — established on the first `run()` call. Use `connect_mcp!` to connect early:
318
+
319
+ ```ruby
320
+ robot = RobotLab.build(
321
+ name: "assistant",
322
+ system_prompt: "You help with tasks.",
323
+ mcp: [
324
+ { name: "github", transport: { type: "stdio", command: "mcp-server-github" } },
325
+ { name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
326
+ ]
327
+ )
328
+
329
+ robot.connect_mcp!
330
+
331
+ # Check which servers failed
332
+ if robot.failed_mcp_server_names.any?
333
+ puts "Failed to connect: #{robot.failed_mcp_server_names.join(', ')}"
334
+ end
335
+ ```
336
+
337
+ ### Automatic Retry
338
+
339
+ Failed MCP servers are automatically retried on subsequent `run()` calls. If a server was down when the robot first connected, it will be retried transparently:
340
+
341
+ ```ruby
342
+ robot.run("First message") # github connects, filesystem fails
343
+ # ... filesystem comes back up ...
344
+ robot.run("Second message") # filesystem retried and connects
345
+ ```
346
+
347
+ ### Injecting External MCP Clients
348
+
349
+ Host applications that manage MCP connections externally can inject pre-connected clients into a robot:
350
+
351
+ ```ruby
352
+ robot.inject_mcp!(clients: my_clients, tools: my_tools)
353
+ ```
354
+
355
+ This skips the normal connection process and marks the robot as MCP-initialized.
356
+
293
357
  ## Error Handling
294
358
 
295
359
  ### Connection Errors
@@ -302,8 +366,16 @@ rescue RobotLab::MCPError => e
302
366
  end
303
367
  ```
304
368
 
305
- !!! tip
306
- MCP connection failures are logged as warnings but do not raise errors by default. The robot will continue without MCP tools if a server is unreachable.
369
+ MCP connection failures are logged as warnings but do not raise errors by default. The robot will continue without MCP tools if a server is unreachable. One failing server does not prevent other servers from connecting.
370
+
371
+ ### Timeout Errors
372
+
373
+ Stdio transports wrap all blocking I/O with a configurable timeout. If a server does not respond within the timeout period, an `MCPError` is raised with a descriptive message:
374
+
375
+ ```ruby
376
+ # Server that takes too long will raise:
377
+ # RobotLab::MCPError: MCP server 'heavy-server' did not respond within 15s
378
+ ```
307
379
 
308
380
  ## Disconnecting
309
381
 
@@ -0,0 +1 @@
1
+ /output/
@@ -59,7 +59,7 @@ end
59
59
 
60
60
  bus = TypedBus::MessageBus.new
61
61
  display = Display.new(
62
- scout_path: File.join(__dir__, "scout_notes.md"),
62
+ scout_path: File.join(__dir__, "output", "scout_notes.md"),
63
63
  log_path: log_path
64
64
  )
65
65
 
@@ -162,15 +162,17 @@ module RobotLab
162
162
  end
163
163
 
164
164
  def create_transport
165
+ config = @server.transport.merge(timeout: @server.timeout)
166
+
165
167
  case @server.transport_type
166
168
  when "stdio"
167
- Transports::Stdio.new(@server.transport)
169
+ Transports::Stdio.new(config)
168
170
  when "ws", "websocket"
169
- Transports::WebSocket.new(@server.transport)
171
+ Transports::WebSocket.new(config)
170
172
  when "sse"
171
- Transports::SSE.new(@server.transport)
173
+ Transports::SSE.new(config)
172
174
  when "streamable-http", "http"
173
- Transports::StreamableHTTP.new(@server.transport)
175
+ Transports::StreamableHTTP.new(config)
174
176
  else
175
177
  raise MCPError, "Unsupported transport type: #{@server.transport_type}"
176
178
  end
@@ -24,20 +24,28 @@ module RobotLab
24
24
  # Valid transport types for MCP connections
25
25
  VALID_TRANSPORT_TYPES = %w[stdio sse ws websocket streamable-http http].freeze
26
26
 
27
+ # Default timeout for MCP requests (in seconds)
28
+ DEFAULT_TIMEOUT = 15
29
+
27
30
  # @!attribute [r] name
28
31
  # @return [String] the server name
29
32
  # @!attribute [r] transport
30
33
  # @return [Hash] the transport configuration
31
- attr_reader :name, :transport
34
+ # @!attribute [r] timeout
35
+ # @return [Numeric] request timeout in seconds
36
+ attr_reader :name, :transport, :timeout
32
37
 
33
38
  # Creates a new Server configuration.
34
39
  #
35
40
  # @param name [String] the server name
36
41
  # @param transport [Hash] the transport configuration
42
+ # @param timeout [Numeric, nil] request timeout in seconds (default: 15)
43
+ # @param _extra [Hash] additional keys are silently ignored for forward compatibility
37
44
  # @raise [ArgumentError] if transport type is invalid or required fields are missing
38
- def initialize(name:, transport:)
45
+ def initialize(name:, transport:, timeout: nil, **_extra)
39
46
  @name = name.to_s
40
47
  @transport = normalize_transport(transport)
48
+ @timeout = normalize_timeout(timeout)
41
49
  validate!
42
50
  end
43
51
 
@@ -54,7 +62,8 @@ module RobotLab
54
62
  def to_h
55
63
  {
56
64
  name: name,
57
- transport: transport
65
+ transport: transport,
66
+ timeout: timeout
58
67
  }
59
68
  end
60
69
 
@@ -79,6 +88,15 @@ module RobotLab
79
88
  raise ArgumentError, "Transport requires :url" unless transport[:url]
80
89
  end
81
90
  end
91
+
92
+ def normalize_timeout(value)
93
+ return DEFAULT_TIMEOUT if value.nil?
94
+
95
+ seconds = value.to_f
96
+ # If the caller passed milliseconds (>= 1000), convert to seconds
97
+ seconds = seconds / 1000.0 if seconds >= 1000
98
+ [seconds, 1].max
99
+ end
82
100
  end
83
101
  end
84
102
  end
@@ -8,15 +8,23 @@ module RobotLab
8
8
  # @abstract Subclass and implement {#connect}, {#send_request}, {#close}
9
9
  #
10
10
  class Base
11
+ # Default timeout for request operations (in seconds)
12
+ DEFAULT_TIMEOUT = 15
13
+
11
14
  # @!attribute [r] config
12
15
  # @return [Hash] the transport configuration
13
- attr_reader :config
16
+ # @!attribute [r] timeout
17
+ # @return [Numeric] request timeout in seconds
18
+ attr_reader :config, :timeout
14
19
 
15
20
  # Creates a new transport instance.
16
21
  #
17
- # @param config [Hash] transport configuration options
22
+ # @param config [Hash] transport configuration options.
23
+ # May include a :timeout key (in seconds) that controls how long
24
+ # blocking operations wait before raising an error.
18
25
  def initialize(config)
19
26
  @config = config.transform_keys(&:to_sym)
27
+ @timeout = @config.delete(:timeout) || DEFAULT_TIMEOUT
20
28
  end
21
29
 
22
30
  # Connect to the server
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "json"
5
+ require "timeout"
5
6
 
6
7
  module RobotLab
7
8
  module MCP
@@ -9,12 +10,13 @@ module RobotLab
9
10
  # StdIO transport for local MCP servers
10
11
  #
11
12
  # Spawns a subprocess and communicates via stdin/stdout.
13
+ # All blocking I/O is wrapped with a configurable timeout
14
+ # so a missing or hung server cannot block the caller forever.
12
15
  #
13
16
  # @example
14
17
  # transport = Stdio.new(
15
- # command: "mcp-server-filesystem",
16
- # args: ["--root", "/data"],
17
- # env: { "DEBUG" => "true" }
18
+ # { command: "mcp-server-filesystem", args: ["--root", "/data"] },
19
+ # timeout: 10
18
20
  # )
19
21
  #
20
22
  class Stdio < Base
@@ -24,6 +26,7 @@ module RobotLab
24
26
  # @option config [String] :command the command to execute
25
27
  # @option config [Array<String>] :args command arguments
26
28
  # @option config [Hash] :env environment variables
29
+ # @option config [Numeric] :timeout request timeout in seconds (default: 15)
27
30
  def initialize(config)
28
31
  super
29
32
  @stdin = nil
@@ -36,7 +39,8 @@ module RobotLab
36
39
  # Connect to the MCP server via stdio.
37
40
  #
38
41
  # @return [self]
39
- # @raise [MCPError] if connection fails
42
+ # @raise [MCPError] if the server process cannot be started or does not
43
+ # respond to the MCP initialize handshake within the timeout period
40
44
  def connect
41
45
  return self if @connected
42
46
 
@@ -44,44 +48,59 @@ module RobotLab
44
48
  args = @config[:args] || []
45
49
  env = @config[:env] || {}
46
50
 
47
- # Merge with current environment
48
51
  full_env = ENV.to_h.merge(env.transform_keys(&:to_s))
49
52
 
50
53
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(full_env, command, *args)
54
+
55
+ # Verify the process actually started
56
+ unless @wait_thread.alive?
57
+ raise MCPError, "MCP server process exited immediately (command: #{command})"
58
+ end
59
+
51
60
  @connected = true
52
61
 
53
- # Initialize MCP protocol
62
+ # Initialize MCP protocol — this must complete within the timeout
54
63
  send_initialize
55
64
 
56
65
  self
66
+ rescue Errno::ENOENT => e
67
+ cleanup_process
68
+ raise MCPError, "MCP server command not found: #{command} (#{e.message})"
69
+ rescue Timeout::Error
70
+ cleanup_process
71
+ raise MCPError, "MCP server '#{command}' did not respond within #{@timeout}s"
57
72
  end
58
73
 
59
74
  # Send a JSON-RPC request to the MCP server.
60
75
  #
61
76
  # @param message [Hash] JSON-RPC message
62
77
  # @return [Hash] the response
63
- # @raise [MCPError] if not connected or no response
78
+ # @raise [MCPError] if not connected, no response, or timeout
64
79
  def send_request(message)
65
80
  raise MCPError, "Not connected" unless @connected
66
81
 
67
- # Write JSON-RPC message
68
- json = message.to_json
69
- @stdin.puts(json)
70
- @stdin.flush
82
+ Timeout.timeout(@timeout, Timeout::Error) do
83
+ json = message.to_json
84
+ @stdin.puts(json)
85
+ @stdin.flush
71
86
 
72
- # Read response, skipping notifications
73
- loop do
74
- response_line = @stdout.gets
75
- raise MCPError, "No response from MCP server" unless response_line
87
+ loop do
88
+ response_line = @stdout.gets
89
+ raise MCPError, "No response from MCP server (EOF on stdout)" unless response_line
76
90
 
77
- parsed = JSON.parse(response_line, symbolize_names: true)
91
+ parsed = JSON.parse(response_line, symbolize_names: true)
78
92
 
79
- # Skip notifications (messages without an id)
80
- next if parsed[:method] && !parsed.key?(:id)
93
+ # Skip notifications (messages without an id)
94
+ next if parsed[:method] && !parsed.key?(:id)
81
95
 
82
- # Return responses (messages with an id)
83
- return parsed
96
+ return parsed
97
+ end
84
98
  end
99
+ rescue Timeout::Error
100
+ raise MCPError, "MCP server did not respond within #{@timeout}s"
101
+ rescue Errno::EPIPE, IOError => e
102
+ @connected = false
103
+ raise MCPError, "MCP server connection lost: #{e.message}"
85
104
  end
86
105
 
87
106
  # Close the connection to the MCP server.
@@ -90,12 +109,7 @@ module RobotLab
90
109
  def close
91
110
  return self unless @connected
92
111
 
93
- @stdin&.close
94
- @stdout&.close
95
- @stderr&.close
96
- @wait_thread&.kill if @wait_thread&.alive?
97
-
98
- @connected = false
112
+ cleanup_process
99
113
  self
100
114
  end
101
115
 
@@ -127,6 +141,18 @@ module RobotLab
127
141
  @stdin.puts({ jsonrpc: "2.0", method: "notifications/initialized" }.to_json)
128
142
  @stdin.flush
129
143
  end
144
+
145
+ def cleanup_process
146
+ @connected = false
147
+ @stdin&.close rescue nil
148
+ @stdout&.close rescue nil
149
+ @stderr&.close rescue nil
150
+ @wait_thread&.kill if @wait_thread&.alive?
151
+ @stdin = nil
152
+ @stdout = nil
153
+ @stderr = nil
154
+ @wait_thread = nil
155
+ end
130
156
  end
131
157
  end
132
158
  end
@@ -26,17 +26,22 @@ module RobotLab
26
26
  end
27
27
 
28
28
 
29
- # Ensure MCP clients are initialized for the given server configs
29
+ # Ensure MCP clients are initialized for the given server configs.
30
+ # On subsequent calls, retries any servers that previously failed to connect.
30
31
  def ensure_mcp_clients(mcp_servers)
31
32
  return if mcp_servers.empty?
32
33
 
33
34
  needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
34
- return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
35
35
 
36
- disconnect if @mcp_initialized
36
+ if @mcp_initialized
37
+ # Already initialized — retry any servers that are needed but not connected
38
+ retry_failed_servers(mcp_servers, needed_servers)
39
+ return
40
+ end
37
41
 
38
42
  @mcp_clients = {}
39
43
  @mcp_tools = []
44
+ @failed_mcp_configs = {}
40
45
 
41
46
  mcp_servers.each do |server_config|
42
47
  init_mcp_client(server_config)
@@ -55,8 +60,48 @@ module RobotLab
55
60
  @mcp_clients[server_name] = client
56
61
  discover_mcp_tools(client, server_name)
57
62
  else
63
+ server_name = extract_server_name(server_config)
64
+ @failed_mcp_configs[server_name] = server_config
58
65
  RobotLab.config.logger.warn(
59
- "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
66
+ "Robot '#{@name}' failed to connect to MCP server: #{server_name}"
67
+ )
68
+ end
69
+ rescue StandardError => e
70
+ server_name = extract_server_name(server_config)
71
+ @failed_mcp_configs[server_name] = server_config
72
+ RobotLab.config.logger.warn(
73
+ "Robot '#{@name}' error connecting to MCP server '#{server_name}': #{e.message}"
74
+ )
75
+ end
76
+
77
+
78
+ # Retry connecting to servers that previously failed
79
+ def retry_failed_servers(mcp_servers, needed_servers)
80
+ return if @failed_mcp_configs.nil? || @failed_mcp_configs.empty?
81
+
82
+ # Only retry servers that are still needed and still failed
83
+ to_retry = @failed_mcp_configs.select { |name, _| needed_servers.include?(name) }
84
+ return if to_retry.empty?
85
+
86
+ to_retry.each do |name, server_config|
87
+ RobotLab.config.logger.info(
88
+ "Robot '#{@name}' retrying MCP server: #{name}"
89
+ )
90
+
91
+ client = MCP::Client.new(server_config)
92
+ client.connect
93
+
94
+ if client.connected?
95
+ @mcp_clients[name] = client
96
+ @failed_mcp_configs.delete(name)
97
+ discover_mcp_tools(client, name)
98
+ RobotLab.config.logger.info(
99
+ "Robot '#{@name}' successfully connected to MCP server '#{name}' on retry"
100
+ )
101
+ end
102
+ rescue StandardError => e
103
+ RobotLab.config.logger.warn(
104
+ "Robot '#{@name}' retry failed for MCP server '#{name}': #{e.message}"
60
105
  )
61
106
  end
62
107
  end
@@ -83,6 +128,18 @@ module RobotLab
83
128
  "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
84
129
  )
85
130
  end
131
+
132
+
133
+ def extract_server_name(server_config)
134
+ case server_config
135
+ when Hash
136
+ server_config[:name] || server_config['name'] || server_config.to_s
137
+ when MCP::Server
138
+ server_config.name
139
+ else
140
+ server_config.to_s
141
+ end
142
+ end
86
143
  end
87
144
  end
88
145
  end
@@ -66,7 +66,7 @@ module RobotLab
66
66
 
67
67
  attr_reader :name, :description, :template, :system_prompt,
68
68
  :local_tools, :mcp_clients, :mcp_tools, :memory,
69
- :bus, :outbox, :config, :skills
69
+ :bus, :outbox, :config, :skills, :provider
70
70
 
71
71
  # @!attribute [r] mcp_config
72
72
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -108,6 +108,7 @@ module RobotLab
108
108
  description: nil,
109
109
  local_tools: [],
110
110
  model: nil,
111
+ provider: nil,
111
112
  mcp_servers: [],
112
113
  mcp: :none,
113
114
  tools: :none,
@@ -187,6 +188,15 @@ module RobotLab
187
188
  resolved_model = @config.model || config.ruby_llm.model
188
189
  chat_kwargs = { model: resolved_model }
189
190
 
191
+ # Pass provider through for local providers (Ollama, GPUStack, etc.)
192
+ # RubyLLM auto-sets assume_model_exists for local providers when
193
+ # provider is specified.
194
+ @provider = provider
195
+ if @provider
196
+ chat_kwargs[:provider] = @provider
197
+ chat_kwargs[:assume_model_exists] = true
198
+ end
199
+
190
200
  # Create the persistent chat via Agent's initialize
191
201
  super(chat: nil, **chat_kwargs)
192
202
 
@@ -354,11 +364,26 @@ module RobotLab
354
364
  # Extract the message from run context
355
365
  message = run_context.delete(:message)
356
366
 
367
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
357
368
  robot_result = run(message, **run_context)
369
+ robot_result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
358
370
 
359
371
  result
360
372
  .with_context(@name.to_sym, robot_result)
361
373
  .continue(robot_result)
374
+ rescue Exception => e # rubocop:disable Lint/RescueException
375
+ # Catch all errors (including SecurityError, Timeout::Error, etc.)
376
+ # so one failing robot doesn't crash the entire network pipeline.
377
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
378
+ error_result = RobotResult.new(
379
+ robot_name: @name,
380
+ output: [TextMessage.new(role: 'assistant', content: "Error: #{e.class}: #{e.message}")]
381
+ )
382
+ error_result.duration = elapsed
383
+
384
+ result
385
+ .with_context(@name.to_sym, error_result)
386
+ .continue(error_result)
362
387
  end
363
388
 
364
389
 
@@ -371,6 +396,26 @@ module RobotLab
371
396
  end
372
397
 
373
398
 
399
+ # Eagerly connect to configured MCP servers and discover tools.
400
+ # Normally MCP connections are lazy (established on first run).
401
+ # Call this to connect early, e.g. to display connection status at startup.
402
+ #
403
+ # @return [self]
404
+ def connect_mcp!
405
+ resolved_mcp = resolve_mcp_hierarchy(@mcp_config)
406
+ ensure_mcp_clients(resolved_mcp) if resolved_mcp.is_a?(Array) && resolved_mcp.any?
407
+ self
408
+ end
409
+
410
+ # Returns server names that failed to connect.
411
+ #
412
+ # @return [Array<String>]
413
+ def failed_mcp_server_names
414
+ return [] unless @failed_mcp_configs
415
+
416
+ @failed_mcp_configs.keys
417
+ end
418
+
374
419
  # Disconnect all MCP clients and bus channel.
375
420
  #
376
421
  # @return [self]
@@ -381,6 +426,83 @@ module RobotLab
381
426
  end
382
427
 
383
428
 
429
+ # --- Public APIs for external MCP and history management (A4) ---
430
+
431
+ # Inject pre-connected MCP clients and their tools into this robot.
432
+ # Used by host applications (e.g. AIA) that manage MCP connections
433
+ # externally and need to pass them to robots without re-connecting.
434
+ #
435
+ # @param clients [Hash<String, MCP::Client>] connected MCP clients by server name
436
+ # @param tools [Array<Tool>] tools discovered from the MCP servers
437
+ # @return [self]
438
+ def inject_mcp!(clients:, tools:)
439
+ @mcp_clients = clients
440
+ @mcp_tools = tools
441
+ @mcp_initialized = true
442
+ self
443
+ end
444
+
445
+ # Access the underlying RubyLLM::Chat instance.
446
+ # Useful for checkpoint/restore operations that need direct
447
+ # access to conversation state.
448
+ #
449
+ # @return [RubyLLM::Chat]
450
+ def chat
451
+ @chat
452
+ end
453
+
454
+ # Return the conversation messages from the underlying chat.
455
+ #
456
+ # @return [Array<RubyLLM::Message>]
457
+ def messages
458
+ @chat.messages
459
+ end
460
+
461
+ # Clear conversation messages, optionally keeping the system prompt.
462
+ #
463
+ # @param keep_system [Boolean] whether to preserve the system message
464
+ # @return [self]
465
+ def clear_messages(keep_system: true)
466
+ if keep_system
467
+ system_msg = @chat.messages.find { |m| m.role == :system }
468
+ @chat.instance_variable_set(:@messages, [])
469
+ @chat.add_message(system_msg) if system_msg
470
+ else
471
+ @chat.instance_variable_set(:@messages, [])
472
+ end
473
+ self
474
+ end
475
+
476
+ # Replace conversation messages with a saved set (for checkpoint restore).
477
+ #
478
+ # @param messages [Array<RubyLLM::Message>] the messages to restore
479
+ # @return [self]
480
+ def replace_messages(messages)
481
+ @chat.instance_variable_set(:@messages, messages)
482
+ self
483
+ end
484
+
485
+ # Return the provider for this robot's chat.
486
+ # Useful for displaying model/provider info without reaching
487
+ # into chat internals.
488
+ #
489
+ # @return [String, nil]
490
+ def chat_provider
491
+ m = @chat.model
492
+ m.respond_to?(:provider) ? m.provider : nil
493
+ rescue StandardError
494
+ nil
495
+ end
496
+
497
+ # Find an MCP client by server name.
498
+ #
499
+ # @param server_name [String] the MCP server name
500
+ # @return [MCP::Client, nil]
501
+ def mcp_client(server_name)
502
+ @mcp_clients[server_name]
503
+ end
504
+
505
+
384
506
  # Converts the robot to a hash representation
385
507
  #
386
508
  # @return [Hash]
@@ -460,7 +582,8 @@ module RobotLab
460
582
  robot_name: @name,
461
583
  output: output,
462
584
  tool_calls: normalize_tool_calls(tool_calls),
463
- stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil
585
+ stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
586
+ raw: response
464
587
  )
465
588
  end
466
589
 
@@ -30,13 +30,15 @@ module RobotLab
30
30
  # @return [String, nil] reason execution stopped
31
31
  attr_reader :robot_name, :output, :tool_calls, :created_at, :id, :stop_reason
32
32
 
33
+ # @!attribute [rw] duration
34
+ # @return [Float, nil] elapsed seconds for this run
33
35
  # @!attribute [rw] prompt
34
36
  # @return [Array<Message>, nil] the prompt messages used (debug)
35
37
  # @!attribute [rw] history
36
38
  # @return [Array<Message>, nil] the history used (debug)
37
39
  # @!attribute [rw] raw
38
40
  # @return [Object, nil] the raw LLM response (debug)
39
- attr_accessor :prompt, :history, :raw
41
+ attr_accessor :duration, :prompt, :history, :raw
40
42
 
41
43
  # Creates a new RobotResult instance.
42
44
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.0.8"
4
+ VERSION = "0.0.9"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -273,6 +273,7 @@ files:
273
273
  - examples/11_network_introspection.rb
274
274
  - examples/12_message_bus.rb
275
275
  - examples/13_spawn.rb
276
+ - examples/14_rusty_circuit/.gitignore
276
277
  - examples/14_rusty_circuit/comic.rb
277
278
  - examples/14_rusty_circuit/display.rb
278
279
  - examples/14_rusty_circuit/heckler.rb
@@ -463,7 +464,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
463
464
  - !ruby/object:Gem::Version
464
465
  version: '0'
465
466
  requirements: []
466
- rubygems_version: 4.0.6
467
+ rubygems_version: 4.0.7
467
468
  specification_version: 4
468
469
  summary: Ruby framework for building and orchestrating multi-robot LLM workflows
469
470
  test_files: []