robot_lab 0.2.7 → 0.3.0

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -2
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +70 -1
  6. data/CLAUDE.md +4 -2
  7. data/README.md +15 -17
  8. data/Rakefile +6 -111
  9. data/_typos.toml +21 -0
  10. data/docs/api/index.md +2 -2
  11. data/docs/api/skills.md +53 -149
  12. data/docs/api/support.md +1 -1
  13. data/docs/architecture/core-concepts.md +1 -1
  14. data/docs/architecture/index.md +5 -4
  15. data/docs/architecture/state-management.md +4 -4
  16. data/docs/concepts.md +1 -1
  17. data/docs/getting-started/configuration.md +4 -1
  18. data/docs/getting-started/installation.md +1 -4
  19. data/docs/guides/hooks.md +122 -0
  20. data/docs/guides/memory.md +2 -2
  21. data/docs/guides/using-tools.md +10 -1
  22. data/examples/.envrc +2 -0
  23. data/examples/02_tools.rb +8 -8
  24. data/examples/03_network.rb +1 -1
  25. data/examples/04_mcp.rb +7 -5
  26. data/examples/08_llm_config.rb +5 -5
  27. data/examples/09_chaining.rb +3 -3
  28. data/examples/14_rusty_circuit/comic.rb +8 -8
  29. data/examples/14_rusty_circuit/scout.rb +4 -4
  30. data/examples/15_memory_network_and_bus/README.md +66 -0
  31. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  32. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  33. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  34. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  35. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  36. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  37. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  38. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  39. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  40. data/examples/16_writers_room/tools.rb +14 -14
  41. data/examples/19_token_tracking.rb +2 -2
  42. data/examples/20_circuit_breaker.rb +3 -3
  43. data/examples/22_context_compression.rb +1 -1
  44. data/examples/27_incident_response/README.md +65 -0
  45. data/examples/28_mcp_discovery.rb +2 -2
  46. data/examples/29_ractor_tools.rb +4 -4
  47. data/examples/30_ractor_network.rb +2 -2
  48. data/examples/33_stock_predictor.rb +8 -8
  49. data/examples/35_hooks.rb +3 -3
  50. data/examples/README.md +17 -0
  51. data/examples/common.rb +55 -23
  52. data/examples/run_all.rb +60 -0
  53. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  54. data/lib/robot_lab/ask_user.rb +5 -3
  55. data/lib/robot_lab/bus_poller.rb +2 -0
  56. data/lib/robot_lab/capabilities.rb +4 -0
  57. data/lib/robot_lab/config/defaults.yml +5 -5
  58. data/lib/robot_lab/config.rb +10 -2
  59. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  60. data/lib/robot_lab/history_compressor.rb +5 -0
  61. data/lib/robot_lab/hook.rb +1 -0
  62. data/lib/robot_lab/hook_context.rb +4 -0
  63. data/lib/robot_lab/hook_registry.rb +1 -0
  64. data/lib/robot_lab/hooks.rb +6 -3
  65. data/lib/robot_lab/mcp/client.rb +2 -2
  66. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  67. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  68. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  69. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  70. data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
  71. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  72. data/lib/robot_lab/memory.rb +39 -13
  73. data/lib/robot_lab/memory_change.rb +1 -0
  74. data/lib/robot_lab/message.rb +3 -0
  75. data/lib/robot_lab/names.rb +2 -4
  76. data/lib/robot_lab/network.rb +13 -6
  77. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  78. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  79. data/lib/robot_lab/robot/history_search.rb +2 -0
  80. data/lib/robot_lab/robot/hooking.rb +29 -0
  81. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  82. data/lib/robot_lab/robot/result_building.rb +119 -0
  83. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  84. data/lib/robot_lab/robot.rb +88 -130
  85. data/lib/robot_lab/robot_result.rb +3 -0
  86. data/lib/robot_lab/run_config.rb +57 -20
  87. data/lib/robot_lab/script_tool.rb +20 -39
  88. data/lib/robot_lab/state_proxy.rb +1 -0
  89. data/lib/robot_lab/streaming/context.rb +1 -0
  90. data/lib/robot_lab/streaming/events.rb +1 -0
  91. data/lib/robot_lab/task.rb +2 -0
  92. data/lib/robot_lab/tool.rb +11 -12
  93. data/lib/robot_lab/user_message.rb +1 -0
  94. data/lib/robot_lab/utils.rb +2 -0
  95. data/lib/robot_lab/version.rb +1 -1
  96. data/lib/robot_lab/waiter.rb +3 -0
  97. data/lib/robot_lab.rb +9 -6
  98. metadata +23 -32
  99. data/lib/robot_lab/sandbox/null.rb +0 -13
  100. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  101. data/lib/robot_lab/sandbox.rb +0 -52
data/docs/api/skills.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Skills API
2
2
 
3
3
  Class-level reference for the AgentSkills subsystem: skill bundles, the scripts
4
- they expose as tools, the capabilities those scripts declare, and the sandbox
5
- that confines them. For the how-to, see
4
+ they expose as tools, and the capabilities those scripts declare. Core itself
5
+ has **no sandboxing and no execution limitations** — every script runs
6
+ unconfined until the optional
7
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem is
8
+ required, at which point it installs a confinement strategy (see
9
+ [ScriptTool.executor](#scripttoolexecutor) below). For the how-to, see
6
10
  [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
7
11
  and [Building Robots: Composable Skills](../guides/building-robots.md#composable-skills).
8
12
 
@@ -27,12 +31,16 @@ graph LR
27
31
  G[effective grant<br/>declared ∩ ceiling]
28
32
  end
29
33
 
30
- subgraph "Execution"
34
+ subgraph "Execution (core)"
31
35
  T[ScriptTool.from_path<br/>-> RobotLab::Tool]
36
+ EX{ScriptTool.executor<br/>set?}
37
+ UN[Open3.capture2e<br/>unconfined, no timeout]
38
+ end
39
+
40
+ subgraph "robot_lab-sandbox (optional gem)"
32
41
  SB{Sandbox.enabled?}
33
42
  SE[Sandbox::Seatbelt<br/>macOS]
34
43
  NU[Sandbox::Null<br/>passthrough]
35
- P[script process]
36
44
  end
37
45
 
38
46
  R[Robot<br/>AgentSkillMatching] --> C
@@ -41,12 +49,12 @@ graph LR
41
49
  S --> T
42
50
  D --> G
43
51
  CE --> G
44
- T --> SB
52
+ T --> EX
53
+ EX -- "nil (default)" --> UN
54
+ EX -- "installed" --> SB
45
55
  SB -- "off, or trust: core,<br/>or non-macOS" --> NU
46
56
  SB -- "on + macOS" --> SE
47
57
  G --> SE
48
- SE --> P
49
- NU --> P
50
58
  ```
51
59
 
52
60
  ---
@@ -264,9 +272,9 @@ of the ceiling, and `intersect` keeps the declared value.
264
272
  capabilities.core? # => trust == "core"
265
273
  ```
266
274
 
267
- A `core` skill is exempt from confinement: `Sandbox.for` returns a
268
- [`Null`](#robotlabsandboxnull) strategy for it regardless of platform or config.
269
- Reserve `trust: core` for bundles you wrote and audited.
275
+ A `core` skill is exempt from confinement: when `robot_lab-sandbox` is loaded,
276
+ `Sandbox.for` returns a `Null` strategy for it regardless of platform or
277
+ config. Reserve `trust: core` for bundles you wrote and audited.
270
278
 
271
279
  ### intersect
272
280
 
@@ -314,43 +322,32 @@ The generated tool takes a single optional `args` string parameter, which is
314
322
  [`derive_name`](#scripttoolderive_name) and its description from
315
323
  [`extract_description`](#scripttoolextract_description).
316
324
 
317
- ### ScriptTool.execute
325
+ ### ScriptTool.executor
318
326
 
319
327
  ```ruby
320
- RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
328
+ RobotLab::ScriptTool.executor # => #call, or nil (the default)
329
+ RobotLab::ScriptTool.executor = obj # any object responding to
330
+ # call(cmd, capabilities:, skill_dir:)
321
331
  ```
322
332
 
323
- Run a command array and return its combined stdout+stderr, or an error string.
324
- Two paths:
325
-
326
- - **Sandboxing off** (the default) `Open3.capture2e`, unconfined, **no timeout**.
327
- - **Sandboxing on** — intersects `capabilities` with `Capabilities.ceiling`, wraps
328
- the command with the strategy from `Sandbox.for`, runs it under the grant's
329
- timeout, and cleans the strategy up in an `ensure`.
330
-
331
- The declared `timeout:` therefore only takes effect when sandboxing is enabled.
333
+ The extension point core exposes for confinement. `nil` by default core has
334
+ no sandboxing of its own. `robot_lab-sandbox`, when required, sets this to
335
+ `RobotLab::Sandbox::Executor`, which handles `capabilities`/timeout/cleanup
336
+ itself. See that gem's docs for what it does when installed.
332
337
 
333
- ### ScriptTool.run_with_timeout
338
+ ### ScriptTool.execute
334
339
 
335
340
  ```ruby
336
- RobotLab::ScriptTool.run_with_timeout(cmd, timeout)
337
- # => [String, Process::Status | nil]
341
+ RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
338
342
  ```
339
343
 
340
- Run `cmd` in its own process group (`pgroup: true`), reading combined output
341
- until `timeout` seconds elapse. On expiry it terminates the group and returns
342
- `["<partial output>\n[killed: exceeded <N>s]", nil]` — a `nil` status is the
343
- timeout signal.
344
-
345
- ### ScriptTool.terminate
346
-
347
- ```ruby
348
- RobotLab::ScriptTool.terminate(pid)
349
- ```
344
+ Run a command array and return its combined stdout+stderr, or an error string.
345
+ Two paths:
350
346
 
351
- `Process.kill('-TERM', ...)` against the process **group** of `pid`, so a script
352
- that spawned children takes them down with it. Swallows every error and returns
353
- `nil` a process that already exited is not an error.
347
+ - **`executor` is `nil`** (the default) `Open3.capture2e`, unconfined, **no timeout**.
348
+ - **`executor` is set** delegates entirely to `executor.call(cmd, capabilities:, skill_dir:)`.
349
+ Core no longer knows or cares what the executor does with `capabilities` or
350
+ how (or whether) it bounds execution time.
354
351
 
355
352
  ### ScriptTool.format_result
356
353
 
@@ -393,121 +390,27 @@ cannot be read.
393
390
 
394
391
  ---
395
392
 
396
- ## RobotLab::Sandbox
393
+ ## Confinement: robot_lab-sandbox
397
394
 
398
- Strategy selector for confining skill-script execution. Module functions.
399
-
400
- ### Sandbox.enabled?
401
-
402
- ```ruby
403
- RobotLab::Sandbox.enabled? # => Boolean
404
- RobotLab::Sandbox.enabled?(some_config)
405
- ```
406
-
407
- `true` only when the config responds to `sandbox`, that section exists, and
408
- `sandbox.enabled == true`. **Sandboxing is off by default** — see the
409
- [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section).
410
-
411
- ### Sandbox.macos?
412
-
413
- ```ruby
414
- RobotLab::Sandbox.macos? # => RUBY_PLATFORM.include?("darwin")
415
- ```
416
-
417
- ### Sandbox.for
418
-
419
- ```ruby
420
- strategy = RobotLab::Sandbox.for(grant, skill_dir:, macos: macos?)
421
- # => Sandbox::Seatbelt or Sandbox::Null
422
- ```
423
-
424
- | Name | Type | Default | Description |
425
- |------|------|---------|-------------|
426
- | `grant` | `Capabilities` | **required** | The **already-intersected** effective grant |
427
- | `skill_dir` | `String` | **required** | Bundle root, always granted read access |
428
- | `macos` | `Boolean` | `macos?` | Injectable so both branches are testable on any host |
395
+ `RobotLab::Sandbox`, `RobotLab::Sandbox::Seatbelt`, and `RobotLab::Sandbox::Null`
396
+ used to live here; they now ship in the separate
397
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem, which
398
+ core has no dependency on. Requiring it installs `RobotLab::Sandbox::Executor`
399
+ as [`ScriptTool.executor`](#scripttoolexecutor):
429
400
 
430
- Selection order: a `trust: core` grant gets `Null`; otherwise macOS gets
431
- `Seatbelt`; anything else warns once and gets `Null`.
432
-
433
- ### Sandbox.warn_once_non_macos
434
-
435
- ```ruby
436
- RobotLab::Sandbox.warn_once_non_macos
437
- ```
438
-
439
- Emits `"Sandbox: OS-level confinement is only available on macOS; scripts run
440
- unconfined here"` at `warn`, at most once per process. Idempotent, so a run with
441
- many scripts does not flood the log.
442
-
443
- ---
444
-
445
- ## RobotLab::Sandbox::Null
446
-
447
- Passthrough strategy — used off macOS and for `trust: core` skills.
448
-
449
- | Method | Returns | Description |
450
- |--------|---------|-------------|
451
- | `wrap(cmd)` | `cmd` | Unchanged |
452
- | `cleanup` | `nil` | No-op |
453
-
454
- ---
455
-
456
- ## RobotLab::Sandbox::Seatbelt
457
-
458
- macOS strategy: generates a deny-by-default `sandbox-exec` profile from the grant
459
- and wraps the command as `sandbox-exec -f <profile> <cmd...>`.
460
-
461
- ### Constants
462
-
463
- | Constant | Description |
464
- |----------|-------------|
465
- | `SYSTEM_READ` | `/usr /bin /sbin /System /Library /opt /private/etc /dev /var/select` — the locations an interpreter needs to boot |
466
- | `DEV_WRITE` | `/dev/null /dev/stdout /dev/stderr /dev/dtracehelper /dev/tty` — always writable |
467
-
468
- ### Constructor
469
-
470
- ```ruby
471
- RobotLab::Sandbox::Seatbelt.new(grant, skill_dir:)
472
- ```
473
-
474
- ### wrap / cleanup
475
-
476
- ```ruby
477
- cmd = strategy.wrap(["bash", "script.sh"])
478
- # => ["sandbox-exec", "-f", "/tmp/robot_lab-sandbox-xxxx.sb", "bash", "script.sh"]
479
- strategy.cleanup # unlinks the generated profile
480
- ```
481
-
482
- `wrap` writes the profile to a `Tempfile`; `cleanup` unlinks it and swallows any
483
- error (an already-removed file is fine). `ScriptTool.execute` always calls
484
- `cleanup` in an `ensure`.
485
-
486
- ### profile_text
487
-
488
- ```ruby
489
- strategy.profile_text # => String
490
- ```
401
+ - `Sandbox.enabled?` reads `config.sandbox.enabled` (default `false`).
402
+ - `Sandbox.for(grant, skill_dir:, macos: macos?)` picks `Sandbox::Seatbelt` on
403
+ macOS, `Sandbox::Null` (passthrough) elsewhere or for `trust: core` grants.
404
+ - `Sandbox::Seatbelt` — generates a deny-by-default `sandbox-exec` profile from
405
+ the effective grant (`fs_read`/`fs_write`/`network`), wrapping the command as
406
+ `sandbox-exec -f <profile> <cmd...>`. `$HOME` is never implicitly readable.
407
+ - `Sandbox::Null` — `wrap(cmd)` returns `cmd` unchanged; `cleanup` is a no-op.
408
+ - `Sandbox::Executor` — the piece that plugs into core: intersects the skill's
409
+ `Capabilities` with `Capabilities.ceiling`, wraps and runs the command under
410
+ the chosen strategy, and bounds it with the grant's `timeout`
411
+ (`Process.kill('-TERM', ...)` on the process group on expiry).
491
412
 
492
- The generated Seatbelt profile. Public so the policy can be asserted in tests
493
- rather than inferred from behavior. It imports `bsd.sb` (without which a
494
- deny-default profile aborts the binary before it starts), denies by default, then
495
- allows: `process-fork`, `process-exec`, `sysctl-read`, `mach-lookup`,
496
- `file-read-metadata` on any path, `file-read*` on `SYSTEM_READ` + the skill
497
- directory + granted `fs_read` paths, `file-write*` on `DEV_WRITE` + granted
498
- `fs_write` paths, and `network*` only when the grant allows it.
499
-
500
- Every path is canonicalized to its symlink-free real path first, because macOS
501
- symlinks `/tmp` → `/private/tmp` and the kernel matches against the real path. For
502
- write targets that do not exist yet, the nearest existing ancestor is resolved and
503
- the remainder re-appended.
504
-
505
- !!! warning "`$HOME` is never implicitly readable"
506
- Which is the point — SSH keys and cloud credentials stay out of reach. But it
507
- also means an interpreter installed under `$HOME` (rbenv, asdf, mise, a
508
- Homebrew prefix in `~`) is **invisible** to the sandboxed process and the
509
- script fails to start. Grant that path explicitly in `fs_read`, or mark the
510
- skill `trust: core`.
413
+ Full reference lives in that gem's own docs.
511
414
 
512
415
  ---
513
416
 
@@ -515,5 +418,6 @@ the remainder re-appended.
515
418
 
516
419
  - [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
517
420
  - [Configuration: `sandbox:` section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section)
421
+ - [robot_lab-sandbox](https://github.com/MadBomber/robot_lab-sandbox) — the optional confinement gem
518
422
  - [Robot: Skills](core/robot.md#skills) — template skills, the other meaning
519
423
  - [Tool](core/tool.md) — `Tool.create`, which `ScriptTool` builds on
data/docs/api/support.md CHANGED
@@ -842,5 +842,5 @@ Thread-safe monotonic counter for event ordering.
842
842
 
843
843
  - [Core Classes](core/index.md) — `Robot`, `Network`, `Memory`, `Tool`, `RobotResult`
844
844
  - [Hooks API](hooks.md) — the extension seam these classes are wired into
845
- - [Skills API](skills.md) — `AgentSkill`, `Capabilities`, `ScriptTool`, `Sandbox`
845
+ - [Skills API](skills.md) — `AgentSkill`, `Capabilities`, `ScriptTool` (confinement lives in the optional `robot_lab-sandbox` gem)
846
846
  - [Errors](errors.md) — including `Errors.retryable?` and `Errors.retryable_classes`
@@ -226,7 +226,7 @@ end
226
226
  | `:results` | `Array` | Accumulated robot results |
227
227
  | `:messages` | `Array` | Conversation history |
228
228
  | `:session_id` | `String` | Session identifier |
229
- | `:cache` | `Module` | Semantic cache (RubyLLM::SemanticCache) |
229
+ | `:cache` | `Module`, `nil` | Semantic cache (RubyLLM::SemanticCache when the optional gem is installed) |
230
230
 
231
231
  ### Reactive Features
232
232
 
@@ -62,7 +62,7 @@ graph TB
62
62
  H[MCP Client]
63
63
  I[Tools &lt; RubyLLM::Tool]
64
64
  J[Templates / prompt_manager]
65
- SK[AgentSkills + Sandbox]
65
+ SK[AgentSkills<br/>+ optional Sandbox]
66
66
  end
67
67
 
68
68
  subgraph "Execution Layer"
@@ -138,8 +138,9 @@ graph LR
138
138
  AS --> CAT[AgentSkillCatalog]
139
139
  AS --> CAP[Capabilities]
140
140
  AS --> ST[ScriptTool]
141
- CAP --> SB[Sandbox<br/>Seatbelt or Null]
142
- ST --> SB
141
+ ST --> EX{ScriptTool.executor}
142
+ EX -. "installed by<br/>robot_lab-sandbox" .-> SB[Sandbox<br/>Seatbelt or Null]
143
+ CAP -. "used if installed" .-> SB
143
144
 
144
145
  HC --> TA[TextAnalysis<br/>TF / TF-IDF]
145
146
  HS --> TA
@@ -165,7 +166,7 @@ dependency.
165
166
  | **Config** | MywayConfig-based global configuration with env var and file support | [Configuration](#configuration) |
166
167
  | **Hook** | Handler base class for the seven hook families — the framework's extension seam | [Hooks API](../api/hooks.md) |
167
168
  | **AgentSkill** | A `SKILL.md` bundle whose instructions and `scripts/` become prompt text and tools | [Skills API](../api/skills.md) |
168
- | **Sandbox** | Opt-in OS-level confinement (macOS Seatbelt) for skill scripts, derived from `Capabilities` | [Skills API](../api/skills.md) |
169
+ | **Sandbox** | Opt-in OS-level confinement (macOS Seatbelt) for skill scripts, derived from `Capabilities`; ships in the optional `robot_lab-sandbox` gem, not core | [Skills API](../api/skills.md) |
169
170
  | **RobotMessage** | Immutable envelope for TypedBus inter-robot messaging, serialized per robot by `BusPoller` | [Support API](../api/support.md) |
170
171
  | **Budget::Ledger** | Thread-safe reserve/reconcile ledger behind `token_budget` / `cost_budget` | [Support API](../api/support.md) |
171
172
 
@@ -13,7 +13,7 @@ memory.data # StateProxy - custom key-value data with method-style access
13
13
  memory.results # Array<RobotResult> - execution history
14
14
  memory.messages # Array<Message> - conversation history
15
15
  memory.session_id # String - optional persistence identifier
16
- memory.cache # RubyLLM::SemanticCache - semantic caching module
16
+ memory.cache # RubyLLM::SemanticCache when the optional gem is installed, else nil
17
17
  ```
18
18
 
19
19
  ## Standalone Robot Memory
@@ -108,7 +108,7 @@ Memory has five reserved keys with special behavior and dedicated accessors:
108
108
  | `:results` | `Array<RobotResult>` | Accumulated robot execution results |
109
109
  | `:messages` | `Array<Message>` | Conversation history |
110
110
  | `:session_id` | `String` | Conversation session identifier |
111
- | `:cache` | `RubyLLM::SemanticCache` | Semantic cache module (read-only after init) |
111
+ | `:cache` | `RubyLLM::SemanticCache` or `nil` | Semantic cache module when the optional ruby_llm-semantic_cache gem is installed (read-only after init) |
112
112
 
113
113
  Reserved keys are accessed through dedicated methods and are excluded from `memory.keys`:
114
114
 
@@ -118,7 +118,7 @@ memory.data.category #=> "billing" (method-style via StateProxy)
118
118
 
119
119
  memory.results #=> []
120
120
  memory.session_id #=> nil
121
- memory.cache #=> RubyLLM::SemanticCache (the module itself), or nil when enable_cache: false
121
+ memory.cache #=> RubyLLM::SemanticCache (the module itself), or nil when enable_cache: false or the optional gem is absent
122
122
  ```
123
123
 
124
124
  ## StateProxy
@@ -319,7 +319,7 @@ memory = Memory.from_hash(hash)
319
319
 
320
320
  ## Semantic Cache
321
321
 
322
- Memory includes a semantic cache via `RubyLLM::SemanticCache` that reduces costs and latency by returning cached responses for semantically equivalent queries:
322
+ Memory includes a semantic cache via `RubyLLM::SemanticCache` (optional ruby_llm-semantic_cache gem; not yet ruby_llm 2.0-compatible, so `memory.cache` is `nil` without it) that reduces costs and latency by returning cached responses for semantically equivalent queries:
323
323
 
324
324
  ```ruby
325
325
  # Using the cache with fetch
data/docs/concepts.md CHANGED
@@ -338,7 +338,7 @@ robot.clear_messages(keep_system: true)
338
338
  | `:results` | Accumulated robot results |
339
339
  | `:messages` | Conversation history |
340
340
  | `:session_id` | Session identifier for history persistence |
341
- | `:cache` | Semantic cache instance (RubyLLM::SemanticCache) |
341
+ | `:cache` | Semantic cache (RubyLLM::SemanticCache when the optional gem is installed, else nil) |
342
342
 
343
343
  ### Reactive Memory in Networks
344
344
 
@@ -278,7 +278,10 @@ robot = RobotLab.build(
278
278
 
279
279
  ### Skill-Script Sandboxing (`sandbox:` section)
280
280
 
281
- Opt-in confinement for the scripts a [skill bundle](../guides/using-tools.md#skill-scripts-and-sandboxing) exposes as tools. Disabled by default scripts run exactly as before until you turn it on:
281
+ Core declares this config schema but has no sandboxing behavior of its ownenforcement
282
+ lives in the optional [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox)
283
+ gem. Without it, skill scripts always run unconfined regardless of these values; requiring
284
+ it installs a `RobotLab::ScriptTool.executor` that honors them:
282
285
 
283
286
  | Key | Default | Description |
284
287
  |-----|---------|-------------|
@@ -35,13 +35,10 @@ RobotLab automatically installs these core dependencies:
35
35
 
36
36
  | Gem | Purpose |
37
37
  |-----|---------|
38
- | `ruby_llm` (~> 1.12) | LLM provider integrations (Anthropic, OpenAI, Gemini, etc.) |
38
+ | `ruby_llm` (~> 2.0.0.rc3) | LLM provider integrations (Anthropic, OpenAI, Gemini, etc.) |
39
39
  | `prompt_manager` (~> 1.0) | Template-based prompt management with YAML front matter |
40
40
  | `simple_flow` (~> 0.4) | Pipeline workflow execution for networks |
41
41
  | `myway_config` (~> 0.1) | Layered configuration (defaults, env vars, config files) |
42
- | `ruby_llm-mcp` (~> 1.0) | Model Context Protocol client for external tool servers |
43
- | `ruby_llm-schema` (~> 0.3) | Schema validation for structured outputs |
44
- | `ruby_llm-semantic_cache` (~> 0.1) | Semantic caching for LLM responses |
45
42
  | `zeitwerk` (~> 2.6) | Autoloading and eager loading |
46
43
  | `async` (~> 2.0) | Fiber-based concurrency |
47
44
  | `async-http` (~> 0.60) | MCP SSE and streamable-HTTP transports |
data/docs/guides/hooks.md CHANGED
@@ -1076,6 +1076,128 @@ RobotLab.on(RedactionHook)
1076
1076
 
1077
1077
  ---
1078
1078
 
1079
+ ## Extension Registration
1080
+
1081
+ Hooks inject *behavior* ("do this on these events"). A separate, lighter
1082
+ mechanism — **extension registration** — answers a different question: *"is
1083
+ optional feature X loaded?"* This is how core lights up conditional behavior
1084
+ without depending on, or probing for, the gem that provides it. The two
1085
+ mechanisms are orthogonal but commonly used together: a gem registers itself
1086
+ (discovery), then wires behavior in via a hook (injection).
1087
+
1088
+ ### The API
1089
+
1090
+ ```ruby
1091
+ RobotLab.register_extension(:audit, RobotLab::Audit) # gem announces itself
1092
+ RobotLab.extension_loaded?(:audit) # => true/false — core's guard
1093
+ RobotLab.extension(:audit) # => RobotLab::Audit
1094
+ RobotLab.loaded_extensions # => [:audit, :ractor, ...]
1095
+ ```
1096
+
1097
+ It is deliberately tiny: a `Hash` and four methods. The registered value can be
1098
+ the extension's primary module, or just a sentinel when the gem only needs a
1099
+ presence flag:
1100
+
1101
+ ```ruby
1102
+ RobotLab.register_extension(:ractor, :ractor_extension_loaded)
1103
+ ```
1104
+
1105
+ ### How an Extension Announces Itself
1106
+
1107
+ At the bottom of the gem's entry file, after everything it provides is
1108
+ defined, guarded so the gem can also load standalone or against an older core:
1109
+
1110
+ ```ruby
1111
+ # robot_lab-audit/lib/robot_lab/audit.rb (tail)
1112
+ if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
1113
+ RobotLab.register_extension(:audit, RobotLab::Audit)
1114
+ end
1115
+ ```
1116
+
1117
+ The same one-liner appears verbatim in `robot_lab-document_store`,
1118
+ `robot_lab-durable`, `robot_lab-discovery`, etc. Registration last, guarded
1119
+ always.
1120
+
1121
+ ### How Core Consumes It
1122
+
1123
+ Core guards optional behavior with `extension_loaded?` instead of scattered
1124
+ `defined?` / `respond_to?` probes — one named question, asked in one style:
1125
+
1126
+ ```ruby
1127
+ # tool.rb — only use the Ractor pool when the gem is present
1128
+ self.class.ractor_safe? && !self.class.name.nil? && RobotLab.extension_loaded?(:ractor)
1129
+
1130
+ # memory.rb — semantic features require the document_store gem
1131
+ unless RobotLab.extension_loaded?(:document_store)
1132
+
1133
+ end
1134
+ ```
1135
+
1136
+ When a guarded feature is *requested* without its extension, core raises a
1137
+ helpful error naming the exact gem to add — not a cryptic `NoMethodError`:
1138
+
1139
+ ```ruby
1140
+ # network.rb — parallel_mode: :ractor needs the gem
1141
+ def run_with_ractor_scheduler(run_context)
1142
+ unless RobotLab.extension_loaded?(:ractor)
1143
+ raise RobotLab::DependencyError,
1144
+ "parallel_mode: :ractor requires the robot_lab-ractor gem. " \
1145
+ "Add `gem 'robot_lab-ractor'` to your Gemfile."
1146
+ end
1147
+
1148
+ end
1149
+ ```
1150
+
1151
+ ### Version-Skew Tolerance
1152
+
1153
+ `robot_lab-a2a` ships a fallback so it works even against a core too old to
1154
+ provide the registry API: if `register_extension` isn't defined, the gem
1155
+ **defines it** (plus `extension_loaded?`/`extension`) over its own private
1156
+ `@_extensions` hash, then registers itself:
1157
+
1158
+ ```ruby
1159
+ module RobotLab
1160
+ @_extensions = {} unless instance_variable_defined?(:@_extensions)
1161
+
1162
+ class << self
1163
+ unless method_defined?(:register_extension) || respond_to?(:register_extension)
1164
+ def register_extension(name, mod) = @_extensions[name.to_sym] = mod
1165
+ end
1166
+ unless method_defined?(:extension_loaded?) || respond_to?(:extension_loaded?)
1167
+ def extension_loaded?(name) = @_extensions.key?(name.to_sym)
1168
+ end
1169
+ # … extension(name) likewise …
1170
+ end
1171
+ end
1172
+
1173
+ RobotLab.register_extension(:a2a, RobotLab::A2A)
1174
+ ```
1175
+
1176
+ The gem never assumes the core's age; it provides the contract if it must.
1177
+
1178
+ ### How It Composes with Hooks
1179
+
1180
+ - `register_extension(:audit, RobotLab::Audit)` — *"I exist"* (discovery).
1181
+ - Inside `Audit.enable(db_path:)` → `RobotLab.on(Hook)` — *"do this on these
1182
+ events"* (behavior).
1183
+
1184
+ Two common shapes:
1185
+
1186
+ - **Auto-wire at load** — register the extension *and* `RobotLab.on(SomeHook)`
1187
+ at require time (always-on features).
1188
+ - **Opt-in enable** — register the extension at load, but expose
1189
+ `enable!`/`enable(...)` that registers the hook on demand
1190
+ (`Audit.enable`, `Narrator.enable!`). Lets the user choose scope/config.
1191
+
1192
+ > [!TIP]
1193
+ > Design decisions worth stealing: a named capability registry beats
1194
+ > duck-typing; core never `require`s an extension — the extension `require`s
1195
+ > core and announces itself; a sentinel value works when you only need a
1196
+ > presence flag; and using a guarded feature without its gem should raise a
1197
+ > helpful error naming the exact gem to add.
1198
+
1199
+ ---
1200
+
1079
1201
  ## See Also
1080
1202
 
1081
1203
  - [examples/35_hooks.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/35_hooks.rb) — full demo with xyzzy extension, perf timer, LLM response cache, and tracer hooks
@@ -10,7 +10,7 @@ Memory is a reactive key-value store that provides:
10
10
  - Reserved keys for structured data (`:data`, `:results`, `:messages`, `:session_id`, `:cache`)
11
11
  - Reactive subscriptions and blocking reads for inter-robot communication
12
12
  - Optional Redis backend for persistence
13
- - Semantic caching via `RubyLLM::SemanticCache`
13
+ - Semantic caching via `RubyLLM::SemanticCache` (optional ruby_llm-semantic_cache gem)
14
14
 
15
15
  ## Standalone Robot Memory
16
16
 
@@ -86,7 +86,7 @@ Memory has reserved keys with special behavior:
86
86
  | `:results` | Array | Accumulated robot results |
87
87
  | `:messages` | Array | Conversation history |
88
88
  | `:session_id` | String | Session identifier for history persistence |
89
- | `:cache` | `RubyLLM::SemanticCache` module, or `nil` | Semantic cache (read-only after init). Set at construction time; `nil` when built with `enable_cache: false` |
89
+ | `:cache` | `RubyLLM::SemanticCache` module, or `nil` | Semantic cache (read-only after init). Set at construction time; `nil` when built with `enable_cache: false` or when the optional gem is absent |
90
90
 
91
91
  ### The Data Hash
92
92
 
@@ -335,13 +335,22 @@ trust: external # or "core" for trusted, always-unconfined skills
335
335
  ---
336
336
  ```
337
337
 
338
- Sandboxing itself is **opt-in and off by default** — see the [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section). When disabled, scripts run exactly as they always have, unconfined. When enabled:
338
+ `Capabilities` (the declaration above) lives in core, but core itself has **no sandboxing
339
+ behavior and no built-in limitations** — a script always runs unconfined via a plain
340
+ `Open3.capture2e`, with no timeout, unless the optional
341
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem is required.
342
+ Requiring it installs `RobotLab::Sandbox::Executor` as `RobotLab::ScriptTool.executor`;
343
+ confinement still stays **opt-in and off by default** from there — see the
344
+ [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section).
345
+ When enabled:
339
346
 
340
347
  - The global `sandbox:` config is a **ceiling** (`fs_read`, `fs_write`, `network`, `timeout`); each skill's front matter is its **declared** request. The script actually runs under the **intersection** of the two — a path outside the ceiling's roots is dropped even if the skill declares it, `network` requires both sides to allow it, and `timeout` is the smaller of the two.
341
348
  - On macOS, confinement is enforced with a generated `sandbox-exec` (Seatbelt) profile: deny-by-default, with narrow allowances for the interpreter to boot, the granted read/write paths, and (optionally) the network. Notably, `$HOME` is never implicitly readable — SSH keys and cloud credentials stay out of reach unless a path under `$HOME` is explicitly granted.
342
349
  - Off macOS, or for any skill declaring `trust: core`, sandboxing is a passthrough — confinement is currently macOS-only and is always skipped for trusted "core" skills regardless of platform.
343
350
  - A script that runs past its `timeout` is killed (its whole process group) and reported back to the LLM as a timed-out error rather than hanging the turn.
344
351
 
352
+ Full API reference: [robot_lab-sandbox](https://github.com/MadBomber/robot_lab-sandbox).
353
+
345
354
  ## Parameter Types
346
355
 
347
356
  Define parameters on `RubyLLM::Tool` subclasses using `param`:
data/examples/.envrc CHANGED
@@ -1 +1,3 @@
1
+ source_up
2
+
1
3
  export ROBOT_LAB_TEMPLATE_PATH="${PWD}/prompts"
data/examples/02_tools.rb CHANGED
@@ -14,17 +14,17 @@ require_relative "common"
14
14
  class Calculator < RubyLLM::Tool
15
15
  description "Performs basic arithmetic operations"
16
16
 
17
- param :operation,
17
+ parameter :operation,
18
18
  type: "string",
19
- desc: "The operation to perform (add, subtract, multiply, divide)"
19
+ description: "The operation to perform (add, subtract, multiply, divide)"
20
20
 
21
- param :a,
21
+ parameter :a,
22
22
  type: "number",
23
- desc: "First operand"
23
+ description: "First operand"
24
24
 
25
- param :b,
25
+ parameter :b,
26
26
  type: "number",
27
- desc: "Second operand"
27
+ description: "Second operand"
28
28
 
29
29
  def execute(operation:, a:, b:)
30
30
  case operation
@@ -40,9 +40,9 @@ end
40
40
  class FortuneCookie < RubyLLM::Tool
41
41
  description "Get a fortune cookie message with wisdom and lucky numbers"
42
42
 
43
- param :category,
43
+ parameter :category,
44
44
  type: "string",
45
- desc: "The category of fortune (wisdom, love, career, adventure)"
45
+ description: "The category of fortune (wisdom, love, career, adventure)"
46
46
 
47
47
  FORTUNES = {
48
48
  "wisdom" => [
@@ -40,7 +40,7 @@ end
40
40
 
41
41
  # Shared RunConfig — operational defaults every robot in this network inherits.
42
42
  #
43
- # RunConfig has no `provider` field (see RunConfig::FIELDS), and an Ollama
43
+ # RunConfig has no `provider` field (see RunConfig::FIELDS), and an LM Studio
44
44
  # model is absent from RubyLLM's registry, so provider and model still travel
45
45
  # together on each robot via **llm_opts. The RunConfig carries the settings
46
46
  # that genuinely are shared.
data/examples/04_mcp.rb CHANGED
@@ -163,14 +163,16 @@ begin
163
163
  puts "Query: 'What are the top 3 most starred Ruby web frameworks on GitHub?'"
164
164
  hr
165
165
 
166
- # mcp: :inherit and tools: :inherit are both required. Both keywords default
167
- # to :none on run(), which resolves to "no MCP servers this turn" and "send
168
- # zero tools this turn" the discovered MCP tools would never reach the
169
- # provider and the model would answer from its training data.
166
+ # mcp: :inherit is required run() defaults it to :none, which resolves to
167
+ # "no MCP servers this turn" and the discovered tools would never reach the
168
+ # provider. tools: takes an allowlist of tool names; :inherit would send all
169
+ # ~90 GitHub tools, whose JSON schemas alone exceed 13k tokens and overflow
170
+ # a local model's context window (LM Studio's default is 8192) before the
171
+ # query even arrives. Send only the one tool this query needs.
170
172
  result = robot.run(
171
173
  "What are the top 3 most starred Ruby web frameworks on GitHub? Just list their names and star counts.",
172
174
  mcp: :inherit,
173
- tools: :inherit
175
+ tools: %w[search_repositories]
174
176
  )
175
177
 
176
178
  puts