robot_lab 0.2.6 → 0.2.7

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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. metadata +7 -2
@@ -0,0 +1,519 @@
1
+ # Skills API
2
+
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
6
+ [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
7
+ and [Building Robots: Composable Skills](../guides/building-robots.md#composable-skills).
8
+
9
+ !!! note "Two different things are called 'skills'"
10
+ **Template skills** (`RobotLab.build(skills: [:clarifier])`) are ordinary
11
+ prompt templates whose bodies are prepended to a robot's system prompt — see
12
+ [Robot: Skills](core/robot.md#skills). **AgentSkills** (this page) are
13
+ `SKILL.md` *bundles* on disk, discovered from `~/.prompts/skills/`, matched
14
+ to a message by embedding similarity at run time, and capable of contributing
15
+ executable tools. They share the word but not the mechanism.
16
+
17
+ ```mermaid
18
+ graph LR
19
+ subgraph "Discovery"
20
+ C[AgentSkillCatalog<br/>~/.prompts/skills/]
21
+ S[AgentSkill<br/>SKILL.md]
22
+ end
23
+
24
+ subgraph "Capability grant"
25
+ D[Capabilities<br/>declared in front matter]
26
+ CE[Capabilities.ceiling<br/>from config.sandbox]
27
+ G[effective grant<br/>declared ∩ ceiling]
28
+ end
29
+
30
+ subgraph "Execution"
31
+ T[ScriptTool.from_path<br/>-> RobotLab::Tool]
32
+ SB{Sandbox.enabled?}
33
+ SE[Sandbox::Seatbelt<br/>macOS]
34
+ NU[Sandbox::Null<br/>passthrough]
35
+ P[script process]
36
+ end
37
+
38
+ R[Robot<br/>AgentSkillMatching] --> C
39
+ C --> S
40
+ S --> D
41
+ S --> T
42
+ D --> G
43
+ CE --> G
44
+ T --> SB
45
+ SB -- "off, or trust: core,<br/>or non-macOS" --> NU
46
+ SB -- "on + macOS" --> SE
47
+ G --> SE
48
+ SE --> P
49
+ NU --> P
50
+ ```
51
+
52
+ ---
53
+
54
+ ## RobotLab::AgentSkill
55
+
56
+ Immutable value object for one skill folder: a directory containing a `SKILL.md`
57
+ with `name` and `description` front matter, plus optional `scripts/`,
58
+ `references/`, and `assets/` subdirectories.
59
+
60
+ ### Constructor
61
+
62
+ ```ruby
63
+ skill = RobotLab::AgentSkill.new("~/.prompts/skills/deploy-checker/SKILL.md")
64
+ ```
65
+
66
+ | Name | Type | Description |
67
+ |------|------|-------------|
68
+ | `skill_md_path` | `String`, `Pathname` | Path to the `SKILL.md` file itself, **not** the directory |
69
+
70
+ **Raises `RobotLab::ConfigurationError`** when front matter is missing `name` or
71
+ `description` (or either is blank) — and because a file with no `---` block
72
+ parses to an empty hash, a `SKILL.md` without front matter always raises.
73
+ Malformed YAML raises `Psych::SyntaxError` instead, straight from
74
+ `YAML.safe_load`. [`AgentSkillCatalog`](#robotlabagentskillcatalog) rescues both
75
+ and skips the bundle; construct an `AgentSkill` directly and you get the
76
+ exception.
77
+
78
+ ### Attributes
79
+
80
+ | Attribute | Type | Description |
81
+ |-----------|------|-------------|
82
+ | `name` | `String` | Front-matter `name`; also the catalog lookup key (symbolized) |
83
+ | `description` | `String` | Front-matter `description`; the text matched against the user's message |
84
+ | `path` | `Pathname` | The skill **directory** (`dirname` of the `SKILL.md` path) |
85
+ | `capabilities` | `Capabilities` | Built from front matter via `Capabilities.from_front_matter` |
86
+
87
+ ### instructions
88
+
89
+ ```ruby
90
+ skill.instructions # => String
91
+ ```
92
+
93
+ The `SKILL.md` body below the front matter, stripped. This is the text
94
+ `Robot::AgentSkillMatching` prepends to the system prompt when the skill matches.
95
+ Memoized.
96
+
97
+ ### scripts
98
+
99
+ ```ruby
100
+ skill.scripts # => Array<Pathname>
101
+ ```
102
+
103
+ Every **file** directly inside the skill's `scripts/` directory, sorted. Returns
104
+ `[]` when there is no `scripts/` directory. Not recursive — subdirectories are
105
+ skipped. Memoized.
106
+
107
+ ### script_tools
108
+
109
+ ```ruby
110
+ skill.script_tools # => Array<RobotLab::Tool>
111
+ ```
112
+
113
+ One `RobotLab::Tool` per script, built with
114
+ [`ScriptTool.from_path`](#scripttoolfrom_path) and carrying this skill's
115
+ `capabilities` and directory. **Non-executable scripts are skipped** (logged at
116
+ `warn` and filtered out by `filter_map`), so this array can be shorter than
117
+ `scripts`. Memoized.
118
+
119
+ These tools are appended to `robot.local_tools` for the duration of a matched
120
+ `run` and removed again in the `ensure` block — see
121
+ [Robot Execution](../architecture/robot-execution.md#execution-overview).
122
+
123
+ ---
124
+
125
+ ## RobotLab::AgentSkillCatalog
126
+
127
+ Lazily-loaded registry of the skill folders under a root directory.
128
+
129
+ ### SKILLS_ROOT
130
+
131
+ ```ruby
132
+ RobotLab::AgentSkillCatalog::SKILLS_ROOT
133
+ # => #<Pathname:/Users/you/.prompts/skills>
134
+ ```
135
+
136
+ `~/.prompts/skills`, expanded at load time. The path the process-level singleton
137
+ scans.
138
+
139
+ ### instance / reset!
140
+
141
+ ```ruby
142
+ RobotLab::AgentSkillCatalog.instance # => the singleton, scanning SKILLS_ROOT
143
+ RobotLab::AgentSkillCatalog.reset! # => nil; next `instance` builds a fresh one
144
+ ```
145
+
146
+ `instance` memoizes. `reset!` drops the memo — it exists so tests can point the
147
+ catalog at a fixture directory by resetting and constructing an instance
148
+ explicitly with a different root.
149
+
150
+ ### Constructor
151
+
152
+ ```ruby
153
+ catalog = RobotLab::AgentSkillCatalog.new("/path/to/skills")
154
+ ```
155
+
156
+ | Name | Type | Default | Description |
157
+ |------|------|---------|-------------|
158
+ | `skills_root` | `String`, `Pathname` | `SKILLS_ROOT` | Directory to scan |
159
+
160
+ Construction does **no** I/O; the scan happens on the first `find`/`all`.
161
+
162
+ ### find
163
+
164
+ ```ruby
165
+ catalog.find(:deploy_checker) # => AgentSkill or nil
166
+ catalog.find("deploy-checker") # => AgentSkill or nil
167
+ ```
168
+
169
+ Look up by skill **name** (the front-matter `name`, symbolized) — not by
170
+ directory name, and not by file path. Returns `nil` when not found.
171
+
172
+ ### all
173
+
174
+ ```ruby
175
+ catalog.all # => Array<AgentSkill>
176
+ ```
177
+
178
+ Every successfully-loaded skill.
179
+
180
+ !!! note "Loading is lazy, thread-safe, and forgiving"
181
+ The scan runs once, under a `Mutex`, on the first `find` or `all`. A missing
182
+ root directory is not an error — the catalog is simply empty. A directory
183
+ without a `SKILL.md` is skipped silently; a `SKILL.md` that raises
184
+ `ConfigurationError` or `Psych::SyntaxError` is skipped with a `warn`
185
+ (`"AgentSkillCatalog: <message>, skipping <dir>"`). One bad bundle never
186
+ prevents the others from loading, and the scan is never retried.
187
+
188
+ ---
189
+
190
+ ## RobotLab::Capabilities
191
+
192
+ What a skill's scripts may read, write, reach, and how long they may run.
193
+
194
+ A skill declares what it **wants** in `SKILL.md` front matter; the global
195
+ `sandbox:` config declares the **ceiling**. The effective grant is the
196
+ [intersection](#intersect) of the two.
197
+
198
+ ### Constants
199
+
200
+ | Constant | Value | Description |
201
+ |----------|-------|-------------|
202
+ | `DEFAULT_TIMEOUT` | `60` | Seconds, used when `timeout` is absent or non-positive |
203
+ | `TRUST_LEVELS` | `["core", "external"]` | Any other value falls back to `"external"` |
204
+
205
+ ### Constructor
206
+
207
+ ```ruby
208
+ RobotLab::Capabilities.new(
209
+ fs_read: [], fs_write: [], network: false,
210
+ timeout: DEFAULT_TIMEOUT, trust: "external"
211
+ )
212
+ ```
213
+
214
+ | Name | Type | Default | Coercion |
215
+ |------|------|---------|----------|
216
+ | `fs_read` | `Array<String>` | `[]` | `Array(...)` then `to_s` on each entry |
217
+ | `fs_write` | `Array<String>` | `[]` | Same |
218
+ | `network` | `Boolean` | `false` | Any truthy value becomes `true` |
219
+ | `timeout` | `Integer` | `60` | `to_i`; anything not positive becomes `DEFAULT_TIMEOUT` |
220
+ | `trust` | `String` | `"external"` | Must be in `TRUST_LEVELS`, else `"external"` |
221
+
222
+ Every value is normalized in the constructor, so the readers `fs_read`,
223
+ `fs_write`, `network`, `timeout`, and `trust` always return well-formed values —
224
+ a malformed `SKILL.md` degrades to the safe default rather than raising.
225
+
226
+ ### from_front_matter
227
+
228
+ ```ruby
229
+ RobotLab::Capabilities.from_front_matter(front_matter_hash) # => Capabilities
230
+ ```
231
+
232
+ Build from a parsed `SKILL.md` front-matter hash, reading `fs_read`, `fs_write`,
233
+ `network`, `timeout`, and `trust`. A `nil` front matter yields an all-defaults
234
+ instance.
235
+
236
+ ### fm_value
237
+
238
+ ```ruby
239
+ RobotLab::Capabilities.fm_value(front_matter, :network, false)
240
+ ```
241
+
242
+ Look up a front-matter key tolerating **either** string or symbol keys (string
243
+ first, then symbol), returning `default` when both are `nil`. Exposed because
244
+ `from_front_matter` uses it and skill-tooling may need the same leniency.
245
+
246
+ ### ceiling
247
+
248
+ ```ruby
249
+ RobotLab::Capabilities.ceiling # => from RobotLab.config.sandbox
250
+ RobotLab::Capabilities.ceiling(custom_config)
251
+ ```
252
+
253
+ The maximum grant any skill may receive, read from the config's `sandbox:`
254
+ section. When there is no `sandbox` section at all, the ceiling is
255
+ `Capabilities.new(fs_read: ["."])` — read-only access to the working directory,
256
+ no writes, no network.
257
+
258
+ Note the ceiling never carries a `trust` — trust is a property of the skill, not
259
+ of the ceiling, and `intersect` keeps the declared value.
260
+
261
+ ### core?
262
+
263
+ ```ruby
264
+ capabilities.core? # => trust == "core"
265
+ ```
266
+
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.
270
+
271
+ ### intersect
272
+
273
+ ```ruby
274
+ grant = declared.intersect(RobotLab::Capabilities.ceiling)
275
+ ```
276
+
277
+ The effective grant. Per field:
278
+
279
+ | Field | Rule |
280
+ |-------|------|
281
+ | `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 |
282
+ | `network` | `declared && ceiling` — both must allow it |
283
+ | `timeout` | The **smaller** of the two |
284
+ | `trust` | The **declared** value, unchanged |
285
+
286
+ Because the check is prefix-based on expanded paths, a ceiling of `["."]` grants
287
+ nothing outside the working directory even if a skill asks for `/etc`.
288
+
289
+ ---
290
+
291
+ ## RobotLab::ScriptTool
292
+
293
+ Factory module that turns an executable script into a `RobotLab::Tool`. All
294
+ methods are module functions.
295
+
296
+ ### ScriptTool.from_path
297
+
298
+ ```ruby
299
+ tool = RobotLab::ScriptTool.from_path(script_path, capabilities: nil, skill_dir: nil)
300
+ # => RobotLab::Tool, or nil
301
+ ```
302
+
303
+ | Name | Type | Default | Description |
304
+ |------|------|---------|-------------|
305
+ | `script_path` | `String`, `Pathname` | **required** | The script file |
306
+ | `capabilities` | `Capabilities`, `nil` | `nil` → `Capabilities.new` | The skill's declared capabilities |
307
+ | `skill_dir` | `String`, `nil` | `nil` → the script's own directory | Bundle root; always granted read access under Seatbelt |
308
+
309
+ **Returns `nil`** when the file is not executable, logging
310
+ `"ScriptTool: <basename> is not executable, skipping"` at `warn`. It never raises.
311
+
312
+ The generated tool takes a single optional `args` string parameter, which is
313
+ `Shellwords.split` and appended to `bash <script>`. Its name comes from
314
+ [`derive_name`](#scripttoolderive_name) and its description from
315
+ [`extract_description`](#scripttoolextract_description).
316
+
317
+ ### ScriptTool.execute
318
+
319
+ ```ruby
320
+ RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
321
+ ```
322
+
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.
332
+
333
+ ### ScriptTool.run_with_timeout
334
+
335
+ ```ruby
336
+ RobotLab::ScriptTool.run_with_timeout(cmd, timeout)
337
+ # => [String, Process::Status | nil]
338
+ ```
339
+
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
+ ```
350
+
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.
354
+
355
+ ### ScriptTool.format_result
356
+
357
+ ```ruby
358
+ RobotLab::ScriptTool.format_result(output, status) # => String
359
+ ```
360
+
361
+ | `status` | Result |
362
+ |----------|--------|
363
+ | `nil` | `"Error (timed out):\n<output>"` |
364
+ | success | `output` verbatim |
365
+ | non-zero exit | `"Error (exit <N>):\n<output>"` |
366
+
367
+ Failures come back as **text for the LLM**, not exceptions — the model sees the
368
+ error and can adapt.
369
+
370
+ ### ScriptTool.derive_name
371
+
372
+ ```ruby
373
+ RobotLab::ScriptTool.derive_name(Pathname.new("check-deploy.sh")) # => "check_deploy"
374
+ ```
375
+
376
+ Strips the final extension, replaces every run of non-alphanumerics with `_`, and
377
+ trims leading/trailing underscores.
378
+
379
+ ### ScriptTool.extract_description
380
+
381
+ ```ruby
382
+ RobotLab::ScriptTool.extract_description(path) # => String
383
+ ```
384
+
385
+ The first non-shebang comment line in the file, with leading `#` and whitespace
386
+ removed. Falls back to `derive_name(path)` when there is no comment or the file
387
+ cannot be read.
388
+
389
+ ```bash
390
+ #!/usr/bin/env bash
391
+ # Verifies a deployment's health before promoting it. <- becomes the description
392
+ ```
393
+
394
+ ---
395
+
396
+ ## RobotLab::Sandbox
397
+
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 |
429
+
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
+ ```
491
+
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`.
511
+
512
+ ---
513
+
514
+ ## See Also
515
+
516
+ - [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
517
+ - [Configuration: `sandbox:` section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section)
518
+ - [Robot: Skills](core/robot.md#skills) — template skills, the other meaning
519
+ - [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