robot_lab 0.0.7 → 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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -3
  3. data/README.md +125 -26
  4. data/Rakefile +39 -1
  5. data/docs/api/core/robot.md +284 -8
  6. data/docs/api/core/tool.md +28 -2
  7. data/docs/api/mcp/client.md +1 -0
  8. data/docs/api/mcp/server.md +27 -8
  9. data/docs/api/mcp/transports.md +21 -6
  10. data/docs/architecture/core-concepts.md +1 -1
  11. data/docs/architecture/robot-execution.md +20 -2
  12. data/docs/concepts.md +11 -3
  13. data/docs/examples/index.md +1 -1
  14. data/docs/examples/rails-application.md +1 -1
  15. data/docs/guides/building-robots.md +245 -11
  16. data/docs/guides/creating-networks.md +18 -0
  17. data/docs/guides/mcp-integration.md +74 -2
  18. data/docs/guides/rails-integration.md +145 -17
  19. data/docs/guides/using-tools.md +45 -4
  20. data/docs/index.md +62 -6
  21. data/examples/05_streaming.rb +113 -94
  22. data/examples/14_rusty_circuit/.gitignore +1 -0
  23. data/examples/14_rusty_circuit/open_mic.rb +1 -1
  24. data/examples/17_skills.rb +242 -0
  25. data/examples/18_rails/.envrc +3 -0
  26. data/examples/18_rails/.gitignore +5 -0
  27. data/examples/18_rails/Gemfile +10 -0
  28. data/examples/18_rails/README.md +48 -0
  29. data/examples/18_rails/Rakefile +4 -0
  30. data/examples/18_rails/app/controllers/application_controller.rb +4 -0
  31. data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
  32. data/examples/18_rails/app/jobs/application_job.rb +4 -0
  33. data/examples/18_rails/app/jobs/robot_run_job.rb +79 -0
  34. data/examples/18_rails/app/models/application_record.rb +5 -0
  35. data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
  36. data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
  37. data/examples/18_rails/app/robots/chat_robot.rb +14 -0
  38. data/examples/18_rails/app/tools/time_tool.rb +9 -0
  39. data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
  40. data/examples/18_rails/app/views/chat/index.html.erb +67 -0
  41. data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
  42. data/examples/18_rails/bin/dev +7 -0
  43. data/examples/18_rails/bin/rails +6 -0
  44. data/examples/18_rails/bin/setup +15 -0
  45. data/examples/18_rails/config/application.rb +33 -0
  46. data/examples/18_rails/config/cable.yml +2 -0
  47. data/examples/18_rails/config/database.yml +5 -0
  48. data/examples/18_rails/config/environment.rb +4 -0
  49. data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
  50. data/examples/18_rails/config/routes.rb +6 -0
  51. data/examples/18_rails/config.ru +4 -0
  52. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
  53. data/examples/README.md +30 -0
  54. data/examples/prompts/audit_trail.md +8 -0
  55. data/examples/prompts/incident_responder.md +18 -0
  56. data/examples/prompts/parameterized_main_test.md +6 -0
  57. data/examples/prompts/pii_redactor.md +7 -0
  58. data/examples/prompts/runbook_protocol.md +12 -0
  59. data/examples/prompts/skill_a_test.md +4 -0
  60. data/examples/prompts/skill_b_test.md +4 -0
  61. data/examples/prompts/skill_config_test.md +5 -0
  62. data/examples/prompts/skill_cycle_a_test.md +6 -0
  63. data/examples/prompts/skill_cycle_b_test.md +6 -0
  64. data/examples/prompts/skill_description_test.md +4 -0
  65. data/examples/prompts/skill_leaf_test.md +4 -0
  66. data/examples/prompts/skill_nested_test.md +6 -0
  67. data/examples/prompts/skill_refs_main_test.md +6 -0
  68. data/examples/prompts/skill_self_ref_test.md +6 -0
  69. data/examples/prompts/skill_with_params_test.md +6 -0
  70. data/examples/prompts/sre_compliance.md +10 -0
  71. data/examples/prompts/structured_output.md +7 -0
  72. data/examples/prompts/template_with_skills_test.md +6 -0
  73. data/lib/generators/robot_lab/install_generator.rb +12 -0
  74. data/lib/generators/robot_lab/templates/initializer.rb.tt +36 -22
  75. data/lib/generators/robot_lab/templates/job.rb.tt +92 -0
  76. data/lib/generators/robot_lab/templates/robot.rb.tt +6 -21
  77. data/lib/generators/robot_lab/templates/robot_test.rb.tt +5 -3
  78. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +41 -35
  79. data/lib/robot_lab/mcp/client.rb +6 -4
  80. data/lib/robot_lab/mcp/server.rb +21 -3
  81. data/lib/robot_lab/mcp/transports/base.rb +10 -2
  82. data/lib/robot_lab/mcp/transports/stdio.rb +52 -26
  83. data/lib/robot_lab/{rails → rails_integration}/engine.rb +1 -1
  84. data/lib/robot_lab/{rails → rails_integration}/railtie.rb +1 -1
  85. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +72 -0
  86. data/lib/robot_lab/robot/mcp_management.rb +61 -4
  87. data/lib/robot_lab/robot/template_rendering.rb +181 -4
  88. data/lib/robot_lab/robot.rb +196 -33
  89. data/lib/robot_lab/robot_result.rb +3 -1
  90. data/lib/robot_lab/run_config.rb +1 -1
  91. data/lib/robot_lab/tool.rb +26 -0
  92. data/lib/robot_lab/version.rb +1 -1
  93. data/lib/robot_lab.rb +6 -4
  94. metadata +55 -19
  95. data/examples/14_rusty_circuit/scout_notes.md +0 -89
@@ -23,13 +23,16 @@ 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,
29
30
  on_tool_call: nil,
30
31
  on_tool_result: nil,
32
+ on_content: nil,
31
33
  enable_cache: true,
32
34
  bus: nil,
35
+ skills: nil,
33
36
  temperature: nil,
34
37
  top_p: nil,
35
38
  top_k: nil,
@@ -52,13 +55,16 @@ Robot.new(
52
55
  | `description` | `String`, `nil` | `nil` | Human-readable description of what the robot does |
53
56
  | `local_tools` | `Array` | `[]` | Tools defined locally (`RubyLLM::Tool` subclasses or `RobotLab::Tool` instances) |
54
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` |
55
59
  | `mcp_servers` | `Array` | `[]` | Legacy MCP server configurations |
56
60
  | `mcp` | `Symbol`, `Array` | `:none` | Hierarchical MCP config (`:none`, `:inherit`, or server array) |
57
61
  | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name array) |
58
62
  | `on_tool_call` | `Proc`, `nil` | `nil` | Callback invoked when a tool is called |
59
63
  | `on_tool_result` | `Proc`, `nil` | `nil` | Callback invoked when a tool returns a result |
64
+ | `on_content` | `Proc`, `nil` | `nil` | Stored streaming callback invoked with each content chunk (see [Streaming](#streaming)) |
60
65
  | `enable_cache` | `Boolean` | `true` | Whether to enable semantic caching |
61
66
  | `bus` | `TypedBus::MessageBus`, `nil` | `nil` | Optional message bus for inter-robot communication |
67
+ | `skills` | `Symbol`, `Array<Symbol>`, `nil` | `nil` | Skill templates to prepend (see [Skills](#skills)) |
62
68
  | `config` | `RunConfig`, `nil` | `nil` | Shared config merged with explicit kwargs (see [RunConfig](#runconfig)) |
63
69
  | `temperature` | `Float`, `nil` | `nil` | Controls randomness (0.0-1.0) |
64
70
  | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold |
@@ -80,6 +86,7 @@ robot = RobotLab.build(
80
86
  context: {},
81
87
  enable_cache: true,
82
88
  bus: nil, # Optional TypedBus::MessageBus
89
+ skills: nil, # Optional skill templates
83
90
  **options # All other Robot.new parameters
84
91
  )
85
92
  # => RobotLab::Robot
@@ -95,6 +102,8 @@ If `name` is omitted, it defaults to `"robot"`.
95
102
  | `description` | `String`, `nil` | Human-readable description |
96
103
  | `template` | `Symbol`, `nil` | Prompt template identifier |
97
104
  | `system_prompt` | `String`, `nil` | Inline system prompt |
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 |
98
107
  | `local_tools` | `Array` | Locally defined tools |
99
108
  | `mcp_clients` | `Hash<String, MCP::Client>` | Connected MCP clients, keyed by server name |
100
109
  | `mcp_tools` | `Array<Tool>` | Tools discovered from MCP servers |
@@ -119,7 +128,7 @@ Used by tools like [`AskUser`](tool.md#built-in-askuser) that need terminal IO.
119
128
  ### run
120
129
 
121
130
  ```ruby
122
- result = robot.run(message, **kwargs)
131
+ result = robot.run(message, **kwargs, &block)
123
132
  # => RobotResult
124
133
  ```
125
134
 
@@ -136,6 +145,9 @@ Primary execution method. Sends a message to the LLM with memory/MCP/tools resol
136
145
  | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override |
137
146
  | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override |
138
147
  | `**kwargs` | `Hash` | `{}` | Additional keyword arguments passed to `Agent#ask` |
148
+ | `&block` | `Proc` | `nil` | Per-call streaming block, receives each content chunk |
149
+
150
+ When both a stored `on_content` callback and a runtime block are provided, both fire (stored first, then runtime block).
139
151
 
140
152
  **Returns:** `RobotResult`
141
153
 
@@ -148,8 +160,8 @@ result = robot.run("What is 2+2?")
148
160
  # With runtime memory
149
161
  result = robot.run("Summarize the data", memory: { data: report })
150
162
 
151
- # With streaming block
152
- result = robot.run("Tell me a story") { |event| print event.text }
163
+ # With per-call streaming block
164
+ result = robot.run("Tell me a story") { |chunk| print chunk.content }
153
165
 
154
166
  # With runtime overrides
155
167
  result = robot.run("Help me", mcp: :none, tools: :none)
@@ -230,7 +242,9 @@ robot.call(result)
230
242
  # => SimpleFlow::Result
231
243
  ```
232
244
 
233
- 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.
234
248
 
235
249
  Override this method in subclasses for custom routing logic (e.g., classifiers).
236
250
 
@@ -392,6 +406,142 @@ bot.with_bus(bus1) # joins bus1
392
406
  bot.with_bus(bus2) # leaves bus1, joins bus2
393
407
  ```
394
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
+
395
545
  ### disconnect
396
546
 
397
547
  ```ruby
@@ -408,7 +558,7 @@ robot.to_h
408
558
  # => Hash
409
559
  ```
410
560
 
411
- Returns a hash representation of the robot including name, description, template, system_prompt, local_tools, mcp_tools, mcp_config, tools_config, mcp_servers, model, and bus (true if configured, omitted otherwise).
561
+ Returns a hash representation of the robot including name, description, template, skills, system_prompt, local_tools, mcp_tools, mcp_config, tools_config, mcp_servers, model, and bus (true if configured, omitted otherwise). Nil values are compacted out.
412
562
 
413
563
  ## Memory Behavior
414
564
 
@@ -437,7 +587,7 @@ Front matter supports two categories of keys:
437
587
 
438
588
  **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` — applied to the underlying chat.
439
589
 
440
- **Robot Extras:** `robot_name`, `description`, `tools`, `mcp` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
590
+ **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
441
591
 
442
592
  | Key | Type | Description |
443
593
  |-----|------|-------------|
@@ -445,6 +595,40 @@ Front matter supports two categories of keys:
445
595
  | `description` | `String` | Human-readable description |
446
596
  | `tools` | `Array<String>` | Tool class names resolved via `Object.const_get` |
447
597
  | `mcp` | `Array<Hash>` | MCP server configurations |
598
+ | `skills` | `Array<Symbol>` | Skill templates to prepend (recursive, with cycle detection) |
599
+
600
+ ## Skills
601
+
602
+ Skills compose robot behaviors from reusable templates. Each skill is a standard `.md` template whose prompt body is prepended before the main template. Skills are expanded depth-first with automatic cycle detection.
603
+
604
+ **Constructor:** `skills:` accepts `Symbol` or `Array<Symbol>`:
605
+
606
+ ```ruby
607
+ robot = RobotLab.build(
608
+ name: "support",
609
+ template: :support,
610
+ skills: [:clarifier, :json_responder]
611
+ )
612
+ ```
613
+
614
+ **Front matter:** templates can declare skills via `skills:` key:
615
+
616
+ ```markdown
617
+ ---
618
+ skills:
619
+ - clarifier
620
+ - json_responder
621
+ ---
622
+ Main template body here.
623
+ ```
624
+
625
+ Constructor `skills:` and front matter `skills:` are combined (constructor first, then front matter). Skills can nest (a skill can declare its own `skills:` in front matter).
626
+
627
+ **Config cascade:** skill config merges in processing order (deepest first). Later values override earlier. Constructor kwargs always win.
628
+
629
+ **Prompt order:** skill bodies are concatenated in expansion order, followed by the main template body. All are joined with `"\n\n"` and set as system instructions via a single `with_instructions` call.
630
+
631
+ **Cycle detection:** if skills form a cycle, the duplicate is skipped with a logger warning.
448
632
 
449
633
  ## RunConfig
450
634
 
@@ -463,10 +647,78 @@ robot = RobotLab.build(
463
647
  robot.config #=> RunConfig with model: "claude-sonnet-4", temperature: 0.9, ...
464
648
  ```
465
649
 
466
- RunConfig fields: `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`, `mcp`, `tools`, `on_tool_call`, `on_tool_result`, `bus`, `enable_cache`.
650
+ RunConfig fields: `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`, `mcp`, `tools`, `on_tool_call`, `on_tool_result`, `on_content`, `bus`, `enable_cache`.
467
651
 
468
652
  See [Configuration: RunConfig](../../getting-started/configuration.md#runconfig-shared-operational-defaults) for full details.
469
653
 
654
+ ## Streaming
655
+
656
+ Robots support two complementary approaches for streaming LLM content in real-time.
657
+
658
+ ### The Chunk Object
659
+
660
+ Both callbacks and blocks receive a [`RubyLLM::Chunk`](https://rubyllm.com/streaming/#basic-streaming) (subclass of `RubyLLM::Message`). Key accessors:
661
+
662
+ | Accessor | Type | Description |
663
+ |----------|------|-------------|
664
+ | `content` | `String`, `nil` | The text delta for this chunk (`nil` on tool-call or usage-only chunks) |
665
+ | `role` | `Symbol` | Always `:assistant` |
666
+ | `model_id` | `String` | The LLM model ID |
667
+ | `tool_calls` | `Array`, `nil` | Tool call deltas (partial JSON arguments) |
668
+ | `tool_call?` | `Boolean` | Whether this chunk contains tool call data |
669
+ | `thinking` | `Thinking`, `nil` | Extended thinking delta (Anthropic only) |
670
+ | `input_tokens` | `Integer`, `nil` | Input token count (populated on final chunk) |
671
+ | `output_tokens` | `Integer`, `nil` | Output token count (populated on final chunk) |
672
+ | `cached_tokens` | `Integer`, `nil` | Cached prompt tokens (final chunk) |
673
+
674
+ Most chunks carry only `content` (the text delta). The final chunk(s) carry token usage counts. Tool call chunks have `tool_calls` instead of `content`.
675
+
676
+ ### Stored Callback (`on_content:`)
677
+
678
+ Wired at build time via constructor or RunConfig. Fires on every `run()` call automatically:
679
+
680
+ ```ruby
681
+ robot = RobotLab.build(
682
+ name: "assistant",
683
+ system_prompt: "You are helpful.",
684
+ on_content: ->(chunk) { broadcast(chunk.content) }
685
+ )
686
+ robot.run("Tell me a story") # streams via stored callback
687
+ ```
688
+
689
+ The `on_content` callback participates in the RunConfig cascade:
690
+
691
+ ```ruby
692
+ config = RobotLab::RunConfig.new(
693
+ on_content: ->(chunk) { log(chunk.content) }
694
+ )
695
+ robot = RobotLab.build(name: "bot", config: config)
696
+ ```
697
+
698
+ Constructor `on_content:` overrides RunConfig `on_content`.
699
+
700
+ ### Per-Call Block
701
+
702
+ Pass a block to `run()` for one-off streaming:
703
+
704
+ ```ruby
705
+ robot.run("Tell me a story") { |chunk| print chunk.content }
706
+ ```
707
+
708
+ ### Both Together
709
+
710
+ When both exist, both fire — stored callback first, then runtime block:
711
+
712
+ ```ruby
713
+ robot = RobotLab.build(
714
+ name: "bot",
715
+ system_prompt: "You are helpful.",
716
+ on_content: ->(chunk) { log(chunk.content) }
717
+ )
718
+ robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
719
+ # log() fires first, then stream_to_client()
720
+ ```
721
+
470
722
  ## Configuration Hierarchy
471
723
 
472
724
  Tools and MCP servers use hierarchical resolution: **runtime > robot > network > global config**.
@@ -542,6 +794,18 @@ robot = RobotLab.build(
542
794
  result = robot.run("What is 15 * 7?")
543
795
  ```
544
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
+
545
809
  ### Robot with MCP
546
810
 
547
811
  ```ruby
@@ -559,6 +823,18 @@ result = robot.run("Search for popular Ruby repos")
559
823
  robot.disconnect
560
824
  ```
561
825
 
826
+ ### Robot with Skills
827
+
828
+ ```ruby
829
+ robot = RobotLab.build(
830
+ name: "support",
831
+ template: :support,
832
+ skills: [:clarifier, :safety, :json_responder],
833
+ context: { company: "Acme Corp" }
834
+ )
835
+ result = robot.run("I need help with my order")
836
+ ```
837
+
562
838
  ### Bare Robot with Chaining
563
839
 
564
840
  ```ruby
@@ -628,6 +904,6 @@ bot.send_message(to: :someone, content: "Hello!")
628
904
 
629
905
  ## See Also
630
906
 
631
- - [Building Robots Guide](../../guides/building-robots.md)
907
+ - [Building Robots Guide](../../guides/building-robots.md) (includes [Composable Skills](../../guides/building-robots.md#composable-skills))
632
908
  - [Tool](tool.md)
633
909
  - [Network](network.md)
@@ -4,7 +4,7 @@ Callable function that robots can use to interact with external systems.
4
4
 
5
5
  ## Class: `RobotLab::Tool < RubyLLM::Tool`
6
6
 
7
- RobotLab::Tool inherits from RubyLLM::Tool, adding a `robot:` constructor parameter and a `Tool.create` factory for dynamic tools.
7
+ RobotLab::Tool inherits from RubyLLM::Tool, adding a `robot:` constructor parameter, a `Tool.create` factory for dynamic tools, and graceful error handling that returns plain-text errors to the LLM instead of crashing the run.
8
8
 
9
9
  ### Subclass Pattern
10
10
 
@@ -51,6 +51,15 @@ Tool.new(robot: nil)
51
51
 
52
52
  ## Class Methods
53
53
 
54
+ ### raise_on_error / raise_on_error?
55
+
56
+ ```ruby
57
+ MyTool.raise_on_error = true
58
+ MyTool.raise_on_error? # => true
59
+ ```
60
+
61
+ Per-class flag controlling whether `call` propagates exceptions from `execute` instead of catching them. Defaults to `false`. Does not affect other tool classes.
62
+
54
63
  ### Tool.create
55
64
 
56
65
  Factory for dynamic tools (MCP wrappers, inline tools).
@@ -169,7 +178,24 @@ Whether this is an MCP-provided tool.
169
178
  result = tool.call(args_hash)
170
179
  ```
171
180
 
172
- Inherited from RubyLLM::Tool. Converts string keys to symbols and calls `execute(**args)`.
181
+ Overrides `RubyLLM::Tool#call` with graceful error handling. Converts string keys to symbols and calls `execute(**args)`. If `execute` raises a `StandardError`, the error is caught and returned as a plain-text string the LLM can reason about:
182
+
183
+ ```
184
+ Error (tool_name): exception message
185
+ ```
186
+
187
+ The error is also logged via `RobotLab.config.logger` at `:warn` level.
188
+
189
+ To propagate exceptions instead of catching them (for critical tools), set `raise_on_error` on the class:
190
+
191
+ ```ruby
192
+ class CriticalTool < RobotLab::Tool
193
+ self.raise_on_error = true
194
+ # ...
195
+ end
196
+ ```
197
+
198
+ `raise_on_error` is per-class (defaults to `false`) and does not affect other tool classes.
173
199
 
174
200
  ### params_schema
175
201
 
@@ -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