robot_lab 0.2.6 → 0.2.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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +4 -0
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +17 -1
  6. data/README.md +124 -64
  7. data/Rakefile +6 -111
  8. data/_typos.toml +21 -0
  9. data/docs/api/core/index.md +41 -15
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +255 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +551 -61
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +110 -17
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +80 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +423 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +79 -31
  33. data/docs/architecture/index.md +87 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +145 -38
  36. data/docs/architecture/robot-execution.md +172 -90
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +288 -98
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +258 -50
  48. data/docs/guides/creating-networks.md +214 -30
  49. data/docs/guides/hooks.md +263 -54
  50. data/docs/guides/knowledge.md +35 -4
  51. data/docs/guides/mcp-integration.md +211 -44
  52. data/docs/guides/memory.md +103 -12
  53. data/docs/guides/observability.md +95 -47
  54. data/docs/guides/streaming.md +184 -125
  55. data/docs/guides/using-tools.md +247 -18
  56. data/docs/index.md +36 -4
  57. data/examples/01_simple_robot.rb +2 -2
  58. data/examples/02_tools.rb +14 -4
  59. data/examples/03_network.rb +12 -7
  60. data/examples/04_mcp.rb +11 -4
  61. data/examples/05_streaming.rb +8 -5
  62. data/examples/06_prompt_templates.rb +13 -9
  63. data/examples/07_network_memory.rb +5 -5
  64. data/examples/08_llm_config.rb +20 -15
  65. data/examples/09_chaining.rb +4 -4
  66. data/examples/11_network_introspection.rb +4 -4
  67. data/examples/12_message_bus.rb +2 -2
  68. data/examples/13_spawn.rb +2 -2
  69. data/examples/14_rusty_circuit/README.md +1 -0
  70. data/examples/14_rusty_circuit/comic.rb +7 -3
  71. data/examples/14_rusty_circuit/display.rb +14 -0
  72. data/examples/14_rusty_circuit/heckler.rb +8 -6
  73. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  74. data/examples/14_rusty_circuit/scout.rb +17 -10
  75. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  76. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  77. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  78. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  79. data/examples/16_writers_room/writer.rb +22 -22
  80. data/examples/16_writers_room/writers_room.rb +2 -0
  81. data/examples/17_skills.rb +14 -13
  82. data/examples/18_rails/README.md +20 -1
  83. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  84. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  85. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  86. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  87. data/examples/19_token_tracking.rb +25 -9
  88. data/examples/20_circuit_breaker.rb +10 -7
  89. data/examples/21_learning_loop.rb +42 -16
  90. data/examples/22_context_compression.rb +23 -23
  91. data/examples/23_convergence.rb +24 -17
  92. data/examples/24_structured_delegation.rb +13 -8
  93. data/examples/25_history_search.rb +12 -8
  94. data/examples/27_incident_response/incident_response.rb +31 -13
  95. data/examples/28_mcp_discovery.rb +17 -13
  96. data/examples/29_ractor_tools.rb +4 -2
  97. data/examples/30_ractor_network.rb +22 -17
  98. data/examples/31_launch_assessment.rb +20 -9
  99. data/examples/32_newsletter_reader.rb +7 -2
  100. data/examples/33_stock_predictor.rb +34 -13
  101. data/examples/34_agentskills.rb +7 -3
  102. data/examples/35_hooks.rb +18 -8
  103. data/examples/README.md +199 -45
  104. data/examples/common.rb +79 -11
  105. data/examples/xyzzy.rb +8 -1
  106. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  107. data/lib/robot_lab/ask_user.rb +2 -0
  108. data/lib/robot_lab/bus_poller.rb +2 -0
  109. data/lib/robot_lab/capabilities.rb +4 -0
  110. data/lib/robot_lab/config.rb +18 -5
  111. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  112. data/lib/robot_lab/history_compressor.rb +5 -0
  113. data/lib/robot_lab/hook.rb +1 -0
  114. data/lib/robot_lab/hook_context.rb +4 -0
  115. data/lib/robot_lab/hook_registry.rb +1 -0
  116. data/lib/robot_lab/hooks.rb +6 -3
  117. data/lib/robot_lab/mcp/client.rb +2 -2
  118. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  119. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  120. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  121. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  122. data/lib/robot_lab/mcp/transports/streamable_http.rb +4 -0
  123. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  124. data/lib/robot_lab/memory.rb +23 -6
  125. data/lib/robot_lab/memory_change.rb +1 -0
  126. data/lib/robot_lab/message.rb +3 -0
  127. data/lib/robot_lab/names.rb +400 -0
  128. data/lib/robot_lab/network.rb +13 -6
  129. data/lib/robot_lab/robot/agent_skill_matching.rb +3 -3
  130. data/lib/robot_lab/robot/bus_messaging.rb +19 -8
  131. data/lib/robot_lab/robot/history_search.rb +2 -0
  132. data/lib/robot_lab/robot/hooking.rb +3 -0
  133. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  134. data/lib/robot_lab/robot/template_rendering.rb +29 -7
  135. data/lib/robot_lab/robot.rb +81 -19
  136. data/lib/robot_lab/robot_result.rb +3 -0
  137. data/lib/robot_lab/run_config.rb +5 -0
  138. data/lib/robot_lab/script_tool.rb +20 -39
  139. data/lib/robot_lab/state_proxy.rb +1 -0
  140. data/lib/robot_lab/streaming/context.rb +1 -0
  141. data/lib/robot_lab/streaming/events.rb +1 -0
  142. data/lib/robot_lab/task.rb +2 -0
  143. data/lib/robot_lab/tool.rb +4 -0
  144. data/lib/robot_lab/user_message.rb +1 -0
  145. data/lib/robot_lab/utils.rb +2 -0
  146. data/lib/robot_lab/version.rb +1 -1
  147. data/lib/robot_lab/waiter.rb +3 -0
  148. data/lib/robot_lab.rb +27 -18
  149. data/mkdocs.yml +6 -1
  150. metadata +12 -7
  151. data/lib/robot_lab/sandbox/null.rb +0 -13
  152. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  153. data/lib/robot_lab/sandbox.rb +0 -52
@@ -0,0 +1,423 @@
1
+ # Skills API
2
+
3
+ Class-level reference for the AgentSkills subsystem: skill bundles, the scripts
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
10
+ [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
11
+ and [Building Robots: Composable Skills](../guides/building-robots.md#composable-skills).
12
+
13
+ !!! note "Two different things are called 'skills'"
14
+ **Template skills** (`RobotLab.build(skills: [:clarifier])`) are ordinary
15
+ prompt templates whose bodies are prepended to a robot's system prompt — see
16
+ [Robot: Skills](core/robot.md#skills). **AgentSkills** (this page) are
17
+ `SKILL.md` *bundles* on disk, discovered from `~/.prompts/skills/`, matched
18
+ to a message by embedding similarity at run time, and capable of contributing
19
+ executable tools. They share the word but not the mechanism.
20
+
21
+ ```mermaid
22
+ graph LR
23
+ subgraph "Discovery"
24
+ C[AgentSkillCatalog<br/>~/.prompts/skills/]
25
+ S[AgentSkill<br/>SKILL.md]
26
+ end
27
+
28
+ subgraph "Capability grant"
29
+ D[Capabilities<br/>declared in front matter]
30
+ CE[Capabilities.ceiling<br/>from config.sandbox]
31
+ G[effective grant<br/>declared ∩ ceiling]
32
+ end
33
+
34
+ subgraph "Execution (core)"
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)"
41
+ SB{Sandbox.enabled?}
42
+ SE[Sandbox::Seatbelt<br/>macOS]
43
+ NU[Sandbox::Null<br/>passthrough]
44
+ end
45
+
46
+ R[Robot<br/>AgentSkillMatching] --> C
47
+ C --> S
48
+ S --> D
49
+ S --> T
50
+ D --> G
51
+ CE --> G
52
+ T --> EX
53
+ EX -- "nil (default)" --> UN
54
+ EX -- "installed" --> SB
55
+ SB -- "off, or trust: core,<br/>or non-macOS" --> NU
56
+ SB -- "on + macOS" --> SE
57
+ G --> SE
58
+ ```
59
+
60
+ ---
61
+
62
+ ## RobotLab::AgentSkill
63
+
64
+ Immutable value object for one skill folder: a directory containing a `SKILL.md`
65
+ with `name` and `description` front matter, plus optional `scripts/`,
66
+ `references/`, and `assets/` subdirectories.
67
+
68
+ ### Constructor
69
+
70
+ ```ruby
71
+ skill = RobotLab::AgentSkill.new("~/.prompts/skills/deploy-checker/SKILL.md")
72
+ ```
73
+
74
+ | Name | Type | Description |
75
+ |------|------|-------------|
76
+ | `skill_md_path` | `String`, `Pathname` | Path to the `SKILL.md` file itself, **not** the directory |
77
+
78
+ **Raises `RobotLab::ConfigurationError`** when front matter is missing `name` or
79
+ `description` (or either is blank) — and because a file with no `---` block
80
+ parses to an empty hash, a `SKILL.md` without front matter always raises.
81
+ Malformed YAML raises `Psych::SyntaxError` instead, straight from
82
+ `YAML.safe_load`. [`AgentSkillCatalog`](#robotlabagentskillcatalog) rescues both
83
+ and skips the bundle; construct an `AgentSkill` directly and you get the
84
+ exception.
85
+
86
+ ### Attributes
87
+
88
+ | Attribute | Type | Description |
89
+ |-----------|------|-------------|
90
+ | `name` | `String` | Front-matter `name`; also the catalog lookup key (symbolized) |
91
+ | `description` | `String` | Front-matter `description`; the text matched against the user's message |
92
+ | `path` | `Pathname` | The skill **directory** (`dirname` of the `SKILL.md` path) |
93
+ | `capabilities` | `Capabilities` | Built from front matter via `Capabilities.from_front_matter` |
94
+
95
+ ### instructions
96
+
97
+ ```ruby
98
+ skill.instructions # => String
99
+ ```
100
+
101
+ The `SKILL.md` body below the front matter, stripped. This is the text
102
+ `Robot::AgentSkillMatching` prepends to the system prompt when the skill matches.
103
+ Memoized.
104
+
105
+ ### scripts
106
+
107
+ ```ruby
108
+ skill.scripts # => Array<Pathname>
109
+ ```
110
+
111
+ Every **file** directly inside the skill's `scripts/` directory, sorted. Returns
112
+ `[]` when there is no `scripts/` directory. Not recursive — subdirectories are
113
+ skipped. Memoized.
114
+
115
+ ### script_tools
116
+
117
+ ```ruby
118
+ skill.script_tools # => Array<RobotLab::Tool>
119
+ ```
120
+
121
+ One `RobotLab::Tool` per script, built with
122
+ [`ScriptTool.from_path`](#scripttoolfrom_path) and carrying this skill's
123
+ `capabilities` and directory. **Non-executable scripts are skipped** (logged at
124
+ `warn` and filtered out by `filter_map`), so this array can be shorter than
125
+ `scripts`. Memoized.
126
+
127
+ These tools are appended to `robot.local_tools` for the duration of a matched
128
+ `run` and removed again in the `ensure` block — see
129
+ [Robot Execution](../architecture/robot-execution.md#execution-overview).
130
+
131
+ ---
132
+
133
+ ## RobotLab::AgentSkillCatalog
134
+
135
+ Lazily-loaded registry of the skill folders under a root directory.
136
+
137
+ ### SKILLS_ROOT
138
+
139
+ ```ruby
140
+ RobotLab::AgentSkillCatalog::SKILLS_ROOT
141
+ # => #<Pathname:/Users/you/.prompts/skills>
142
+ ```
143
+
144
+ `~/.prompts/skills`, expanded at load time. The path the process-level singleton
145
+ scans.
146
+
147
+ ### instance / reset!
148
+
149
+ ```ruby
150
+ RobotLab::AgentSkillCatalog.instance # => the singleton, scanning SKILLS_ROOT
151
+ RobotLab::AgentSkillCatalog.reset! # => nil; next `instance` builds a fresh one
152
+ ```
153
+
154
+ `instance` memoizes. `reset!` drops the memo — it exists so tests can point the
155
+ catalog at a fixture directory by resetting and constructing an instance
156
+ explicitly with a different root.
157
+
158
+ ### Constructor
159
+
160
+ ```ruby
161
+ catalog = RobotLab::AgentSkillCatalog.new("/path/to/skills")
162
+ ```
163
+
164
+ | Name | Type | Default | Description |
165
+ |------|------|---------|-------------|
166
+ | `skills_root` | `String`, `Pathname` | `SKILLS_ROOT` | Directory to scan |
167
+
168
+ Construction does **no** I/O; the scan happens on the first `find`/`all`.
169
+
170
+ ### find
171
+
172
+ ```ruby
173
+ catalog.find(:deploy_checker) # => AgentSkill or nil
174
+ catalog.find("deploy-checker") # => AgentSkill or nil
175
+ ```
176
+
177
+ Look up by skill **name** (the front-matter `name`, symbolized) — not by
178
+ directory name, and not by file path. Returns `nil` when not found.
179
+
180
+ ### all
181
+
182
+ ```ruby
183
+ catalog.all # => Array<AgentSkill>
184
+ ```
185
+
186
+ Every successfully-loaded skill.
187
+
188
+ !!! note "Loading is lazy, thread-safe, and forgiving"
189
+ The scan runs once, under a `Mutex`, on the first `find` or `all`. A missing
190
+ root directory is not an error — the catalog is simply empty. A directory
191
+ without a `SKILL.md` is skipped silently; a `SKILL.md` that raises
192
+ `ConfigurationError` or `Psych::SyntaxError` is skipped with a `warn`
193
+ (`"AgentSkillCatalog: <message>, skipping <dir>"`). One bad bundle never
194
+ prevents the others from loading, and the scan is never retried.
195
+
196
+ ---
197
+
198
+ ## RobotLab::Capabilities
199
+
200
+ What a skill's scripts may read, write, reach, and how long they may run.
201
+
202
+ A skill declares what it **wants** in `SKILL.md` front matter; the global
203
+ `sandbox:` config declares the **ceiling**. The effective grant is the
204
+ [intersection](#intersect) of the two.
205
+
206
+ ### Constants
207
+
208
+ | Constant | Value | Description |
209
+ |----------|-------|-------------|
210
+ | `DEFAULT_TIMEOUT` | `60` | Seconds, used when `timeout` is absent or non-positive |
211
+ | `TRUST_LEVELS` | `["core", "external"]` | Any other value falls back to `"external"` |
212
+
213
+ ### Constructor
214
+
215
+ ```ruby
216
+ RobotLab::Capabilities.new(
217
+ fs_read: [], fs_write: [], network: false,
218
+ timeout: DEFAULT_TIMEOUT, trust: "external"
219
+ )
220
+ ```
221
+
222
+ | Name | Type | Default | Coercion |
223
+ |------|------|---------|----------|
224
+ | `fs_read` | `Array<String>` | `[]` | `Array(...)` then `to_s` on each entry |
225
+ | `fs_write` | `Array<String>` | `[]` | Same |
226
+ | `network` | `Boolean` | `false` | Any truthy value becomes `true` |
227
+ | `timeout` | `Integer` | `60` | `to_i`; anything not positive becomes `DEFAULT_TIMEOUT` |
228
+ | `trust` | `String` | `"external"` | Must be in `TRUST_LEVELS`, else `"external"` |
229
+
230
+ Every value is normalized in the constructor, so the readers `fs_read`,
231
+ `fs_write`, `network`, `timeout`, and `trust` always return well-formed values —
232
+ a malformed `SKILL.md` degrades to the safe default rather than raising.
233
+
234
+ ### from_front_matter
235
+
236
+ ```ruby
237
+ RobotLab::Capabilities.from_front_matter(front_matter_hash) # => Capabilities
238
+ ```
239
+
240
+ Build from a parsed `SKILL.md` front-matter hash, reading `fs_read`, `fs_write`,
241
+ `network`, `timeout`, and `trust`. A `nil` front matter yields an all-defaults
242
+ instance.
243
+
244
+ ### fm_value
245
+
246
+ ```ruby
247
+ RobotLab::Capabilities.fm_value(front_matter, :network, false)
248
+ ```
249
+
250
+ Look up a front-matter key tolerating **either** string or symbol keys (string
251
+ first, then symbol), returning `default` when both are `nil`. Exposed because
252
+ `from_front_matter` uses it and skill-tooling may need the same leniency.
253
+
254
+ ### ceiling
255
+
256
+ ```ruby
257
+ RobotLab::Capabilities.ceiling # => from RobotLab.config.sandbox
258
+ RobotLab::Capabilities.ceiling(custom_config)
259
+ ```
260
+
261
+ The maximum grant any skill may receive, read from the config's `sandbox:`
262
+ section. When there is no `sandbox` section at all, the ceiling is
263
+ `Capabilities.new(fs_read: ["."])` — read-only access to the working directory,
264
+ no writes, no network.
265
+
266
+ Note the ceiling never carries a `trust` — trust is a property of the skill, not
267
+ of the ceiling, and `intersect` keeps the declared value.
268
+
269
+ ### core?
270
+
271
+ ```ruby
272
+ capabilities.core? # => trust == "core"
273
+ ```
274
+
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.
278
+
279
+ ### intersect
280
+
281
+ ```ruby
282
+ grant = declared.intersect(RobotLab::Capabilities.ceiling)
283
+ ```
284
+
285
+ The effective grant. Per field:
286
+
287
+ | Field | Rule |
288
+ |-------|------|
289
+ | `fs_read` / `fs_write` | A requested path survives only when it **is** a ceiling root or lives beneath one. Both sides are `File.expand_path`ed before comparison, so `~` and relative paths resolve first |
290
+ | `network` | `declared && ceiling` — both must allow it |
291
+ | `timeout` | The **smaller** of the two |
292
+ | `trust` | The **declared** value, unchanged |
293
+
294
+ Because the check is prefix-based on expanded paths, a ceiling of `["."]` grants
295
+ nothing outside the working directory even if a skill asks for `/etc`.
296
+
297
+ ---
298
+
299
+ ## RobotLab::ScriptTool
300
+
301
+ Factory module that turns an executable script into a `RobotLab::Tool`. All
302
+ methods are module functions.
303
+
304
+ ### ScriptTool.from_path
305
+
306
+ ```ruby
307
+ tool = RobotLab::ScriptTool.from_path(script_path, capabilities: nil, skill_dir: nil)
308
+ # => RobotLab::Tool, or nil
309
+ ```
310
+
311
+ | Name | Type | Default | Description |
312
+ |------|------|---------|-------------|
313
+ | `script_path` | `String`, `Pathname` | **required** | The script file |
314
+ | `capabilities` | `Capabilities`, `nil` | `nil` → `Capabilities.new` | The skill's declared capabilities |
315
+ | `skill_dir` | `String`, `nil` | `nil` → the script's own directory | Bundle root; always granted read access under Seatbelt |
316
+
317
+ **Returns `nil`** when the file is not executable, logging
318
+ `"ScriptTool: <basename> is not executable, skipping"` at `warn`. It never raises.
319
+
320
+ The generated tool takes a single optional `args` string parameter, which is
321
+ `Shellwords.split` and appended to `bash <script>`. Its name comes from
322
+ [`derive_name`](#scripttoolderive_name) and its description from
323
+ [`extract_description`](#scripttoolextract_description).
324
+
325
+ ### ScriptTool.executor
326
+
327
+ ```ruby
328
+ RobotLab::ScriptTool.executor # => #call, or nil (the default)
329
+ RobotLab::ScriptTool.executor = obj # any object responding to
330
+ # call(cmd, capabilities:, skill_dir:)
331
+ ```
332
+
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.
337
+
338
+ ### ScriptTool.execute
339
+
340
+ ```ruby
341
+ RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
342
+ ```
343
+
344
+ Run a command array and return its combined stdout+stderr, or an error string.
345
+ Two paths:
346
+
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.
351
+
352
+ ### ScriptTool.format_result
353
+
354
+ ```ruby
355
+ RobotLab::ScriptTool.format_result(output, status) # => String
356
+ ```
357
+
358
+ | `status` | Result |
359
+ |----------|--------|
360
+ | `nil` | `"Error (timed out):\n<output>"` |
361
+ | success | `output` verbatim |
362
+ | non-zero exit | `"Error (exit <N>):\n<output>"` |
363
+
364
+ Failures come back as **text for the LLM**, not exceptions — the model sees the
365
+ error and can adapt.
366
+
367
+ ### ScriptTool.derive_name
368
+
369
+ ```ruby
370
+ RobotLab::ScriptTool.derive_name(Pathname.new("check-deploy.sh")) # => "check_deploy"
371
+ ```
372
+
373
+ Strips the final extension, replaces every run of non-alphanumerics with `_`, and
374
+ trims leading/trailing underscores.
375
+
376
+ ### ScriptTool.extract_description
377
+
378
+ ```ruby
379
+ RobotLab::ScriptTool.extract_description(path) # => String
380
+ ```
381
+
382
+ The first non-shebang comment line in the file, with leading `#` and whitespace
383
+ removed. Falls back to `derive_name(path)` when there is no comment or the file
384
+ cannot be read.
385
+
386
+ ```bash
387
+ #!/usr/bin/env bash
388
+ # Verifies a deployment's health before promoting it. <- becomes the description
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Confinement: robot_lab-sandbox
394
+
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):
400
+
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).
412
+
413
+ Full reference lives in that gem's own docs.
414
+
415
+ ---
416
+
417
+ ## See Also
418
+
419
+ - [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
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
422
+ - [Robot: Skills](core/robot.md#skills) — template skills, the other meaning
423
+ - [Tool](core/tool.md) — `Tool.create`, which `ScriptTool` builds on
@@ -2,6 +2,12 @@
2
2
 
3
3
  Manages streaming event publishing with automatic sequencing, timestamping, and ID generation.
4
4
 
5
+ > **Not wired into the framework.** No robot, network, task, or hook ever
6
+ > constructs a `Streaming::Context` — `grep -rn "Streaming::" lib/` finds nothing
7
+ > outside `lib/robot_lab/streaming/`. The class works, but it only publishes what
8
+ > *you* hand it. For token streaming from a robot, use `on_content:` or the block
9
+ > form of `run`; see the [Streaming overview](index.md#the-real-streaming-api).
10
+
5
11
  ## Class: `RobotLab::Streaming::Context`
6
12
 
7
13
  ```ruby
@@ -157,10 +163,20 @@ Create a new context that shares the same sequence counter as this context, but
157
163
  ### generate_part_id
158
164
 
159
165
  ```ruby
160
- context.generate_part_id # => "part_run_1234_900123_a1b2c3d4"
166
+ context.generate_part_id # => "part_37ac1d49_815007_8f522876"
161
167
  ```
162
168
 
163
- Generate an OpenAI-compatible part ID (max 40 characters). Combines a truncated message ID, a timestamp suffix, and random hex.
169
+ Generate an OpenAI-compatible part ID (max 40 characters), assembled as
170
+ `"part_<msg>_<ts>_<rand>"`:
171
+
172
+ | Segment | Source | Length |
173
+ |---------|--------|--------|
174
+ | `<msg>` | First 8 characters of **`message_id`** (not `run_id`) | 8 |
175
+ | `<ts>` | Last 6 digits of the millisecond Unix timestamp | 6 |
176
+ | `<rand>` | `SecureRandom.hex(4)` | 8 hex chars |
177
+
178
+ Note this reads `message_id`, so two contexts sharing a message produce IDs with
179
+ the same first segment.
164
180
 
165
181
  ### generate_step_id
166
182
 
@@ -168,7 +184,11 @@ Generate an OpenAI-compatible part ID (max 40 characters). Combines a truncated
168
184
  context.generate_step_id("text_output") # => "publish-3:text_output"
169
185
  ```
170
186
 
171
- Generate a step ID for durable execution compatibility. Uses the current sequence number.
187
+ Generate a step ID for durable execution compatibility, formatted as
188
+ `"publish-<n>:<base_name>"`. It reads the counter with `current`, which does
189
+ **not** increment: `<n>` is the sequence number of the most recently published
190
+ event (`0` before anything has been published), so calling it repeatedly between
191
+ publishes yields the same ID.
172
192
 
173
193
  **Parameters:**
174
194
 
@@ -206,10 +226,13 @@ context.publish_event(event: "text.delta", data: { delta: "world!" })
206
226
  context.publish_event(event: "run.completed", data: {})
207
227
  ```
208
228
 
209
- ### Network with Child Contexts
229
+ ### Modelling a Multi-Robot Run with Child Contexts
230
+
231
+ `RobotLab::Network` does not do any of this for you — the nesting below is
232
+ something you would write by hand around your own orchestration.
210
233
 
211
234
  ```ruby
212
- # Network-level context
235
+ # Parent context
213
236
  network_ctx = RobotLab::Streaming::Context.new(
214
237
  run_id: "net_run_1",
215
238
  message_id: "net_msg_1",
@@ -1,10 +1,50 @@
1
1
  # Streaming
2
2
 
3
- Real-time event streaming during robot and network execution.
3
+ A standalone event-publishing toolkit. **Not** the live streaming path.
4
+
5
+ ## Status: not wired in
6
+
7
+ `RobotLab::Streaming::Context`, `Streaming::Events`, and
8
+ `Streaming::SequenceCounter` exist and work, but **nothing in the framework uses
9
+ them**. `grep -rn "Streaming::" lib/` returns zero hits outside
10
+ `lib/robot_lab/streaming/` itself: no robot, network, task, or hook ever
11
+ constructs a `Streaming::Context`, and no framework code publishes any of the
12
+ events listed below. Enabling this module does not make a robot stream.
13
+
14
+ Treat this as a set of building blocks you may drive yourself — a vocabulary of
15
+ event names plus a sequencing/ID helper — if you are writing your own
16
+ event-broadcast layer (a websocket relay, a SSE endpoint, an audit feed).
17
+
18
+ ### The real streaming API
19
+
20
+ To actually receive tokens as a robot generates them, use `on_content:` and/or a
21
+ block on `run`:
22
+
23
+ ```ruby
24
+ # Constructor / RunConfig callback — fires on every run
25
+ robot = RobotLab.build(
26
+ name: "assistant",
27
+ system_prompt: "You are helpful.",
28
+ on_content: ->(chunk) { print chunk.content }
29
+ )
30
+
31
+ # Or a block passed to run
32
+ robot.run("Tell me a story") { |chunk| print chunk.content }
33
+ ```
34
+
35
+ Each callback receives a `RubyLLM::Chunk`. Use `chunk.content` — there is no
36
+ `chunk.text`. If both are supplied, both fire, with the stored `on_content`
37
+ first. `on_content` is read from the robot's own config at construction time; a
38
+ network-level `config:` does not supply it.
39
+
40
+ The config key `streaming_enabled` has zero consumers in `lib/` and does nothing.
4
41
 
5
42
  ## Overview
6
43
 
7
- The streaming system provides structured event publishing during LLM execution. Events are emitted for run lifecycle, content deltas (token streaming), tool calls, and metadata updates. The system supports nested contexts for network-level orchestration where multiple robots execute within a single run.
44
+ The module provides structured event publishing with automatic sequencing,
45
+ timestamping, and ID generation. Event names cover run lifecycle, content deltas
46
+ (token streaming), tool calls, and metadata updates, and contexts can be nested
47
+ so a network-level run and its child robot runs share one monotonic sequence.
8
48
 
9
49
  ```ruby
10
50
  publish = ->(event) {
@@ -32,25 +72,26 @@ context.publish_event(event: "text.delta", data: { delta: "Hello" })
32
72
  |-----------|-------------|
33
73
  | [Context](context.md) | Manages streaming state, sequencing, and event publishing |
34
74
  | [Events](events.md) | Event type constants and classification helpers |
35
-
36
- Also used internally:
37
-
38
- | Component | Description |
39
- |-----------|-------------|
40
- | `SequenceCounter` | Thread-safe monotonic counter for event ordering |
75
+ | `SequenceCounter` | Thread-safe monotonic counter for event ordering. Its only consumer is `Streaming::Context`, which is itself unused by the framework |
41
76
 
42
77
  ## Event Categories
43
78
 
79
+ These are the names defined in `Streaming::Events`. They are constants and
80
+ classification helpers only — no framework code emits any of them.
81
+
44
82
  | Category | Events | Description |
45
83
  |----------|--------|-------------|
46
84
  | Lifecycle | `run.started`, `run.completed`, `run.failed`, `run.interrupted` | Run-level state changes |
47
85
  | Steps | `step.started`, `step.completed`, `step.failed` | Durable execution steps |
48
86
  | Parts | `part.created`, `part.completed`, `part.failed` | Message composition parts |
49
- | Deltas | `text.delta`, `tool_call.arguments.delta`, `reasoning.delta`, `data.delta` | Token-level content streaming |
87
+ | Deltas | `text.delta`, `tool_call.arguments.delta`, `tool_call.output.delta`, `reasoning.delta`, `data.delta` | Token-level content streaming |
50
88
  | HITL | `hitl.requested`, `hitl.resolved` | Human-in-the-loop events |
51
89
  | Metadata | `usage.updated`, `metadata.updated` | Token usage and metadata |
52
90
  | Terminal | `stream.ended` | End of stream signal |
53
91
 
92
+ `DELTA_EVENTS` has five members — `tool_call.output.delta` is easy to miss.
93
+ `ALL_EVENTS` has twenty.
94
+
54
95
  ## Event Structure
55
96
 
56
97
  Each published event is a hash with the following shape:
@@ -88,10 +129,14 @@ context.publish_event(event: "text.delta", data: { delta: "world!" })
88
129
  context.publish_event(event: "run.completed", data: {})
89
130
  ```
90
131
 
91
- ### Nested Contexts for Networks
132
+ ### Nested Contexts
133
+
134
+ Contexts can be nested to model a parent run with child runs sharing one
135
+ sequence. Note that no `Network` creates these — you would build the hierarchy
136
+ yourself.
92
137
 
93
138
  ```ruby
94
- # Parent context for the network run
139
+ # Parent context for a run you are orchestrating
95
140
  network_context = RobotLab::Streaming::Context.new(
96
141
  run_id: "network_run_1",
97
142
  message_id: "msg_1",
@@ -108,3 +153,4 @@ robot_context.publish_event(event: "text.delta", data: { delta: "Response" })
108
153
 
109
154
  - [Context](context.md)
110
155
  - [Events](events.md)
156
+ - [Robot](../core/robot.md) -- `on_content:`, and the block form of `run`, which is how streaming actually works