robot_lab 0.0.7 → 0.0.8

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -3
  3. data/README.md +99 -23
  4. data/Rakefile +39 -1
  5. data/docs/api/core/robot.md +130 -7
  6. data/docs/api/core/tool.md +28 -2
  7. data/docs/concepts.md +7 -3
  8. data/docs/examples/index.md +1 -1
  9. data/docs/examples/rails-application.md +1 -1
  10. data/docs/guides/building-robots.md +227 -11
  11. data/docs/guides/rails-integration.md +145 -17
  12. data/docs/guides/using-tools.md +45 -4
  13. data/docs/index.md +62 -6
  14. data/examples/05_streaming.rb +113 -94
  15. data/examples/17_skills.rb +242 -0
  16. data/examples/18_rails/.envrc +3 -0
  17. data/examples/18_rails/.gitignore +5 -0
  18. data/examples/18_rails/Gemfile +10 -0
  19. data/examples/18_rails/README.md +48 -0
  20. data/examples/18_rails/Rakefile +4 -0
  21. data/examples/18_rails/app/controllers/application_controller.rb +4 -0
  22. data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
  23. data/examples/18_rails/app/jobs/application_job.rb +4 -0
  24. data/examples/18_rails/app/jobs/robot_run_job.rb +79 -0
  25. data/examples/18_rails/app/models/application_record.rb +5 -0
  26. data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
  27. data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
  28. data/examples/18_rails/app/robots/chat_robot.rb +14 -0
  29. data/examples/18_rails/app/tools/time_tool.rb +9 -0
  30. data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
  31. data/examples/18_rails/app/views/chat/index.html.erb +67 -0
  32. data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
  33. data/examples/18_rails/bin/dev +7 -0
  34. data/examples/18_rails/bin/rails +6 -0
  35. data/examples/18_rails/bin/setup +15 -0
  36. data/examples/18_rails/config/application.rb +33 -0
  37. data/examples/18_rails/config/cable.yml +2 -0
  38. data/examples/18_rails/config/database.yml +5 -0
  39. data/examples/18_rails/config/environment.rb +4 -0
  40. data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
  41. data/examples/18_rails/config/routes.rb +6 -0
  42. data/examples/18_rails/config.ru +4 -0
  43. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
  44. data/examples/README.md +30 -0
  45. data/examples/prompts/audit_trail.md +8 -0
  46. data/examples/prompts/incident_responder.md +18 -0
  47. data/examples/prompts/parameterized_main_test.md +6 -0
  48. data/examples/prompts/pii_redactor.md +7 -0
  49. data/examples/prompts/runbook_protocol.md +12 -0
  50. data/examples/prompts/skill_a_test.md +4 -0
  51. data/examples/prompts/skill_b_test.md +4 -0
  52. data/examples/prompts/skill_config_test.md +5 -0
  53. data/examples/prompts/skill_cycle_a_test.md +6 -0
  54. data/examples/prompts/skill_cycle_b_test.md +6 -0
  55. data/examples/prompts/skill_description_test.md +4 -0
  56. data/examples/prompts/skill_leaf_test.md +4 -0
  57. data/examples/prompts/skill_nested_test.md +6 -0
  58. data/examples/prompts/skill_refs_main_test.md +6 -0
  59. data/examples/prompts/skill_self_ref_test.md +6 -0
  60. data/examples/prompts/skill_with_params_test.md +6 -0
  61. data/examples/prompts/sre_compliance.md +10 -0
  62. data/examples/prompts/structured_output.md +7 -0
  63. data/examples/prompts/template_with_skills_test.md +6 -0
  64. data/lib/generators/robot_lab/install_generator.rb +12 -0
  65. data/lib/generators/robot_lab/templates/initializer.rb.tt +36 -22
  66. data/lib/generators/robot_lab/templates/job.rb.tt +92 -0
  67. data/lib/generators/robot_lab/templates/robot.rb.tt +6 -21
  68. data/lib/generators/robot_lab/templates/robot_test.rb.tt +5 -3
  69. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +41 -35
  70. data/lib/robot_lab/{rails → rails_integration}/engine.rb +1 -1
  71. data/lib/robot_lab/{rails → rails_integration}/railtie.rb +1 -1
  72. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +72 -0
  73. data/lib/robot_lab/robot/template_rendering.rb +181 -4
  74. data/lib/robot_lab/robot.rb +72 -32
  75. data/lib/robot_lab/run_config.rb +1 -1
  76. data/lib/robot_lab/tool.rb +26 -0
  77. data/lib/robot_lab/version.rb +1 -1
  78. data/lib/robot_lab.rb +6 -4
  79. metadata +53 -18
  80. data/examples/14_rusty_circuit/scout_notes.md +0 -89
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1eaa090a87b2f82890dc8ee350bb1326b380c41d6560c3ae01e0ea1a4fc0d84a
4
- data.tar.gz: 8232818730ba698d09ed9537adbc774f5d2ad84013942016e48ebad192a653c9
3
+ metadata.gz: 37a044eb81a0e5c56aa7c5c00f9b4eff600c56eecda8bb2deb18058919a18267
4
+ data.tar.gz: a48253bbceb5ac99f1babf9c4538045886e038fb0c4a827b96b219b5363bf5c8
5
5
  SHA512:
6
- metadata.gz: ce75a3733e3d153196f85995c1363e542aa9b039f523cb15388a2d0d0f7a13d7ea26390a12abf6582680da5b119f1df8f6f35a7837a28a8b6f07f5aca72639db
7
- data.tar.gz: c2c4f50e6352dc6018c8f0077549450e05adccb3eaa555236c46b131c557f69acf6f59ca6af12c823e429db777019bca684846338af273de2e2ce5587be5a976
6
+ metadata.gz: 9f84d7c82598d281e8c88a4e67b2b0c13bac1142e8dae9834a1b0e84d1055e16837c62b452398da615d477aec834b153ca74f6313c67b806fe174aacd3a0999b
7
+ data.tar.gz: 549afa0eb2e622ad8caf0d928559bb881b3f1307d2e427074a2f96f943418784e580ef9d5b2a06534f9ec57873682189ec6890cd0ed1790e19b66c159fff572e
data/CHANGELOG.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # Changelog
2
2
 
3
- > [!CAUTION]
4
- > This gem is under active development. APIs and features may change without notice.
5
-
6
3
  All notable changes to this project will be documented in this file.
7
4
 
8
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
@@ -11,6 +8,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
8
 
12
9
  ## [Unreleased]
13
10
 
11
+ ## [0.0.8] - 2026-02-22
12
+
13
+ ### Added
14
+
15
+ - **Skills as composable templates** — prepend reusable prompt snippets at build time via `skills:` parameter
16
+ - Skills are regular PromptManager templates (no special subdirectory)
17
+ - Recursive expansion — skills can declare nested skills via front matter `skills:` key
18
+ - Depth-first ordering — nested skills appear before their parent
19
+ - Cycle detection via `Set` of visited IDs; cycles log a warning and skip
20
+ - Config cascade — skill₁ → skill₂ → ... → main template → constructor kwargs
21
+ - Shared context — all skills and the main template render with the same `context:` hash
22
+ - Example: `examples/17_skills.rb` — SRE incident response system with flat and recursive skills
23
+ - 20 new tests covering expansion, ordering, cycles, config cascade, and factory passthrough
24
+ - **Streaming content callback (`on_content:`)** — wire streaming at robot build time
25
+ - Stored callback fires on every `run()` call automatically
26
+ - Pass-through block on `run()` for per-call streaming
27
+ - When both exist, both fire (stored first, then runtime block)
28
+ - `effective_streaming_block` merges stored + runtime into a single Proc
29
+ - Added `:on_content` to `RunConfig::CALLBACK_FIELDS`
30
+ - 12 new tests
31
+ - **Graceful tool error handling** — `RobotLab::Tool#call` wraps `execute` with `rescue StandardError`
32
+ - Errors returned as plain string (`"Error (tool_name): message"`) so the LLM can reason about them
33
+ - Class-level `raise_on_error` opt-out for critical tools
34
+ - MCP tools inherit the same wrapper via `Tool.create` subclasses
35
+ - 10 new tests
36
+ - **`RobotRunJob` generator template** (`job.rb.tt`) — turnkey ActiveJob background job for robot runs
37
+ - Resolves robot class via `constantize.build`
38
+ - Wires `TurboStreamCallbacks` when `turbo-rails` is available (graceful no-op otherwise)
39
+ - Persists results via `result.export`; broadcasts completion/error via Turbo Streams
40
+ - `--skip-job` option on install generator
41
+ - **`TurboStreamCallbacks` module** (`lib/robot_lab/rails_integration/turbo_stream_callbacks.rb`)
42
+ - `available?` — runtime check for `Turbo::StreamsChannel`
43
+ - `build_content_callback(stream_name:, target:)` — broadcasts HTML-escaped content chunks
44
+ - `build_tool_call_callback(stream_name:, target:)` — broadcasts tool call badges
45
+ - 13 tests
46
+ - **Rails demo app** (`examples/18_rails/`) — minimal hand-built Rails 8 app exercising all Rails integration
47
+ - ChatRobot with TimeTool, RobotRunJob, Turbo Stream token streaming, SQLite persistence
48
+ - No asset pipeline — Turbo JS via importmap from CDN
49
+ - `:async` adapters for both ActiveJob and ActionCable (no Redis, no Solid Queue)
50
+ - User messages persisted in history; auto-scrolling via MutationObserver; form clears after submit
51
+ - Rake tasks: `examples:rails_setup`, `examples:rails`
52
+ - **Routing robot example** in Rails integration docs — `ClassifierRobot` subclass with `call(result)` override
53
+ - **Custom tool example** in Rails integration docs — `OrderLookup` tool with ActiveRecord
54
+
55
+ ### Changed
56
+
57
+ - Bumped version to 0.0.8
58
+ - **Renamed `RobotLab::Rails` → `RobotLab::RailsIntegration`** — eliminates constant shadowing where bare `Rails` inside `module RobotLab` resolved to the gem's own namespace instead of `::Rails`
59
+ - Moved `lib/robot_lab/rails/` → `lib/robot_lab/rails_integration/`
60
+ - Updated all require paths, loader.ignore, generator templates, tests, and documentation
61
+ - Reverted `::Rails` back to bare `Rails` in `config.rb` (shadow eliminated)
62
+ - Rakefile updated with `STANDALONE_APPS` map for standalone demo apps
63
+ - Documentation updates across README, guides, API reference, and examples for all new features
64
+ - Updated Gemfile.lock dependencies
65
+
66
+ ### Fixed
67
+
68
+ - `RobotLab::Rails` namespace shadowing `::Rails` in `config.rb` (`NoMethodError: undefined method 'root' for module RobotLab::Rails`)
69
+ - MkDocs broken anchor link in `docs/examples/index.md` (`#with-conversation-history` → `#with-memory`)
70
+
14
71
  ## [0.0.7] - 2026-02-17 [unreleased]
15
72
 
16
73
  ### Added
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # RobotLab
2
2
 
3
- > [!CAUTION]
4
- > This gem is under active development. APIs and features may change without notice. See the [CHANGELOG](CHANGELOG.md) for details.
3
+ > [!INFO]
4
+ > See the [CHANGELOG](CHANGELOG.md) for the latest changes. The [examples directory has a good cross section of demo apps](examples/README.md) that show-off the various capabilities of the RobotLab library.
5
+
5
6
  <br>
6
7
  <table>
7
8
  <tr>
@@ -10,24 +11,27 @@
10
11
  <em>"Build robots. Solve problems."</em>
11
12
  </td>
12
13
  <td width="50%" valign="top">
13
- <strong>Multi-robot LLM workflow orchestration for Ruby</strong><br><br>
14
- RobotLab enables you to build sophisticated AI applications using multiple specialized robots (LLM agents) that work together to accomplish complex tasks. Each robot has its own system prompt, tools, and capabilities.<br><br>
15
14
  <strong>Key Features</strong><br>
16
15
 
17
16
  - <strong>Multi-Robot Architecture</strong> - Build with specialized AI agents<br>
18
17
  - <strong>Network Orchestration</strong> - Connect robots with flexible routing<br>
19
- - <strong>Extensible Tools</strong> - Give robots custom capabilities<br>
18
+ - <strong>Prompt Templates</strong> - Self-contained .md files with YAML front matter<br>
19
+ - <strong>Composable Skills</strong> - Mix reusable prompt behaviors into any robot<br>
20
+ - <strong>Extensible Tools</strong> - Custom capabilities with graceful error handling<br>
21
+ - <strong>Human-in-the-Loop</strong> - AskUser tool for interactive prompting<br>
22
+ - <strong>Content Streaming</strong> - Stored callbacks, per-call blocks, or both<br>
20
23
  - <strong>MCP Integration</strong> - Connect to external tool servers<br>
21
24
  - <strong>Shared Memory</strong> - Reactive key-value store with subscriptions<br>
22
- - <strong>Conversation History</strong> - Persist and restore threads<br>
23
25
  - <strong>Message Bus</strong> - Bidirectional robot communication via TypedBus<br>
24
26
  - <strong>Dynamic Spawning</strong> - Robots create new robots at runtime<br>
25
- - <strong>Streaming</strong> - Real-time event streaming<br>
26
- - <strong>Rails Integration</strong> - Generators and ActiveRecord support
27
+ - <strong>Layered Configuration</strong> - Cascading YAML, env vars, and RunConfig<br>
28
+ - <strong>Rails Integration</strong> - Generators, background jobs, Turbo Stream broadcasting
27
29
  </td>
28
30
  </tr>
29
31
  </table>
30
32
 
33
+ <p>RobotLab enables sophisticated AI applications using multiple specialized robots (LLM agents) that work together to accomplish complex tasks. Each robot has its own instructions, skills, tools, and capabilities. Review the [full documentation website](https://madbomber.github.io/robot_lab) snd explore the [many examples](examples/README.md) available as working demo applications.</p>
34
+
31
35
  ## Installation
32
36
 
33
37
  ```bash
@@ -172,7 +176,35 @@ You are a GitHub assistant. Use available tools to help with repository tasks.
172
176
  robot = RobotLab.build(template: :github_assistant)
173
177
  ```
174
178
 
175
- Front matter supports: `description`, `robot_name`, `tools`, `mcp`, `parameters`, and LLM config keys (`model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`). Constructor-provided values always take precedence over front matter.
179
+ Front matter supports: `description`, `robot_name`, `tools`, `mcp`, `skills`, `parameters`, and LLM config keys (`model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`). Constructor-provided values always take precedence over front matter.
180
+
181
+ ### Composable Skills
182
+
183
+ Skills let you compose robot behaviors from reusable templates. A skill is just a template whose prompt body is prepended before the main template. Use skills to mix in capabilities like "ask clarifying questions", "respond in JSON", or "follow safety guidelines" without creating a dedicated template for every combination.
184
+
185
+ ```ruby
186
+ # Compose a support robot from reusable skills
187
+ robot = RobotLab.build(
188
+ name: "support",
189
+ template: :support_agent,
190
+ skills: [:clarifier, :sentiment_aware, :json_responder],
191
+ context: { company: "Acme Corp" }
192
+ )
193
+ ```
194
+
195
+ Skills can also be declared in template front matter:
196
+
197
+ ```markdown
198
+ ---
199
+ description: Support agent with built-in skills
200
+ skills:
201
+ - clarifier
202
+ - sentiment_aware
203
+ ---
204
+ You are a support agent for <%= company %>.
205
+ ```
206
+
207
+ Skills are expanded depth-first and can reference other skills (with automatic cycle detection). Config cascades through skills in order — later values override earlier ones, and constructor kwargs always win.
176
208
 
177
209
  ### Combining Templates with System Prompts
178
210
 
@@ -252,6 +284,28 @@ result = robot
252
284
  .run("Write a haiku about Ruby programming")
253
285
  ```
254
286
 
287
+ ## Graceful Tool Error Handling
288
+
289
+ `RobotLab::Tool` automatically catches exceptions in `execute` and returns a plain-text error to the LLM instead of crashing the run. The LLM can then reason about the error and try an alternative approach.
290
+
291
+ ```ruby
292
+ tool = RobotLab::Tool.create(name: "fetch_data") do |args|
293
+ raise IOError, "connection refused"
294
+ end
295
+
296
+ result = tool.call({})
297
+ # => "Error (fetch_data): connection refused"
298
+ ```
299
+
300
+ This applies to all tools — subclasses, factory tools, and MCP tools. For critical tools where you want exceptions to propagate, opt out per class:
301
+
302
+ ```ruby
303
+ class CriticalTool < RobotLab::Tool
304
+ self.raise_on_error = true
305
+ # ...
306
+ end
307
+ ```
308
+
255
309
  ## Creating a Robot with Tools
256
310
 
257
311
  ```ruby
@@ -502,26 +556,48 @@ Key features:
502
556
 
503
557
  ## Streaming
504
558
 
505
- Use a `Streaming::Context` with a publish callback to receive real-time events:
559
+ Stream LLM content in real-time using a stored callback, a per-call block, or both. Each receives a [`RubyLLM::Chunk`](https://rubyllm.com/streaming/#basic-streaming) use `chunk.content` for the text delta. Chunks also carry `model_id`, `tool_calls`, `thinking`, and token usage on the final chunk.
560
+
561
+ ### Stored Callback (`on_content:`)
562
+
563
+ Wire streaming at build time. The callback fires on every `run()` call automatically:
506
564
 
507
565
  ```ruby
508
- handler = lambda do |event|
509
- case event[:event]
510
- when RobotLab::Streaming::Events::TEXT_DELTA
511
- print event[:data][:delta]
512
- when RobotLab::Streaming::Events::RUN_COMPLETED
513
- puts "\nDone!"
514
- end
515
- end
566
+ robot = RobotLab.build(
567
+ name: "assistant",
568
+ system_prompt: "You are helpful.",
569
+ on_content: ->(chunk) { print chunk.content }
570
+ )
571
+
572
+ robot.run("Tell me a story") # streams automatically
573
+ ```
574
+
575
+ ### Per-Call Block
576
+
577
+ Pass a block to `run()` for one-off streaming:
516
578
 
517
- context = RobotLab::Streaming::Context.new(
518
- run_id: SecureRandom.uuid,
519
- message_id: SecureRandom.uuid,
520
- scope: "robot",
521
- publish: handler
579
+ ```ruby
580
+ robot = RobotLab.build(name: "assistant", system_prompt: "You are helpful.")
581
+
582
+ robot.run("Tell me a story") { |chunk| print chunk.content }
583
+ ```
584
+
585
+ ### Both Together
586
+
587
+ When both a stored callback and a runtime block are provided, both fire (stored first):
588
+
589
+ ```ruby
590
+ robot = RobotLab.build(
591
+ name: "assistant",
592
+ system_prompt: "You are helpful.",
593
+ on_content: ->(chunk) { log_chunk(chunk.content) }
522
594
  )
595
+
596
+ robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
523
597
  ```
524
598
 
599
+ The `on_content:` callback participates in the RunConfig cascade, so it can be set at the network or config level and inherited by robots.
600
+
525
601
  ## Rails Integration
526
602
 
527
603
  ```bash
data/Rakefile CHANGED
@@ -52,7 +52,12 @@ namespace :examples do
52
52
  "16_writers_room" => "writers_room.rb"
53
53
  }.freeze
54
54
 
55
- desc "Run all examples"
55
+ # Subdirectory demos that are standalone apps (not run via `ruby`)
56
+ STANDALONE_APPS = {
57
+ "18_rails" => { setup: "bin/setup", run: "bin/dev" }
58
+ }.freeze
59
+
60
+ desc "Run all examples (excludes standalone apps like 18_rails)"
56
61
  task :all do
57
62
  # Single-file examples
58
63
  Dir.glob("examples/*.rb").sort.each do |example|
@@ -72,6 +77,15 @@ namespace :examples do
72
77
  puts '=' * 60
73
78
  ruby path
74
79
  end
80
+
81
+ # Remind about standalone apps
82
+ STANDALONE_APPS.each do |dir, commands|
83
+ puts "\n#{'=' * 60}"
84
+ puts "Skipped: examples/#{dir} (standalone app)"
85
+ puts " Setup: cd examples/#{dir} && #{commands[:setup]}"
86
+ puts " Run: cd examples/#{dir} && #{commands[:run]}"
87
+ puts '=' * 60
88
+ end
75
89
  end
76
90
 
77
91
  desc "Run a specific example by number (e.g., rake examples:run[1])"
@@ -89,6 +103,16 @@ namespace :examples do
89
103
  dir = Dir.glob("examples/#{padded}_*/").first
90
104
  if dir
91
105
  dir_name = File.basename(dir)
106
+
107
+ # Check if it's a standalone app
108
+ if STANDALONE_APPS.key?(dir_name)
109
+ commands = STANDALONE_APPS[dir_name]
110
+ puts "Example #{args[:num]} is a standalone app (#{dir_name})."
111
+ puts " Setup: cd examples/#{dir_name} && #{commands[:setup]}"
112
+ puts " Run: cd examples/#{dir_name} && #{commands[:run]}"
113
+ next
114
+ end
115
+
92
116
  entry = SUBDIR_ENTRY_POINTS[dir_name]
93
117
  if entry && File.exist?(File.join(dir, entry))
94
118
  ruby File.join(dir, entry)
@@ -99,6 +123,20 @@ namespace :examples do
99
123
  puts "Example #{args[:num]} not found"
100
124
  end
101
125
  end
126
+
127
+ desc "Setup the Rails demo app (example 18)"
128
+ task :rails_setup do
129
+ Dir.chdir("examples/18_rails") do
130
+ sh "bin/setup"
131
+ end
132
+ end
133
+
134
+ desc "Start the Rails demo app (example 18)"
135
+ task :rails do
136
+ Dir.chdir("examples/18_rails") do
137
+ sh "bin/dev"
138
+ end
139
+ end
102
140
  end
103
141
 
104
142
  namespace :docs do
@@ -28,8 +28,10 @@ Robot.new(
28
28
  tools: :none,
29
29
  on_tool_call: nil,
30
30
  on_tool_result: nil,
31
+ on_content: nil,
31
32
  enable_cache: true,
32
33
  bus: nil,
34
+ skills: nil,
33
35
  temperature: nil,
34
36
  top_p: nil,
35
37
  top_k: nil,
@@ -57,8 +59,10 @@ Robot.new(
57
59
  | `tools` | `Symbol`, `Array` | `:none` | Hierarchical tools config (`:none`, `:inherit`, or tool name array) |
58
60
  | `on_tool_call` | `Proc`, `nil` | `nil` | Callback invoked when a tool is called |
59
61
  | `on_tool_result` | `Proc`, `nil` | `nil` | Callback invoked when a tool returns a result |
62
+ | `on_content` | `Proc`, `nil` | `nil` | Stored streaming callback invoked with each content chunk (see [Streaming](#streaming)) |
60
63
  | `enable_cache` | `Boolean` | `true` | Whether to enable semantic caching |
61
64
  | `bus` | `TypedBus::MessageBus`, `nil` | `nil` | Optional message bus for inter-robot communication |
65
+ | `skills` | `Symbol`, `Array<Symbol>`, `nil` | `nil` | Skill templates to prepend (see [Skills](#skills)) |
62
66
  | `config` | `RunConfig`, `nil` | `nil` | Shared config merged with explicit kwargs (see [RunConfig](#runconfig)) |
63
67
  | `temperature` | `Float`, `nil` | `nil` | Controls randomness (0.0-1.0) |
64
68
  | `top_p` | `Float`, `nil` | `nil` | Nucleus sampling threshold |
@@ -80,6 +84,7 @@ robot = RobotLab.build(
80
84
  context: {},
81
85
  enable_cache: true,
82
86
  bus: nil, # Optional TypedBus::MessageBus
87
+ skills: nil, # Optional skill templates
83
88
  **options # All other Robot.new parameters
84
89
  )
85
90
  # => RobotLab::Robot
@@ -95,6 +100,7 @@ If `name` is omitted, it defaults to `"robot"`.
95
100
  | `description` | `String`, `nil` | Human-readable description |
96
101
  | `template` | `Symbol`, `nil` | Prompt template identifier |
97
102
  | `system_prompt` | `String`, `nil` | Inline system prompt |
103
+ | `skills` | `Array<Symbol>`, `nil` | Constructor-provided skill template IDs (nil if none) |
98
104
  | `local_tools` | `Array` | Locally defined tools |
99
105
  | `mcp_clients` | `Hash<String, MCP::Client>` | Connected MCP clients, keyed by server name |
100
106
  | `mcp_tools` | `Array<Tool>` | Tools discovered from MCP servers |
@@ -119,7 +125,7 @@ Used by tools like [`AskUser`](tool.md#built-in-askuser) that need terminal IO.
119
125
  ### run
120
126
 
121
127
  ```ruby
122
- result = robot.run(message, **kwargs)
128
+ result = robot.run(message, **kwargs, &block)
123
129
  # => RobotResult
124
130
  ```
125
131
 
@@ -136,6 +142,9 @@ Primary execution method. Sends a message to the LLM with memory/MCP/tools resol
136
142
  | `mcp` | `Symbol`, `Array` | `:none` | Runtime MCP override |
137
143
  | `tools` | `Symbol`, `Array` | `:none` | Runtime tools override |
138
144
  | `**kwargs` | `Hash` | `{}` | Additional keyword arguments passed to `Agent#ask` |
145
+ | `&block` | `Proc` | `nil` | Per-call streaming block, receives each content chunk |
146
+
147
+ When both a stored `on_content` callback and a runtime block are provided, both fire (stored first, then runtime block).
139
148
 
140
149
  **Returns:** `RobotResult`
141
150
 
@@ -148,8 +157,8 @@ result = robot.run("What is 2+2?")
148
157
  # With runtime memory
149
158
  result = robot.run("Summarize the data", memory: { data: report })
150
159
 
151
- # With streaming block
152
- result = robot.run("Tell me a story") { |event| print event.text }
160
+ # With per-call streaming block
161
+ result = robot.run("Tell me a story") { |chunk| print chunk.content }
153
162
 
154
163
  # With runtime overrides
155
164
  result = robot.run("Help me", mcp: :none, tools: :none)
@@ -408,7 +417,7 @@ robot.to_h
408
417
  # => Hash
409
418
  ```
410
419
 
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).
420
+ 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
421
 
413
422
  ## Memory Behavior
414
423
 
@@ -437,7 +446,7 @@ Front matter supports two categories of keys:
437
446
 
438
447
  **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` — applied to the underlying chat.
439
448
 
440
- **Robot Extras:** `robot_name`, `description`, `tools`, `mcp` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
449
+ **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. Constructor-provided values always take precedence.
441
450
 
442
451
  | Key | Type | Description |
443
452
  |-----|------|-------------|
@@ -445,6 +454,40 @@ Front matter supports two categories of keys:
445
454
  | `description` | `String` | Human-readable description |
446
455
  | `tools` | `Array<String>` | Tool class names resolved via `Object.const_get` |
447
456
  | `mcp` | `Array<Hash>` | MCP server configurations |
457
+ | `skills` | `Array<Symbol>` | Skill templates to prepend (recursive, with cycle detection) |
458
+
459
+ ## Skills
460
+
461
+ 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.
462
+
463
+ **Constructor:** `skills:` accepts `Symbol` or `Array<Symbol>`:
464
+
465
+ ```ruby
466
+ robot = RobotLab.build(
467
+ name: "support",
468
+ template: :support,
469
+ skills: [:clarifier, :json_responder]
470
+ )
471
+ ```
472
+
473
+ **Front matter:** templates can declare skills via `skills:` key:
474
+
475
+ ```markdown
476
+ ---
477
+ skills:
478
+ - clarifier
479
+ - json_responder
480
+ ---
481
+ Main template body here.
482
+ ```
483
+
484
+ 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).
485
+
486
+ **Config cascade:** skill config merges in processing order (deepest first). Later values override earlier. Constructor kwargs always win.
487
+
488
+ **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.
489
+
490
+ **Cycle detection:** if skills form a cycle, the duplicate is skipped with a logger warning.
448
491
 
449
492
  ## RunConfig
450
493
 
@@ -463,10 +506,78 @@ robot = RobotLab.build(
463
506
  robot.config #=> RunConfig with model: "claude-sonnet-4", temperature: 0.9, ...
464
507
  ```
465
508
 
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`.
509
+ 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
510
 
468
511
  See [Configuration: RunConfig](../../getting-started/configuration.md#runconfig-shared-operational-defaults) for full details.
469
512
 
513
+ ## Streaming
514
+
515
+ Robots support two complementary approaches for streaming LLM content in real-time.
516
+
517
+ ### The Chunk Object
518
+
519
+ Both callbacks and blocks receive a [`RubyLLM::Chunk`](https://rubyllm.com/streaming/#basic-streaming) (subclass of `RubyLLM::Message`). Key accessors:
520
+
521
+ | Accessor | Type | Description |
522
+ |----------|------|-------------|
523
+ | `content` | `String`, `nil` | The text delta for this chunk (`nil` on tool-call or usage-only chunks) |
524
+ | `role` | `Symbol` | Always `:assistant` |
525
+ | `model_id` | `String` | The LLM model ID |
526
+ | `tool_calls` | `Array`, `nil` | Tool call deltas (partial JSON arguments) |
527
+ | `tool_call?` | `Boolean` | Whether this chunk contains tool call data |
528
+ | `thinking` | `Thinking`, `nil` | Extended thinking delta (Anthropic only) |
529
+ | `input_tokens` | `Integer`, `nil` | Input token count (populated on final chunk) |
530
+ | `output_tokens` | `Integer`, `nil` | Output token count (populated on final chunk) |
531
+ | `cached_tokens` | `Integer`, `nil` | Cached prompt tokens (final chunk) |
532
+
533
+ 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`.
534
+
535
+ ### Stored Callback (`on_content:`)
536
+
537
+ Wired at build time via constructor or RunConfig. Fires on every `run()` call automatically:
538
+
539
+ ```ruby
540
+ robot = RobotLab.build(
541
+ name: "assistant",
542
+ system_prompt: "You are helpful.",
543
+ on_content: ->(chunk) { broadcast(chunk.content) }
544
+ )
545
+ robot.run("Tell me a story") # streams via stored callback
546
+ ```
547
+
548
+ The `on_content` callback participates in the RunConfig cascade:
549
+
550
+ ```ruby
551
+ config = RobotLab::RunConfig.new(
552
+ on_content: ->(chunk) { log(chunk.content) }
553
+ )
554
+ robot = RobotLab.build(name: "bot", config: config)
555
+ ```
556
+
557
+ Constructor `on_content:` overrides RunConfig `on_content`.
558
+
559
+ ### Per-Call Block
560
+
561
+ Pass a block to `run()` for one-off streaming:
562
+
563
+ ```ruby
564
+ robot.run("Tell me a story") { |chunk| print chunk.content }
565
+ ```
566
+
567
+ ### Both Together
568
+
569
+ When both exist, both fire — stored callback first, then runtime block:
570
+
571
+ ```ruby
572
+ robot = RobotLab.build(
573
+ name: "bot",
574
+ system_prompt: "You are helpful.",
575
+ on_content: ->(chunk) { log(chunk.content) }
576
+ )
577
+ robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
578
+ # log() fires first, then stream_to_client()
579
+ ```
580
+
470
581
  ## Configuration Hierarchy
471
582
 
472
583
  Tools and MCP servers use hierarchical resolution: **runtime > robot > network > global config**.
@@ -559,6 +670,18 @@ result = robot.run("Search for popular Ruby repos")
559
670
  robot.disconnect
560
671
  ```
561
672
 
673
+ ### Robot with Skills
674
+
675
+ ```ruby
676
+ robot = RobotLab.build(
677
+ name: "support",
678
+ template: :support,
679
+ skills: [:clarifier, :safety, :json_responder],
680
+ context: { company: "Acme Corp" }
681
+ )
682
+ result = robot.run("I need help with my order")
683
+ ```
684
+
562
685
  ### Bare Robot with Chaining
563
686
 
564
687
  ```ruby
@@ -628,6 +751,6 @@ bot.send_message(to: :someone, content: "Hello!")
628
751
 
629
752
  ## See Also
630
753
 
631
- - [Building Robots Guide](../../guides/building-robots.md)
754
+ - [Building Robots Guide](../../guides/building-robots.md) (includes [Composable Skills](../../guides/building-robots.md#composable-skills))
632
755
  - [Tool](tool.md)
633
756
  - [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
 
data/docs/concepts.md CHANGED
@@ -12,7 +12,9 @@ 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
- - **Local Tools**: `RubyLLM::Tool` subclasses or `RobotLab::Tool` instances
15
+ - **Skills**: Composable template behaviors prepended before the main template
16
+ - **Local Tools**: `RubyLLM::Tool` subclasses or `RobotLab::Tool` instances (with automatic error handling)
17
+ - **Streaming**: Real-time content via stored `on_content` callback or per-call block
16
18
  - **Memory**: Persistent key-value store across runs
17
19
 
18
20
  ```ruby
@@ -137,7 +139,9 @@ result.continued? # Whether execution continues
137
139
 
138
140
  ## Tool
139
141
 
140
- **Tools** give robots the ability to interact with external systems. There are two patterns for defining tools:
142
+ **Tools** give robots the ability to interact with external systems. `RobotLab::Tool` extends `RubyLLM::Tool` with graceful error handling — if `execute` raises a `StandardError`, the error is caught and returned as a plain-text string (`"Error (tool_name): message"`) so the LLM can reason about it. Critical tools can opt out with `self.raise_on_error = true`.
143
+
144
+ There are two patterns for defining tools:
141
145
 
142
146
  ### RubyLLM::Tool Subclass (Preferred)
143
147
 
@@ -446,7 +450,7 @@ Templates support two categories of front matter keys:
446
450
 
447
451
  **LLM Config:** `model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop` — applied to the robot's chat configuration.
448
452
 
449
- **Robot Extras:** `robot_name`, `description`, `tools`, `mcp` — applied to the robot's identity and capabilities. These make templates self-contained: reading the `.md` file tells you everything about the robot.
453
+ **Robot Extras:** `robot_name`, `description`, `tools`, `mcp`, `skills` — applied to the robot's identity and capabilities. These make templates self-contained: reading the `.md` file tells you everything about the robot.
450
454
 
451
455
  ```markdown
452
456
  ---
@@ -29,7 +29,7 @@ These examples show how to use RobotLab for common scenarios, from simple chatbo
29
29
  ### Advanced Examples
30
30
 
31
31
  - [Streaming Responses](basic-chat.md#with-streaming)
32
- - [Persistent Conversations](basic-chat.md#with-conversation-history)
32
+ - [Persistent Conversations](basic-chat.md#with-memory)
33
33
  - [MCP Integration](mcp-server.md)
34
34
  - [Message Bus Communication](#message-bus)
35
35
  - [Spawning Robots](#spawning-robots)
@@ -88,7 +88,7 @@ RobotLab.config.logger #=> Rails.logger
88
88
 
89
89
  ### Rails Engine and Railtie
90
90
 
91
- RobotLab provides both a Rails Engine (`RobotLab::Rails::Engine`) and a Railtie (`RobotLab::Rails::Railtie`). These are loaded automatically when Rails is detected. The Engine isolates the RobotLab namespace and adds `app/robots` and `app/tools` to the autoload paths. The Railtie loads rake tasks and generators.
91
+ RobotLab provides both a Rails Engine (`RobotLab::RailsIntegration::Engine`) and a Railtie (`RobotLab::RailsIntegration::Railtie`). These are loaded automatically when Rails is detected. The Engine isolates the RobotLab namespace and adds `app/robots` and `app/tools` to the autoload paths. The Railtie loads rake tasks and generators.
92
92
 
93
93
  ## Models
94
94