nexo_ai 0.7.0 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34123b61080f43662220abb5a92686ac3123ef5ae1255a1318f311985a08e304
4
- data.tar.gz: 46fdf315414f80c500cc7fa33794c5a576839c6b67fcbb0e282d2ad327d354fd
3
+ metadata.gz: af80f73909a7207f4414d45ae28de58bd36555e8aa186f27a7d76b93b6d6ac85
4
+ data.tar.gz: 91efdbb90334708bc98ddfa099bf1d74f87424fdb6a29bdb0dd83ec6a326e844
5
5
  SHA512:
6
- metadata.gz: 6df53f1442c2332977b40a2c3d1024699b2fc7ec9f464714e8dd0f0364dfa906c13c423af438523e7c054f93b61391c2810e44e4bab97a5a087361a82635f993
7
- data.tar.gz: 961b1ce3a8975f2127a2d0a207d93742e674ea233cb549942fe18c39b3cc4b742f61bf7b41fe6eadca815ee863cae956362a18acf9731450089e55bcfbf57204
6
+ metadata.gz: 985b074e6c59e6e5aa2353beef2fd14890efceb5f8cd20d7c3611ced5861f3fdf7a086a12b67a90ba2fbd8358c5288ac3c1e774bb3c2bb512a0868f2cc053b75
7
+ data.tar.gz: '091eff209c74596c4acb2b767182c21fd7f717416fc8a8ef9b3150e245b65d11ab185d8eb546d63dd508d36f7e81faced7c182fcd26fcbe3ad2998561e06965f'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-08-19
4
+
5
+ Sandbox tiers made honest and skill resources made usable. A skill's `scripts/` and
6
+ `assets/` live outside every sandbox, so an agent could not reach them; `Skills.materialize`
7
+ stages them in through the sandbox's own `#write`, which is the one route that works on
8
+ `:local`, `:container` and `:remote` alike. Along the way the `:apple` runtime turned out
9
+ to be unable to start a container at all, and `Container#write` was reporting success
10
+ while writing nothing. Verified end to end against Apple `container` 1.2.2 and Docker
11
+ 29.4.0.
12
+
13
+ ### Fixed
14
+
15
+ - **`Container#write` creates parent directories and raises on failure.** It ran
16
+ `sh -c 'cat > "$0"'` and discarded the exit status, so writing a nested path into a
17
+ fresh workspace failed with `Directory nonexistent` while reporting success to the
18
+ caller. `Local#write` has always done `mkdir_p` and raised; the two now match. Staging
19
+ anything with a directory in its path — a skill's `scripts/render.rb` — silently
20
+ produced nothing on a container before this.
21
+ - **The `:apple` runtime can start a container.** `#run_argv` passed
22
+ `--security-opt no-new-privileges` and `--pids-limit`, which Apple's `container` CLI
23
+ rejects with `Unknown option`, so `runtime: :apple` failed before any tool ran. Both
24
+ come from defaults, so no configuration avoided it. They are now omitted for `:apple`
25
+ and reported through `#hardening_gaps`.
26
+
27
+ ### Added
28
+
29
+ - **`Nexo::Skills.materialize(name, into:)`** — copies a skill's `scripts/`, `assets/`
30
+ and `references/` into a sandbox so an agent can actually reach them, through the
31
+ sandbox's own `#write`. Returns **sandbox-relative** paths so callers can build a
32
+ command without knowing which tier they are on. `kinds:` narrows the copy;
33
+ `overwrite: false` skips files already present, for images that bake the skill in.
34
+ - **`Container#hardening_gaps`** — hardening the caller asked for that the runtime
35
+ cannot honor, as human-readable strings; empty on `:docker`. Running with weaker
36
+ isolation than requested is now visible rather than silent. It also reports that
37
+ Apple's `--tmpfs` is not writable under `--read-only`, which makes the default
38
+ `readonly_rootfs: true` leave an `:apple` sandbox with no writable workspace.
39
+ - **`Agent#wrap_mcp_tool(tool)`** — a hook called once per MCP tool, after gating and
40
+ before attachment, for decorating every MCP tool an agent gets (capping an oversized
41
+ reply, timings, redaction). Default returns the tool unchanged; previously the only
42
+ way in was overriding the private `#apply_mcp`. Gating happens underneath, so a
43
+ wrapper cannot widen what the agent may do.
44
+ - **`Nexo.config.tool_concurrency`** — surfaces RubyLLM's concurrent execution of
45
+ multiple tool calls from one assistant turn (`:fibers` / `:threads` / `false`).
46
+ Defaults to `nil`, leaving RubyLLM's own setting alone, so behaviour is unchanged
47
+ until you opt in. Applied after every tool is attached, since each `with_tools`
48
+ resets it.
49
+ - **`Sandboxes::Remote#instructions`**, with an optional `instructions:` on the
50
+ constructor. `Local` and `Container` describe their environment to the agent;
51
+ `Remote` returned `nil`, leaving the tier most likely to surprise a tool-caller the
52
+ one it knew least about.
53
+
54
+ ### Changed
55
+
56
+ - **`max_turns` is now counted and reported** on `Loops::RubyLLM` instead of being
57
+ accepted and ignored. It still cannot halt a run — ruby_llm executes the whole tool
58
+ loop inside `Chat#ask` and its callbacks are observation-only — but exceeding the
59
+ budget now emits `:turn_limit_exceeded` with `{turns:, max_turns:}`, once per prompt.
60
+ Previously the parameter read as a safety bound it has never been.
61
+
62
+ ### Documentation
63
+
64
+ - **`docs/skills.md`: bundled files are not reachable until staged.** The docs stated
65
+ that a skill's `scripts/`/`references/` are reached through Nexo's sandbox-backed
66
+ tools. They are not — a skill lives under `skills_path`, outside every sandbox, and
67
+ nothing bridged the two. Now documents `Skills.materialize`, the relative-path rule,
68
+ the ambient-environment pitfall, and the writable-space tradeoff.
69
+ - **Apple `container` parity table filled from live runs**, replacing the previous
70
+ all-`unverified` placeholder. Records that `--network none` *does* work on Apple,
71
+ that `ps -aqf` does not exist there (Apple has `list`), and the
72
+ `--tmpfs`-under-`--read-only` divergence.
73
+ - **`docs/sandboxes.md`: path confinement on `:remote` is the client's job.** `Local`
74
+ and `Container` raise `SecurityError` on escape; `Remote` passes paths through
75
+ untouched. Deliberate, but it moves a guarantee callers may rely on.
76
+ - `docs/mcp.md` documents `wrap_mcp_tool`; `docs/concurrency.md` documents
77
+ `tool_concurrency` and that tools must be concurrency-safe before enabling it;
78
+ `docs/loops.md` documents what `max_turns` does and does not do.
79
+
80
+ ## [0.8.0] - 2026-07-16
81
+
82
+ Durability enhancements for workflows: a suspended run can now wake itself on a
83
+ timer, and independent checkpoints run concurrently, each persisting as it
84
+ completes. Both build on the existing ActiveJob and `Nexo.concurrent` seams — no
85
+ schema, run-status, or store change.
86
+
87
+ ### Added
88
+
89
+ - **Scheduled enqueue/resume.** `Workflow.run_later` and `Workflow.resume_later`
90
+ accept `wait:` (a duration) or `wait_until:` (an absolute time), forwarded to the
91
+ installed ActiveJob's own `.set(...)` scheduler — so a suspended run can wake
92
+ itself on a timer and an initial enqueue can be deferred, without Nexo adding a
93
+ scheduler. Passing both raises `ArgumentError`; with neither the enqueue is
94
+ unchanged. Status stays `"queued"`/`"suspended"` (no new status). No retry
95
+ semantics are added.
96
+ - **Parallel checkpoints.** `Workflow#checkpoint_all(name => callable, …)` runs
97
+ independent checkpoints concurrently through the existing `Nexo.concurrent`
98
+ driver, persisting **each step as it completes** so a resume after a partial
99
+ failure re-runs only the still-missing steps. Each newly-completed step emits a
100
+ `"checkpoint"`-typed event (name-only) on the existing `nexo.workflow.event`
101
+ seam. Reuses `state`/`save_state!` — no schema, run-status, or store change.
102
+
3
103
  ## [0.7.0] - 2026-07-10
4
104
 
5
105
  The harness fills out: MCP data sources, a web-content capability, durable
data/docs/concurrency.md CHANGED
@@ -41,6 +41,12 @@ most important knob for staying under provider rate limits.
41
41
  Using `Nexo.concurrent` with `async` not installed raises
42
42
  `Nexo::MissingDependencyError` with install guidance.
43
43
 
44
+ Inside a durable workflow, `Workflow#checkpoint_all` is the workflow-durability
45
+ flavored sibling of `Nexo.concurrent`: it drives this same bounded fan-out but
46
+ persists each step to the run's `state` as it lands, so a resume only re-runs what
47
+ never completed. See [Parallel checkpoints](durable-workflows.md#parallel-checkpoints--checkpoint_all)
48
+ in the durable-workflows guide.
49
+
44
50
  ## `Sandboxes::Local` offload
45
51
 
46
52
  Under a reactor, blocking file/subprocess I/O would stall every other fiber. Flip
@@ -91,4 +97,32 @@ Note that DB work under a reactor is *offloaded/pooled*, not truly fiber-async
91
97
  Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber
92
98
  scheduler), see the [`async` guide](https://socketry.github.io/async/).
93
99
 
100
+
101
+ ## Tool concurrency — several tool calls in one turn
102
+
103
+ `Nexo.config.concurrency` governs how **Nexo** runs blocking work. A separate setting
104
+ governs how **RubyLLM** executes multiple tool calls returned in a *single* assistant
105
+ turn:
106
+
107
+ ```ruby
108
+ Nexo.configure { |c| c.tool_concurrency = :fibers } # :fibers | :threads | false | nil
109
+ ```
110
+
111
+ - `:fibers` — via `async`, the same driver `Nexo.concurrent` uses.
112
+ - `:threads` — OS threads.
113
+ - `false` — one at a time.
114
+ - `nil` (**default**) — leave RubyLLM's own setting alone, so behaviour is unchanged
115
+ until you opt in.
116
+
117
+ It is applied to the chat after every tool is attached, because each `with_tools` call
118
+ resets the chat's concurrency.
119
+
120
+ This only matters when a model returns several tool calls in one turn. Tools that batch
121
+ their own work — one call that reads twenty messages rather than twenty calls — make it
122
+ largely moot, and are the better optimization where you control the tool.
123
+
124
+ **Your tools must be safe to run concurrently before you turn this on.** Anything sharing
125
+ a process-wide resource — a CLI that serializes on a keychain, a single connection, a
126
+ non-reentrant client — needs its own lock regardless of this setting.
127
+
94
128
  ← Back to the [README](../README.md)
@@ -64,6 +64,52 @@ DocumentApproval.resume_later(run.id, { approved: true }, queue: :nexo)
64
64
  See [`examples/approval_workflow.rb`](../examples/approval_workflow.rb) for the full
65
65
  offline flow (`ruby -Ilib examples/approval_workflow.rb`).
66
66
 
67
+ ## Parallel checkpoints — `checkpoint_all`
68
+
69
+ When several checkpoints are **independent** (no step depends on another's
70
+ result), run them concurrently with `checkpoint_all(name => callable, …)` instead
71
+ of a sequence of `checkpoint` calls. It fans the pending steps out through
72
+ [`Nexo.concurrent`](concurrency.md) — all in flight at once — and persists **each
73
+ step as it completes** (not the batch as a whole), so a resume after a partial
74
+ failure only re-runs the steps that never landed:
75
+
76
+ ```ruby
77
+ class BuildDashboard < Nexo::Workflow
78
+ def call(payload)
79
+ data = checkpoint_all(
80
+ account: -> { fetch_account(payload[:id]) }, # these two run
81
+ usage: -> { fetch_usage(payload[:id]) } # concurrently
82
+ )
83
+ { report: render(data[:account], data[:usage]) }
84
+ end
85
+ end
86
+ ```
87
+
88
+ `checkpoint_all` returns a Hash keyed by the **original** names you passed
89
+ (`data[:account]`), with values read back from `state` — the same shape whether a
90
+ value came from this pass or a prior one. Each newly-completed step also surfaces a
91
+ `"checkpoint"`-typed event on the run's event log and the `nexo.workflow.event`
92
+ notification (data is the step **name** only, never the value — so a dashboard can
93
+ show batch progress without the event log carrying large or sensitive results).
94
+ Steps already present in `state` are skipped silently and emit nothing.
95
+
96
+ Bound the batch by how many keys you pass — there is no separate rate knob; every
97
+ pending step goes in flight. Because it drives `Nexo.concurrent`, `checkpoint_all`
98
+ needs the `async` gem **only when something is actually pending** — an
99
+ all-persisted pass (every step already done) returns the prior values directly
100
+ without touching concurrency. The same restrictions as `checkpoint` apply: values
101
+ must be json-serializable, a step must **not** be named after a reserved state key
102
+ (`__suspend__`/`__approval__`/`__buffer_events__` — raises `Nexo::Error` before any
103
+ step runs), and do **not** call `suspend!` inside a step (undefined — unsupported).
104
+
105
+ > **⚠️ Known trade-off: per-step persistence, not an atomic batch.** `checkpoint_all`
106
+ > is **not** transactional. If step B raises after step A persisted, A stays in
107
+ > `state`, B is absent, the run goes `"failed"`, and the exception propagates through
108
+ > the workflow's normal failure path (`Nexo.concurrent`'s "first failure re-raises,
109
+ > the rest stop" — it is not rescued away). A subsequent `execute` of the **same** run
110
+ > re-submits only the still-missing names — A is skipped, B re-runs. Do **not** treat
111
+ > a batch as all-or-nothing.
112
+
67
113
  ## Durable **agent** approval — `:approve` (bridge a mid-run gate to a suspend)
68
114
 
69
115
  The example above suspends at an **explicit** `suspend!` the workflow author placed.
data/docs/loops.md CHANGED
@@ -90,4 +90,31 @@ NEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_tes
90
90
  If Gemma's tool-calling proves too weak, point `NEXO_MODEL` at a stronger model — the gem
91
91
  stays provider-neutral; only the smoke target changes.
92
92
 
93
+
94
+ ## `max_turns` is a budget, not a hard stop
95
+
96
+ `Agent#prompt`, `Session#prompt` and `Workflow#run_agent` all take `max_turns:`. On the
97
+ default `Loops::RubyLLM` it **cannot halt a run**: ruby_llm executes the entire tool loop
98
+ inside `Chat#ask`, and the callbacks Nexo wires are observation-only.
99
+
100
+ What it does do is count tool calls and tell you when the budget is passed:
101
+
102
+ ```ruby
103
+ agent.prompt("Triage the inbox", max_turns: 10) do |type, payload|
104
+ warn "over budget: #{payload}" if type == :turn_limit_exceeded
105
+ end
106
+ # => :turn_limit_exceeded, { turns: 11, max_turns: 10 }
107
+ ```
108
+
109
+ Emitted once per prompt, not per call.
110
+
111
+ If you need a **hard** ceiling, the options are:
112
+
113
+ - bound the work inside your own tools (a read tool that accepts at most N ids);
114
+ - use `Loops::AgentSDK`, whose engine enforces `max_turns` natively;
115
+ - stop the run yourself from the event block.
116
+
117
+ Treat `max_turns` on the default loop as telemetry. It was previously accepted and never
118
+ read, which made it look like a safety bound it has never been.
119
+
93
120
  ← Back to the [README](../README.md)
data/docs/mcp.md CHANGED
@@ -141,4 +141,30 @@ deny-all). Attaching an authenticated server adds no permission surface.
141
141
  server. Without it installed, `require "nexo"` still loads; building a server raises a clear
142
142
  `Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-mcp"`.
143
143
 
144
+
145
+ ## Decorating MCP tools
146
+
147
+ `wrap_mcp_tool` is called once per MCP tool, after it is wrapped in a permission-gating
148
+ `MCP::GatedTool` and before it is attached. Override it to decorate every MCP tool an
149
+ agent gets — a third-party server's response shape is not yours to change, and its tools
150
+ are attached by the harness rather than by you:
151
+
152
+ ```ruby
153
+ class MailAgent < Nexo::Agent
154
+ mcp :mail, transport: :stdio, command: "apple-mail-mcp"
155
+
156
+ # This server returns an entire message body with no cap; bound it.
157
+ def wrap_mcp_tool(tool)
158
+ CappedTool.new(tool: tool, max_chars: 4_000)
159
+ end
160
+ end
161
+ ```
162
+
163
+ A wrapper must keep the duck type the chat relies on — `#name`, `#description`,
164
+ `#params_schema` and `#call`. `GatedTool` delegates the rest through `method_missing`, so
165
+ wrappers compose.
166
+
167
+ Gating happens **underneath** the wrapper, so it only ever sees an already-authorized
168
+ call. Wrapping cannot widen what an agent may do.
169
+
144
170
  ← Back to the [README](../README.md)
data/docs/rails.md CHANGED
@@ -35,6 +35,39 @@ GenerateReport.run_later(account_id: 42, queue: :nexo) # per-call
35
35
  Nexo.configure { |c| c.job_queue = :nexo } # or a global default
36
36
  ```
37
37
 
38
+ ### Scheduling a future run or resume
39
+
40
+ `run_later` and `resume_later` accept `wait:` (a duration) or `wait_until:` (an
41
+ absolute time), forwarded straight to **the installed ActiveJob's own**
42
+ `.set(...)` scheduler — Nexo adds no scheduler of its own. Use them to defer an
43
+ initial enqueue ("send this digest at 9am") or to let a suspended run wake itself
44
+ on a timer, symmetrically:
45
+
46
+ ```ruby
47
+ # Defer the initial enqueue until tomorrow morning.
48
+ DailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)
49
+
50
+ # Let a suspended run wake itself up in an hour (no separate scheduled job).
51
+ MyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)
52
+ ```
53
+
54
+ The run's status is unchanged — a scheduled `run_later` is still `"queued"` (no
55
+ `"scheduled"` status is invented), and a scheduled `resume_later` leaves the run
56
+ `"suspended"` until the job fires. Passing **both** `wait:` and `wait_until:` in
57
+ one call raises `ArgumentError` (checked before any run is created or job
58
+ enqueued) — the installed ActiveJob would otherwise silently keep just one. With
59
+ neither given, the enqueue is byte-for-byte the immediate one above.
60
+
61
+ > **⚠️ `wait:`/`wait_until:`/`queue:` are scheduling options, not payload.** Like
62
+ > `queue:` already was, a bare-keyword call consumes them as options: `run_later(wait:
63
+ > 60)` schedules the job **60 seconds out** and leaves the payload `{}` — it does
64
+ > **not** store `"wait" => 60` as data. A payload that legitimately needs a key named
65
+ > `"wait"` must be passed as an explicit positional Hash: `run_later({wait: "value"})`.
66
+
67
+ This is still "no scheduler, no cron" — `wait:`/`wait_until:` schedule a **single**
68
+ future run/resume via ActiveJob; recurring schedules stay the host's (see the "no
69
+ queue and no scheduler" note below).
70
+
38
71
  Nexo ships **no queue and no scheduler** — ActiveJob uses whatever adapter your
39
72
  app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob
40
73
  / `whenever`) stays the host's. Without ActiveJob, `run_later` raises
data/docs/sandboxes.md CHANGED
@@ -138,6 +138,30 @@ Nexo ships **only** `Remote` plus this documented pattern — purpose-built
138
138
  `Sandboxes::E2B` / `Sandboxes::Daytona` classes are a possible future addition, deliberately
139
139
  left out of v1 because their vendor client APIs aren't pinned yet.
140
140
 
141
+ ### Path confinement is the client's job
142
+
143
+ `Local` and `Container` confine every `read`/`write`/`glob` to their working directory and
144
+ raise `SecurityError` on escape. **`Remote` does not.** It passes each path to the injected
145
+ client untouched, because only the provider knows what its own boundary is.
146
+
147
+ That is deliberate — Nexo re-implementing a confinement it cannot enforce would be
148
+ theatre — but it moves a guarantee you may be relying on. A shim is responsible for:
149
+
150
+ - rejecting paths that escape the session's working directory;
151
+ - deciding what `write` does with a missing parent directory (`Local` creates it,
152
+ `Container` creates it — a shim should too, or say that it does not);
153
+ - raising on failure rather than returning a non-zero status the caller may ignore.
154
+
155
+ Give the shim an `instructions:` describing the environment, too — `Remote` cannot derive
156
+ one, and an agent told nothing about where it runs will assume it is on the host:
157
+
158
+ ```ruby
159
+ Nexo::Sandboxes::Remote.new(
160
+ client: my_client,
161
+ instructions: "You run in an E2B sandbox, cwd /home/user. Ruby 3.4 is available."
162
+ )
163
+ ```
164
+
141
165
  ## Container sandbox — Docker / Apple Container
142
166
 
143
167
  `Sandboxes::Container` runs an agent's tools inside a **throwaway OCI container** via the
@@ -161,12 +185,50 @@ the host dir enters only through a `binds:` entry.
161
185
 
162
186
  `sandbox :docker` (or `runtime: :docker`) shells out to `docker`; `sandbox :apple`
163
187
  (`runtime: :apple`) shells out to Apple's `container` binary. The `run`/`exec` surface is
164
- largely shared; where the CLIs diverge (networking especially) the class branches on the
165
- runtime. **Apple `container` parity is NOT yet verified** — the flags are encoded from the
166
- reference mapping, not confirmed against a live daemon, so every Apple flag must be verified
167
- before trust (the networking flag in particular; see the parity table below). An unknown
188
+ largely shared; where the CLIs diverge the class branches on the runtime. An unknown
168
189
  runtime raises `Nexo::ConfigurationError`.
169
190
 
191
+ **Apple `container` parity is now verified** against `container` 1.2.2 on macOS. Three
192
+ flags docker accepts are rejected outright, so they are omitted for `:apple`:
193
+
194
+ | Knob | `:docker` | `:apple` |
195
+ |---|---|---|
196
+ | `--security-opt no-new-privileges` | applied | **rejected** (`Unknown option`) — omitted |
197
+ | `--pids-limit` | applied (`512`) | **rejected** (`Unknown option`) — omitted |
198
+ | `--read-only` + `--tmpfs <cwd>:rw` | scratch is writable | flags accepted, but **the scratch is NOT writable** |
199
+
200
+ Everything else — `--network` (including `none`), `--cap-drop ALL`, `--memory`,
201
+ `--cpus`, `--user`, `-v` binds, `-e` env, `-w`, `exec -i` — is honored identically.
202
+
203
+ > **`readonly_rootfs: true` is the default, and on `:apple` it leaves you with no
204
+ > writable workspace.** Apple accepts `--tmpfs /workspace:rw` but under `--read-only`
205
+ > the mount is still read-only, so every `write` fails. Pass `readonly_rootfs: false`,
206
+ > or mount a `:rw` bind, when using the `:apple` runtime.
207
+
208
+ Because omitting a hardening flag means *weaker isolation than you asked for*, the
209
+ difference is reported rather than hidden:
210
+
211
+ ```ruby
212
+ sandbox = Nexo::Sandboxes::Container.new(image: "…", runtime: :apple, network: :none)
213
+ sandbox.hardening_gaps
214
+ # => ["--security-opt no-new-privileges is not supported by apple",
215
+ # "--pids-limit is not supported by apple",
216
+ # "apple has no 'none' network; using 'default' instead"]
217
+ ```
218
+
219
+ `hardening_gaps` is empty on `:docker`. If your threat model requires one of those knobs,
220
+ check it and refuse rather than assuming every runtime honors every flag.
221
+
222
+ Two practical notes for Apple `container`:
223
+
224
+ - It resolves its **init image through the keychain** and fails in a non-interactive
225
+ shell (`errSecAuthFailed`, status `-25293`). Pinning an already-pulled tag avoids the
226
+ lookup: `container run --init-image ghcr.io/apple/containerization/vminit:<tag> …`.
227
+ - Containers are **VM-backed**, so infrastructure failures surface differently — e.g.
228
+ `no available interface strategy for network default, plugin=container-network-vmnet`
229
+ when the network service needs restarting (`container system stop && container system
230
+ start`).
231
+
170
232
  ### Hardened by default — every knob an explicit opt-out
171
233
 
172
234
  All of the following are applied to the `run` argv by default and individually invertible:
@@ -239,9 +301,9 @@ The container starts **lazily** on first tool use and its id is memoized.
239
301
  `:rw` bind (this is where staged files and artifacts land).
240
302
  - **Non-root is recommended, not forced.** The default hardening holds regardless of uid; set
241
303
  `user:` for defense-in-depth.
242
- - **Apple `container` parity is NOT yet verified** — especially networking. Every Apple flag
243
- in the parity table below is UNVERIFIED; confirm the flag/subcommand against Apple's CLI
244
- before trusting the `:apple` runtime in production.
304
+ - **Apple `container` needs `readonly_rootfs: false`.** The default read-only rootfs makes the
305
+ `--tmpfs` scratch unwritable on Apple, so an agent cannot write to its own workspace. The
306
+ parity table below records what was verified.
245
307
  - **Reconnect is Docker-only today.** `reconnect: true` combined with `runtime: :apple` raises
246
308
  `Nexo::ConfigurationError` at the point reconnect would run. Apple's `container` CLI has no
247
309
  **live-verified** exact `label=` filter, and a name-substring match is unsafe (it can attach
@@ -251,40 +313,35 @@ The container starts **lazily** on first tool use and its id is memoized.
251
313
 
252
314
  #### Apple `container` parity table (Group 0)
253
315
 
254
- This table is filled **only** from live Group 0 runs against a real Apple `container` runtime —
255
- never from assumption. Apple's runtime is macOS-only and is **not** present in CI (or in the
256
- environment this spec shipped from), so the divergences below are **UNVERIFIED** and marked as
257
- such. Until a maintainer runs Group 0 on Apple hardware and records the results here, treat the
258
- `:apple` runtime as **Docker-flag-shaped but unconfirmed** and reconnect as unsupported (it
259
- raises). Do not silently trust any Apple hardening flag until its row reads `same`.
316
+ Filled from live Group 0 runs against **Apple `container` 1.2.2 on macOS (arm64)** and
317
+ **Docker 29.4.0**, image `ubuntu:24.04`. Rows marked _unverified_ have not been run.
260
318
 
261
319
  | Subcommand / flag | Docker | Apple `container` | Divergence → action |
262
320
  |---|---|---|---|
263
- | `run -d` | ✓ | _unverified_ | verify before trust |
264
- | `exec -i` | ✓ | _unverified_ | verify before trust |
265
- | `ps -aqf` | ✓ | _unverified_ | verify before trust |
266
- | `start <id>` | ✓ | _unverified_ | verify before trust |
267
- | `rm -f <id>` | ✓ | _unverified_ | verify before trust |
268
- | `--label` / `label=` filter | ✓ (exact) | _unverified_ | **reconnect raises `ConfigurationError` until confirmed** |
269
- | `--network` | ✓ | _unverified_ | expected to diverge — verify before trust |
270
- | `--tmpfs` | ✓ | _unverified_ | verify before trust |
271
- | `--read-only` | ✓ | _unverified_ | verify before trust |
272
- | `--cap-drop` / `--cap-add` | ✓ | _unverified_ | verify before trust |
273
- | `--security-opt no-new-privileges` | ✓ | _unverified_ | verify before trust |
274
- | `--pids-limit` | ✓ | _unverified_ | verify before trust |
275
- | `-w` / `-v` / `-e` / `--user` / `--memory` / `--cpus` | ✓ | _unverified_ | verify before trust |
276
-
277
- Keep-alive (Docker, Group 0): `tail -f /dev/null` holds Alpine / Debian-slim / ruby-slim open —
278
- **to be confirmed on the maintainer's live daemon** (busybox-portable by construction; `sleep
279
- infinity` is not and was replaced for exactly this reason).
280
-
281
- > **Reduced-guarantee posture.** Where a hardening flag turns out to have no Apple equivalent,
282
- > the container stays functional but the guarantee is **reduced** — and that reduction is
283
- > documented here, never silently dropped. Until the table above is filled from a live run, a
284
- > maintainer must not assume any given `:apple` hardening flag is honored.
285
-
286
- Live container runs are exercised by `NEXO_LIVE`-gated smoke
287
- (`test/sandboxes/container_live_test.rb`); the core suite asserts argv construction with no
288
- daemon. See `examples/container_review.rb` for a runnable end-to-end example.
321
+ | `run -d` | ✓ | ✓ | same |
322
+ | `exec -i` (stdin reaches the process) | ✓ | ✓ | same — this is what `#write` uses |
323
+ | `stop` / `start <id>` | ✓ | ✓ | same |
324
+ | `rm -f <id>` | ✓ | ✓ | same |
325
+ | `ps -aqf label=` | ✓ (exact) | ✗ `Plugin 'container-ps' not found` | Apple has `list`, not `ps` → **reconnect raises `ConfigurationError`** |
326
+ | `--network` (incl. `none`) | ✓ | ✓ | same — `--network none` runs |
327
+ | `--cap-drop ALL` | ✓ | ✓ | same |
328
+ | `--read-only` | ✓ | ✓ | rootfs is read-only on both |
329
+ | `--tmpfs <cwd>:rw` | ✓ writable | ✗ **not writable under `--read-only`** | → omit `--read-only` (`readonly_rootfs: false`) or use a `:rw` bind |
330
+ | `--security-opt no-new-privileges` | ✓ | ✗ `Unknown option` | → omitted for `:apple`, reported in `#hardening_gaps` |
331
+ | `--pids-limit` | ✓ | ✗ `Unknown option` | → omitted for `:apple`, reported in `#hardening_gaps` |
332
+ | `-v host:ctr:ro` | ✓ | ✓ | same |
333
+ | `-e KEY=val` | ✓ | ✓ | same |
334
+ | `-w <dir>` | ✓ | ✓ | same |
335
+ | `--user` | ✓ | ✓ | same |
336
+ | `--memory` / `--cpus` | ✓ | ✓ | same |
337
+
338
+ Two operational notes for Apple, both hit during verification:
339
+
340
+ - It resolves its **init image via the keychain** and fails in a non-interactive shell
341
+ (`errSecAuthFailed`, `-25293`). Pin an already-pulled tag to skip the lookup:
342
+ `--init-image ghcr.io/apple/containerization/vminit:<tag>`.
343
+ - Containers are **VM-backed**, so infrastructure failures look unfamiliar — e.g.
344
+ `no available interface strategy for network default, plugin=container-network-vmnet`,
345
+ cleared by `container system stop && container system start`.
289
346
 
290
347
  ← Back to the [README](../README.md)
data/docs/skills.md CHANGED
@@ -61,8 +61,58 @@ skill that does not exist raises `Nexo::Error` naming the missing `SKILL.md` pat
61
61
 
62
62
  A skill contributes **instructions only**. A loaded skill ships no independent tools, and
63
63
  Nexo deliberately does not attach `ruby_llm-skills`' progressive-disclosure tool (which
64
- reads files outside the sandbox). The model reaches a skill's `references/`/`scripts/`
65
- files through Nexo's own permission-gated, sandbox-backed tools — so **attaching a skill
66
- never widens what an agent can do** beyond its configured sandbox/permission mode.
64
+ reads files outside the sandbox) — so **attaching a skill never widens what an agent can
65
+ do** beyond its configured sandbox/permission mode.
66
+
67
+ ## Bundled files are not reachable until you stage them
68
+
69
+ A skill's `scripts/`, `assets/` and `references/` live under `skills_path`, which is
70
+ **outside every sandbox**. Sandboxes confine file access to their own working directory
71
+ (`Local` and `Container` raise `SecurityError` on escape), so an agent cannot read or run
72
+ a skill's bundled files just because the skill is attached.
73
+
74
+ `Skills.materialize` copies them in, through the sandbox's own `#write` — the only route
75
+ that works on every tier:
76
+
77
+ ```ruby
78
+ staged = Nexo::Skills.materialize(:dashboard_designer, into: agent.sandbox)
79
+ # => { scripts: ["scripts/render_dashboard.rb"],
80
+ # assets: ["assets/dashboard-template.html"] }
81
+ ```
82
+
83
+ They are then ordinary workspace files, reached through the permission-gated `read`,
84
+ `glob` and `shell` tools like anything else in the workspace — and the returned paths are
85
+ **sandbox-relative**, so you can build a command without knowing which tier you are on:
86
+
87
+ ```ruby
88
+ agent.prompt("Run: ruby #{staged[:scripts].first} digest.json #{staged[:assets].first} out.html")
89
+ ```
90
+
91
+ `Local` writes to the filesystem, `Container` streams over `docker exec` / `container
92
+ exec`, and `Remote` hands off to the injected client. The call is identical.
93
+
94
+ | Option | Meaning |
95
+ |---|---|
96
+ | `kinds:` | which of `scripts` / `assets` / `references` to copy (default: all three) |
97
+ | `overwrite:` | `false` skips files already present — what you want against an image that bakes the skill in |
98
+
99
+ Kinds the skill ships nothing for are omitted from the result.
100
+
101
+ Two things to get right:
102
+
103
+ - **Use relative paths** (`"scripts/render.rb"`), in the write and in any command you
104
+ hand the agent. A host-absolute path is meaningless inside a container and on a remote
105
+ sandbox.
106
+ - **A bundled script must not depend on ambient environment.** It runs wherever the
107
+ sandbox is, which may have no locale (Ruby then defaults to `US-ASCII`, and reading a
108
+ UTF-8 file raises `Encoding::InvalidByteSequenceError`), a different `PATH`, or no
109
+ interpreter at all. Be explicit — `File.read(path, encoding: "UTF-8")` — and document
110
+ which interpreter your skill needs.
111
+
112
+ Staged files land in **writable** space. If the agent also holds `:shell` it can rewrite a
113
+ script before running it — where the same file, left outside the sandbox, could not be
114
+ touched at all. `materialize` overwrites by default, so re-materializing at the start of
115
+ every run bounds tampering to a single turn. If that is not enough, keep the resource out
116
+ of the sandbox and feed the agent its contents another way.
67
117
 
68
118
  ← Back to the [README](../README.md)
data/lib/nexo/agent.rb CHANGED
@@ -246,6 +246,7 @@ module Nexo
246
246
  apply_mcp(c)
247
247
  apply_fetch(c)
248
248
  apply_search(c)
249
+ apply_tool_concurrency(c)
249
250
  c
250
251
  end
251
252
 
@@ -360,11 +361,49 @@ module Nexo
360
361
 
361
362
  @mcp_clients ||= self.class.mcp.map { |cfg| Nexo::MCP.build(**cfg) }
362
363
  gated = @mcp_clients.flat_map(&:tools).map do |tool|
363
- Nexo::MCP::GatedTool.new(tool: tool, permissions: @permissions)
364
+ wrap_mcp_tool(Nexo::MCP::GatedTool.new(tool: tool, permissions: @permissions))
364
365
  end
365
366
  chat.with_tools(*gated) unless gated.empty?
366
367
  end
367
368
 
369
+ # Applies Nexo.config.tool_concurrency to the chat, LAST — every +with_tools+ call
370
+ # resets the chat's concurrency to its current value, so setting it before the
371
+ # tools are attached would be undone. +nil+ leaves RubyLLM's own setting alone.
372
+ def apply_tool_concurrency(chat)
373
+ mode = Nexo.config.tool_concurrency
374
+ return if mode.nil?
375
+ return unless chat.respond_to?(:with_tools)
376
+
377
+ chat.with_tools(concurrency: mode)
378
+ end
379
+
380
+ # Hook: called once per MCP tool, AFTER it is wrapped in a permission-gating
381
+ # MCP::GatedTool and before it is attached. Returns the tool to attach; the
382
+ # default returns it unchanged.
383
+ #
384
+ # Override to decorate every MCP tool an agent gets — capping an oversized reply,
385
+ # recording timings, redacting a field. A third-party MCP server's response shape
386
+ # is not yours to change, and its tools are attached by the harness rather than by
387
+ # you, so without this seam the only way in was to override the private
388
+ # +#apply_mcp+.
389
+ #
390
+ # class MailAgent < Nexo::Agent
391
+ # mcp :mail, transport: :stdio, command: "apple-mail-mcp"
392
+ #
393
+ # def wrap_mcp_tool(tool)
394
+ # CappedTool.new(tool: tool, max_chars: 4_000)
395
+ # end
396
+ # end
397
+ #
398
+ # A wrapper must keep the duck type the chat relies on — +#name+, +#description+,
399
+ # +#params_schema+ and +#call+. GatedTool delegates the rest through
400
+ # +method_missing+, so wrappers compose. Gating happens underneath, so a wrapper
401
+ # only ever sees an already-authorized call: wrapping cannot widen what the agent
402
+ # may do.
403
+ def wrap_mcp_tool(tool)
404
+ tool
405
+ end
406
+
368
407
  # Attaches a single Nexo::Tools::Fetch scoped to the agent's +fetch_allow+
369
408
  # hosts (Spec 9). Returns early when no host is declared, so an agent that never
370
409
  # calls +fetch_allow+ gets no fetch tool. Attached right after +apply_mcp+ so
@@ -25,6 +25,14 @@ module Nexo
25
25
  # which always runs its own reactor when called.
26
26
  attr_accessor :concurrency
27
27
 
28
+ # How RubyLLM executes MULTIPLE tool calls returned in a single assistant turn:
29
+ # +:fibers+ (via +async+, the same driver Nexo.concurrent uses), +:threads+, or
30
+ # +false+ for one at a time. +nil+ (the default) leaves RubyLLM's own setting
31
+ # alone, so existing behaviour is unchanged until you opt in.
32
+ #
33
+ # Distinct from #concurrency, which governs how Nexo itself runs blocking work.
34
+ attr_accessor :tool_concurrency
35
+
28
36
  # Max in-flight tasks for Nexo.concurrent fan-out (Spec 5), default +8+.
29
37
  # The single most important knob for staying under provider rate limits.
30
38
  attr_accessor :max_in_flight
@@ -22,10 +22,19 @@ module Nexo
22
22
  # +chat:+ lets a Nexo::Session inject the hydrated, continuing chat so the
23
23
  # loop runs over the persisted thread; left nil it builds the agent's own
24
24
  # fresh chat exactly as before — the default (no-session) path is unchanged.
25
+ # +max_turns+ is a BUDGET, not a hard stop. ruby_llm runs the whole tool loop
26
+ # inside #ask and its callbacks cannot halt it, so this loop cannot cut a run
27
+ # short. What it can do is COUNT the tool calls and say so: exceeding the budget
28
+ # emits a +:turn_limit_exceeded+ event (once) carrying the count and the limit.
29
+ #
30
+ # Previously the parameter was accepted and never read, which made it look like
31
+ # a safety bound it has never been. Treat it as telemetry: if you need a hard
32
+ # ceiling, bound the work inside your tools, or use Loops::AgentSDK whose engine
33
+ # enforces its own max_turns natively.
25
34
  def run(agent:, prompt:, max_turns: 25, chat: nil, &on_event)
26
35
  chat ||= agent.chat
27
36
 
28
- wire_observability(chat, &on_event)
37
+ wire_observability(chat, max_turns: max_turns, &on_event)
29
38
 
30
39
  response = chat.ask(prompt)
31
40
  on_event&.call(:done, response)
@@ -47,20 +56,39 @@ module Nexo
47
56
  # prompt observes with its own block rather than the first one. A fresh chat
48
57
  # (the default per-prompt path) simply wires once — byte-for-byte the prior
49
58
  # behavior.
50
- def wire_observability(chat, &on_event)
59
+ def wire_observability(chat, max_turns: nil, &on_event)
51
60
  return unless chat.respond_to?(:before_tool_call) && chat.respond_to?(:after_tool_result)
52
61
 
53
62
  chat.instance_variable_set(:@nexo_on_event, on_event)
63
+ chat.instance_variable_set(:@nexo_max_turns, max_turns)
64
+ # The count belongs to the PROMPT, not the chat: a continuing Session runs the
65
+ # loop repeatedly over one chat, and each prompt gets its own budget.
66
+ chat.instance_variable_set(:@nexo_turns, 0)
67
+ chat.instance_variable_set(:@nexo_turns_reported, false)
54
68
  return if chat.instance_variable_get(:@nexo_observed)
55
69
 
56
70
  chat.instance_variable_set(:@nexo_observed, true)
57
71
  chat.before_tool_call do |tc|
72
+ count_turn(chat)
58
73
  chat.instance_variable_get(:@nexo_on_event)&.call(:tool_call, tc)
59
74
  end
60
75
  chat.after_tool_result do |r|
61
76
  chat.instance_variable_get(:@nexo_on_event)&.call(:tool_result, r)
62
77
  end
63
78
  end
79
+
80
+ # Counts one tool call and reports the first time the budget is passed. Reported
81
+ # once per prompt, not per call, so a long run does not drown its own event log.
82
+ def count_turn(chat)
83
+ limit = chat.instance_variable_get(:@nexo_max_turns)
84
+ turns = chat.instance_variable_get(:@nexo_turns).to_i + 1
85
+ chat.instance_variable_set(:@nexo_turns, turns)
86
+ return unless limit && turns > limit
87
+ return if chat.instance_variable_get(:@nexo_turns_reported)
88
+
89
+ chat.instance_variable_set(:@nexo_turns_reported, true)
90
+ chat.instance_variable_get(:@nexo_on_event)&.call(:turn_limit_exceeded, {turns: turns, max_turns: limit})
91
+ end
64
92
  end
65
93
  end
66
94
  end
@@ -39,6 +39,23 @@ module Nexo
39
39
  # supported local runtimes in v1.
40
40
  RUNTIMES = {docker: "docker", apple: "container"}.freeze
41
41
 
42
+ # What each runtime's CLI can actually express. Verified live against
43
+ # +container+ 1.2.2 on macOS and Docker 29.4: Apple's CLI rejects
44
+ # +--security-opt+ and +--pids-limit+ with "Unknown option", which aborts
45
+ # +container run+ before the sandbox ever starts.
46
+ #
47
+ # +writable_tmpfs+ is not about the flag being accepted — Apple accepts
48
+ # +--tmpfs+ — but about what it does: combined with +--read-only+ the mount is
49
+ # NOT writable there, so a read-only rootfs leaves no usable scratch.
50
+ #
51
+ # Anything a runtime cannot honor is reported through #hardening_gaps rather
52
+ # than dropped quietly: running with weaker isolation, or with a workspace you
53
+ # cannot write to, must be visible.
54
+ CAPABILITIES = {
55
+ docker: {security_opt: true, pids_limit: true, writable_tmpfs: true},
56
+ apple: {security_opt: false, pids_limit: false, writable_tmpfs: false}
57
+ }.freeze
58
+
42
59
  # The container working directory (default +/workspace+, a container path),
43
60
  # the selected +runtime+ (+:docker+ / +:apple+), and the required +image+.
44
61
  attr_reader :cwd, :runtime, :image
@@ -81,10 +98,20 @@ module Nexo
81
98
  out[:stdout]
82
99
  end
83
100
 
84
- # Writes +content+ to +path+ (guarded) inside the container. Content travels
85
- # on stdin, never interpolated into the argv, so arbitrary bytes are safe.
101
+ # Writes +content+ to +path+ (guarded) inside the container, creating parent
102
+ # directories first. Content travels on stdin, never interpolated into the argv,
103
+ # so arbitrary bytes are safe.
104
+ #
105
+ # Matches Local#write on both counts, which it previously did not: without the
106
+ # +mkdir+ a nested path failed with "Directory nonexistent", and because the
107
+ # exit status was discarded the caller was told the write had succeeded. Raises
108
+ # +IOError+ on failure, mirroring how #read raises +Errno::ENOENT+.
86
109
  def write(path, content)
87
- exec_stdin!(content, "sh", "-c", 'cat > "$0"', guard_path(path))
110
+ full = guard_path(path)
111
+ out = exec_stdin!(content, "sh", "-c", 'mkdir -p "$(dirname "$0")" && cat > "$0"', full)
112
+ raise IOError, "container write failed: #{path}: #{out[:stderr].strip}" unless out[:status].zero?
113
+
114
+ full
88
115
  end
89
116
 
90
117
  # Returns the container paths matching the glob +pattern+ (guarded). Empty
@@ -120,6 +147,20 @@ module Nexo
120
147
  @cid = nil
121
148
  end
122
149
 
150
+ # Hardening the caller asked for that this runtime cannot express, as short
151
+ # human-readable strings. Empty on +:docker+. Callers that require a guarantee
152
+ # should check this rather than assume every runtime honors every knob.
153
+ def hardening_gaps
154
+ gaps = []
155
+ gaps << "--security-opt no-new-privileges is not supported by #{@runtime}" unless capable?(:security_opt)
156
+ gaps << "--pids-limit is not supported by #{@runtime}" if @pids_limit && !capable?(:pids_limit)
157
+ if @readonly_rootfs && !capable?(:writable_tmpfs)
158
+ gaps << "#{@runtime} --tmpfs is not writable under --read-only; " \
159
+ "set readonly_rootfs: false or add a :rw bind for a writable workspace"
160
+ end
161
+ gaps
162
+ end
163
+
123
164
  # A short, human-readable description of the execution environment for the
124
165
  # agent to inject later (consumed by the Refinements spec; until then the
125
166
  # method simply exists and is correct).
@@ -160,11 +201,11 @@ module Nexo
160
201
  argv = [@bin, "run", "-d", "--name", @name,
161
202
  "--label", "nexo.sandbox.id=#{@name}",
162
203
  "--network", @network.to_s,
163
- "--cap-drop", "ALL",
164
- "--security-opt", "no-new-privileges"]
204
+ "--cap-drop", "ALL"]
205
+ argv += ["--security-opt", "no-new-privileges"] if capable?(:security_opt)
165
206
  @cap_add.each { |cap| argv += ["--cap-add", cap.to_s] }
166
207
  argv += ["--read-only", "--tmpfs", "#{@cwd}:rw"] if @readonly_rootfs
167
- argv += ["--pids-limit", @pids_limit.to_s] unless @pids_limit.nil?
208
+ argv += ["--pids-limit", @pids_limit.to_s] if @pids_limit && capable?(:pids_limit)
168
209
  argv += ["--memory", @memory.to_s] unless @memory.nil?
169
210
  argv += ["--cpus", @cpus.to_s] unless @cpus.nil?
170
211
  argv += ["--user", @user.to_s] unless @user.nil?
@@ -177,6 +218,11 @@ module Nexo
177
218
  argv
178
219
  end
179
220
 
221
+ # Whether this runtime's CLI can express +knob+.
222
+ def capable?(knob)
223
+ CAPABILITIES.fetch(@runtime, CAPABILITIES[:docker]).fetch(knob, true)
224
+ end
225
+
180
226
  # Builds one +-v host:ctr:mode+ spec. Binds default to +:ro+; a Hash form
181
227
  # +{ to:, mode: :rw }+ makes a single bind writable.
182
228
  def bind_spec(host, dst)
@@ -25,8 +25,29 @@ module Nexo
25
25
  # default sandbox stays +:virtual+.
26
26
  class Remote < Sandbox
27
27
  # Stores any object responding to +read+/+write+/+exec+/+close+.
28
- def initialize(client:)
28
+ #
29
+ # +instructions:+ describes the remote environment for the agent's system
30
+ # prompt — working directory, available tooling, what is writable. Local and
31
+ # Container derive that themselves; a remote sandbox cannot, because only the
32
+ # shim knows where it points. Supplying it is strongly recommended: the tier
33
+ # most likely to surprise a weak tool-caller is the one it knows least about.
34
+ def initialize(client:, instructions: nil)
29
35
  @client = client
36
+ @instructions = instructions
37
+ end
38
+
39
+ # A short, plain-text description of the execution environment. Falls back to an
40
+ # honest generic statement rather than +nil+, so an agent is never left assuming
41
+ # it runs on the host.
42
+ #
43
+ # NOTE: unlike Local and Container, +Remote+ performs NO path confinement — every
44
+ # path is passed to the client untouched. Confining the agent to a working
45
+ # directory is the client's responsibility, not Nexo's.
46
+ def instructions
47
+ @instructions ||
48
+ "You run inside a remote sandbox managed by an external provider. The " \
49
+ "working directory, available tooling, and writable paths are defined by " \
50
+ "that provider."
30
51
  end
31
52
 
32
53
  # Reads +path+ via the client.
data/lib/nexo/skills.rb CHANGED
@@ -11,9 +11,15 @@ module Nexo
11
11
  # MissingDependencyError with install guidance.
12
12
  #
13
13
  # A loaded skill contributes its **instructions** (the SKILL.md body) to a chat.
14
- # It ships no independent tools — its +scripts/+ and +references/+ files are
15
- # reached by the model through Nexo's own sandbox-backed, permission-gated tools,
16
- # so attaching a skill never widens the agent's effective capabilities. See the
14
+ # It ships no independent tools, so attaching a skill never widens the agent's
15
+ # effective capabilities.
16
+ #
17
+ # Its +scripts/+, +assets/+ and +references/+ files are NOT reachable on their
18
+ # own: a skill lives under +skills_path+, every sandbox confines file access to
19
+ # its own working directory, and nothing bridges the two. To let an agent read or
20
+ # run a skill's bundled files, copy them into the sandbox first — through the
21
+ # sandbox's own +#write+, so it works on every tier. They are then reached through
22
+ # Nexo's permission-gated tools like any other workspace file. See the
17
23
  # spec's "Verified APIs" / safety resolution for why the gem's progressive
18
24
  # disclosure +SkillTool+ (which does ungated +File.read+) is deliberately not
19
25
  # attached.
@@ -49,6 +55,75 @@ module Nexo
49
55
 
50
56
  RubyLLM::Skills.load(dir)
51
57
  end
58
+
59
+ # Copies a skill's bundled files INTO a sandbox, so an agent can read or run
60
+ # them through its permission-gated tools.
61
+ #
62
+ # A skill lives under +skills_path+, outside every sandbox, and each sandbox
63
+ # confines file access to its own working directory — so +scripts/+, +assets/+
64
+ # and +references/+ are unreachable until they are staged. This is that step.
65
+ #
66
+ # It goes through the sandbox's own +#write+, which is the ONLY route that works
67
+ # on every tier: +Local+ writes to the filesystem, +Container+ streams over
68
+ # +docker exec+ / +container exec+, and +Remote+ hands off to the injected
69
+ # client. Nothing here knows or cares which one it is.
70
+ #
71
+ # Nexo::Skills.materialize(:dashboard_designer, into: agent.sandbox)
72
+ # # => { scripts: ["scripts/render_dashboard.rb"],
73
+ # # assets: ["assets/dashboard-template.html"] }
74
+ #
75
+ # Returns the SANDBOX-RELATIVE path of every file written, grouped by kind, so a
76
+ # caller can build a command without knowing where the sandbox lives. Build
77
+ # commands from these relative paths — a host-absolute path is meaningless
78
+ # inside a container and on another machine.
79
+ #
80
+ # Kinds a skill ships nothing for are omitted. +kinds:+ narrows the copy;
81
+ # +overwrite: false+ skips files already present, which is what you want against
82
+ # an image that already bakes the skill in.
83
+ #
84
+ # SECURITY: staged files land in WRITABLE space. An agent holding +:write+ and
85
+ # +:shell+ can rewrite a script before executing it — where the same file, left
86
+ # outside the sandbox, could not be touched at all. Re-materializing on every run
87
+ # bounds tampering to a single turn; if that is not enough, keep the resource out
88
+ # of the sandbox and feed the agent its contents another way.
89
+ def materialize(name, into:, kinds: %i[scripts assets references], overwrite: true)
90
+ skill = name.respond_to?(:scripts) ? name : find(name)
91
+
92
+ Array(kinds).each_with_object({}) do |kind, staged|
93
+ files = resources(skill, kind)
94
+ next if files.empty?
95
+
96
+ staged[kind] = files.map { |host_path| copy_in(host_path, kind, into, overwrite) }
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # A skill's files of one kind, as host paths. Virtual (database-backed) skills
103
+ # have no filesystem resources and answer with an empty list.
104
+ def resources(skill, kind)
105
+ return [] unless %i[scripts assets references].include?(kind.to_sym)
106
+ return [] unless skill.respond_to?(kind)
107
+
108
+ Array(skill.public_send(kind))
109
+ end
110
+
111
+ # Writes one host file into the sandbox under +<kind>/<basename>+, preserving the
112
+ # layout a skill's own prose refers to ("run scripts/render.rb").
113
+ def copy_in(host_path, kind, sandbox, overwrite)
114
+ relative = File.join(kind.to_s, File.basename(host_path))
115
+ return relative if !overwrite && present?(sandbox, relative)
116
+
117
+ sandbox.write(relative, File.binread(host_path))
118
+ relative
119
+ end
120
+
121
+ def present?(sandbox, relative)
122
+ sandbox.read(relative)
123
+ true
124
+ rescue
125
+ false
126
+ end
52
127
  end
53
128
  end
54
129
  end
data/lib/nexo/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Nexo
4
4
  # The gem version (also used as +spec.version+ in the gemspec).
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.1"
6
6
  end
data/lib/nexo/workflow.rb CHANGED
@@ -40,6 +40,13 @@ module Nexo
40
40
  # expensive/side-effectful steps so resume skips already-paid-for work — see the
41
41
  # "Durable workflows" README section for the honest resume semantics.
42
42
  class Workflow
43
+ # State keys Nexo reserves for lifecycle metadata, never a caller's data:
44
+ # +"__suspend__"+ (suspend reason/resume_key — Spec 13), +"__approval__"+
45
+ # (pending approval call — Spec 16), and +"__buffer_events__"+ (the persisted
46
+ # buffering choice — Spec 5). ::cleared_state strips these when a run reaches
47
+ # +"done"+; #checkpoint_all refuses a step named after any of them (Spec 21).
48
+ RESERVED_STATE_KEYS = %w[__suspend__ __approval__ __buffer_events__].freeze
49
+
43
50
  # Control-flow signal raised by #suspend! and caught by ::execute to pause
44
51
  # a run durably — NOT a failure. It transitions the run to +"suspended"+
45
52
  # (never +"failed"+) and returns the run to the caller rather than re-raising.
@@ -165,11 +172,30 @@ module Nexo
165
172
  # also reachable. Not resumable: a crashed or retried job re-runs +#call+ from
166
173
  # scratch (Nexo adds no +retry_on+); pair with ::reconcile_interrupted! to
167
174
  # catch runs orphaned in +"running"+.
168
- def run_later(payload = nil, queue: Nexo.config.job_queue, **kwargs)
175
+ #
176
+ # +wait:+ / +wait_until:+ (Spec 21) defer the enqueue via the installed
177
+ # ActiveJob's own +.set(...)+ scheduler — +wait:+ takes a duration
178
+ # (+wait: 1.hour+), +wait_until:+ an absolute time (+wait_until: tomorrow_9am+).
179
+ # Nexo adds no scheduler of its own; it just forwards these to +.set+. The run
180
+ # is still +"queued"+ (no +"scheduled"+ status is invented). Passing **both** in
181
+ # one call raises ArgumentError (the installed ActiveJob would silently keep one)
182
+ # — checked before any run is created. With neither given the enqueue is
183
+ # byte-for-byte the pre-Spec-21 immediate one (no +:at+ on the job).
184
+ #
185
+ # +wait:+/+wait_until:+/+queue:+ share the bare-keyword/positional ambiguity
186
+ # that +queue:+ already carried: a bare-keyword call like +run_later(wait: 60)+
187
+ # consumes +wait+ as the scheduling option (the payload stays +{}+). A payload
188
+ # that legitimately needs a key literally named +"wait"+ must be passed as an
189
+ # explicit positional Hash — +run_later({wait: "value"})+.
190
+ def run_later(payload = nil, queue: Nexo.config.job_queue, wait: nil, wait_until: nil, **kwargs)
169
191
  unless defined?(::ActiveJob)
170
192
  raise Nexo::MissingDependencyError,
171
193
  "run_later requires ActiveJob (Rails). Use `run` for synchronous execution."
172
194
  end
195
+ if wait && wait_until
196
+ raise ArgumentError,
197
+ "pass either `wait:` or `wait_until:`, not both (got both)"
198
+ end
173
199
  if payload && !kwargs.empty?
174
200
  raise ArgumentError,
175
201
  "pass the payload either as a positional Hash or as keywords, not both (got both)"
@@ -178,9 +204,7 @@ module Nexo
178
204
  run = Nexo::RunStore.default.create(workflow_class: name, payload: stringify(payload))
179
205
  run.update!(status: "queued")
180
206
  notify_status(run) # let a dashboard learn the run was enqueued
181
- job = Nexo::WorkflowJob
182
- job = job.set(queue: queue) if queue
183
- job.perform_later(run.id)
207
+ enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id)
184
208
  run
185
209
  end
186
210
 
@@ -229,15 +253,27 @@ module Nexo
229
253
  # Requires ActiveJob (Rails): without it this raises Nexo::MissingDependencyError
230
254
  # pointing at ::resume for synchronous execution. +queue:+ (default
231
255
  # Nexo.config.job_queue) routes the job exactly like ::run_later.
232
- def resume_later(run_id, input = {}, queue: Nexo.config.job_queue)
256
+ #
257
+ # +wait:+ / +wait_until:+ (Spec 21) defer the resume via the installed
258
+ # ActiveJob's own +.set(...)+ — +wait:+ a duration (+resume_later(id, input,
259
+ # wait: 1.hour)+ so a suspended run wakes itself on a timer), +wait_until:+ an
260
+ # absolute time. Nexo adds no scheduler and no retry; a crashed scheduled-resume
261
+ # job remains the host's +reconcile_interrupted!+ / +retry_on+ story. Passing
262
+ # **both** raises ArgumentError (the installed ActiveJob would silently keep one),
263
+ # checked before the job is enqueued. With neither given the enqueue is
264
+ # byte-for-byte the pre-Spec-21 immediate one. The return value is unchanged: the
265
+ # run stays +"suspended"+ until the job fires and re-enters ::resume's atomic claim.
266
+ def resume_later(run_id, input = {}, queue: Nexo.config.job_queue, wait: nil, wait_until: nil)
233
267
  unless defined?(::ActiveJob)
234
268
  raise Nexo::MissingDependencyError,
235
269
  "resume_later requires ActiveJob (Rails). Use `resume` for synchronous execution."
236
270
  end
271
+ if wait && wait_until
272
+ raise ArgumentError,
273
+ "pass either `wait:` or `wait_until:`, not both (got both)"
274
+ end
237
275
  run = Nexo::RunStore.default.find(run_id)
238
- job = Nexo::WorkflowJob
239
- job = job.set(queue: queue) if queue
240
- job.perform_later(run.id, input)
276
+ enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id, input)
241
277
  run
242
278
  end
243
279
 
@@ -341,6 +377,22 @@ module Nexo
341
377
  )
342
378
  end
343
379
 
380
+ # Builds the ActiveJob dispatch for ::run_later/::resume_later, forwarding
381
+ # only the non-nil scheduling options to the installed ActiveJob's own
382
+ # +.set(...)+ (Spec 21). The options Hash is accumulated conditionally so that
383
+ # with no +queue+/+wait+/+wait_until+ there is no +.set+ call at all — keeping
384
+ # the no-scheduling-option enqueue byte-for-byte identical to the pre-Spec-21
385
+ # path (the job carries no +:at+). +wait:+ and +wait_until:+ are never both
386
+ # present here (the callers raise ArgumentError first), so ActiveJob never has
387
+ # to silently pick between them.
388
+ def enqueue_job(job, queue: nil, wait: nil, wait_until: nil)
389
+ options = {}
390
+ options[:queue] = queue if queue
391
+ options[:wait] = wait if wait
392
+ options[:wait_until] = wait_until if wait_until
393
+ options.empty? ? job : job.set(**options)
394
+ end
395
+
344
396
  # Persists a +true+ buffering choice under the reserved "__buffer_events__"
345
397
  # state key (idempotent — written once) so ::resume can honor it. The
346
398
  # unbuffered default writes nothing, keeping the Spec 2 hot path untouched.
@@ -358,11 +410,10 @@ module Nexo
358
410
  def cleared_state(run)
359
411
  return {} unless run.respond_to?(:state)
360
412
 
361
- reserved = %w[__suspend__ __approval__ __buffer_events__]
362
413
  state = run.state || {}
363
- return {} unless reserved.any? { |k| state.key?(k) }
414
+ return {} unless RESERVED_STATE_KEYS.any? { |k| state.key?(k) }
364
415
 
365
- {state: state.except(*reserved)}
416
+ {state: state.except(*RESERVED_STATE_KEYS)}
366
417
  end
367
418
 
368
419
  def stringify(hash) = hash.transform_keys(&:to_s)
@@ -383,6 +434,11 @@ module Nexo
383
434
  @run = run
384
435
  @buffer_events = buffer_events
385
436
  @event_buffer = []
437
+ # Serializes the read-current → merge → assign → save_state! sequence across
438
+ # the concurrent fibers #checkpoint_all runs each step on (Spec 21). A plain
439
+ # Mutex is fiber-safe under the async reactor (Group 0 verified it serializes
440
+ # without stalling); #checkpoint (singular, no concurrency) does not use it.
441
+ @checkpoint_mutex = Mutex.new
386
442
  end
387
443
 
388
444
  # Subclasses implement the work here. The +payload+ is symbol-keyed; the
@@ -424,6 +480,62 @@ module Nexo
424
480
  value
425
481
  end
426
482
 
483
+ # Runs several independent checkpoints **concurrently** on the first pass and,
484
+ # crucially, persists **each step as it completes** — so a resume after a
485
+ # partial failure only re-runs the steps that never landed (Spec 21):
486
+ #
487
+ # fetched = checkpoint_all(
488
+ # account: -> { fetch_account(payload[:id]) },
489
+ # usage: -> { fetch_usage(payload[:id]) }
490
+ # )
491
+ # fetched[:account] # => the account value (this pass or a prior one)
492
+ #
493
+ # +steps+ is a Hash of +name => callable+ (each value a Proc/lambda); names may
494
+ # be symbols or strings and are stringified for storage exactly like #checkpoint
495
+ # (+name.to_s+). The returned Hash is keyed by the **original** (un-stringified)
496
+ # names with values read back from +@run.state+, so a caller gets the same shape
497
+ # whether a value came from this call or a prior pass.
498
+ #
499
+ # The pending steps (those not already in +@run.state+) run through the existing
500
+ # Nexo.concurrent driver, all in flight at once — callers bound the batch by how
501
+ # many keys they pass; there is no separate rate knob. Each step persists on its
502
+ # own through the same read-current → merge → assign → +save_state!+ sequence as
503
+ # #checkpoint, serialized across the concurrent fibers by an internal Mutex, and
504
+ # emits a +:checkpoint+ event naming the step (Spec 21 R3). Concurrency (and the
505
+ # +async+ gem) is touched **only** when something is pending — an all-persisted
506
+ # pass returns the prior-pass values directly without requiring +async+.
507
+ #
508
+ # Known trade-off: this is per-step persistence, **not** an atomic batch. If
509
+ # step B raises after step A persisted, A stays in +run.state+, B is absent, the
510
+ # run goes +"failed"+, and the exception propagates through the workflow's normal
511
+ # failure path (Nexo.concurrent's "first failure re-raises, the rest stop" — not
512
+ # rescued away). A subsequent ::execute of the SAME run re-submits only the
513
+ # still-missing names — A is skipped, B re-runs. Do NOT treat a batch as
514
+ # all-or-nothing.
515
+ #
516
+ # Same restrictions as #checkpoint: values must be json-serializable (they
517
+ # round-trip the store), a step must NOT be named after a RESERVED_STATE_KEYS
518
+ # entry (raises Nexo::Error before any step runs), and do NOT call #suspend!
519
+ # inside a step (undefined — v1 unsupported, documented not enforced).
520
+ def checkpoint_all(steps)
521
+ if (reserved = steps.keys.find { |name| RESERVED_STATE_KEYS.include?(name.to_s) })
522
+ raise Nexo::Error,
523
+ "checkpoint name #{reserved.to_s.inspect} is reserved (#{RESERVED_STATE_KEYS.join(", ")})"
524
+ end
525
+
526
+ state = @run.state || {}
527
+ pending = steps.reject { |name, _| state.key?(name.to_s) }
528
+
529
+ unless pending.empty?
530
+ Nexo.concurrent(max_in_flight: pending.size) do |c|
531
+ pending.each { |name, callable| c.add { persist_checkpoint(name, callable.call) } }
532
+ end
533
+ end
534
+
535
+ current = @run.state || {}
536
+ steps.keys.to_h { |name| [name, current[name.to_s]] }
537
+ end
538
+
427
539
  # Pauses the run durably (Spec 13): raises Suspended, which ::execute catches
428
540
  # to mark the run +"suspended"+ (a non-failure outcome) and return it to the
429
541
  # caller. Call this **outside** a checkpoint block. +reason+ is surfaced to a
@@ -615,6 +727,28 @@ module Nexo
615
727
  d.key?(:approved) ? {decision: {approved: !!d[:approved]}} : {}
616
728
  end
617
729
 
730
+ # Persists ONE completed #checkpoint_all step (Spec 21) — json_normalizes the
731
+ # raw value, then, inside +@checkpoint_mutex.synchronize+, re-reads the current
732
+ # +@run.state+, merges the single key, reassigns, and +save_state!+s it: the
733
+ # identical read-current → merge → assign → save sequence as #checkpoint, but
734
+ # serialized across the concurrent fibers so two steps landing at once can't
735
+ # clobber each other's merge. The +:checkpoint+ event is emitted **inside** the
736
+ # same synchronized block, alongside the state write, so the event-log append
737
+ # (which mutates the shared buffer / run) serializes with it too. Only reached
738
+ # for genuinely pending steps, so a skipped (already-persisted) step emits
739
+ # nothing. Returns the normalized value.
740
+ def persist_checkpoint(name, raw_value)
741
+ key = name.to_s
742
+ value = json_normalize(raw_value)
743
+ @checkpoint_mutex.synchronize do
744
+ store = @run.state || {}
745
+ @run.state = store.merge(key => value)
746
+ @run.save_state! if @run.respond_to?(:save_state!)
747
+ emit(:checkpoint, name: key)
748
+ end
749
+ value
750
+ end
751
+
618
752
  # Coerces a checkpoint value into exactly what it would become after a round
619
753
  # trip through the AR json column (string keys, symbol values → strings), so
620
754
  # the Memory store and the AR store return identical data on resume. A
@@ -0,0 +1,14 @@
1
+ # Partial RBS for the Spec 21 surface only (RBS reopening) — the scheduled
2
+ # enqueue/resume keywords on the class methods and the new instance
3
+ # #checkpoint_all. The rest of Workflow stays unsigned, matching today's
4
+ # partial RBS coverage.
5
+ module Nexo
6
+ class Workflow
7
+ RESERVED_STATE_KEYS: Array[String]
8
+
9
+ def self.run_later: (?untyped payload, ?queue: untyped, ?wait: untyped, ?wait_until: untyped, **untyped) -> untyped
10
+ def self.resume_later: (untyped run_id, ?untyped input, ?queue: untyped, ?wait: untyped, ?wait_until: untyped) -> untyped
11
+
12
+ def checkpoint_all: (Hash[untyped, untyped] steps) -> Hash[untyped, untyped]
13
+ end
14
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexo_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez
@@ -222,6 +222,7 @@ files:
222
222
  - sig/nexo/sandboxes/local.rbs
223
223
  - sig/nexo/sandboxes/virtual.rbs
224
224
  - sig/nexo/tools.rbs
225
+ - sig/nexo/workflow.rbs
225
226
  - sig/nexo_ai.rbs
226
227
  homepage: https://maquina.app
227
228
  licenses:
@@ -245,7 +246,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
245
246
  - !ruby/object:Gem::Version
246
247
  version: '0'
247
248
  requirements: []
248
- rubygems_version: 4.0.14
249
+ rubygems_version: 4.0.17
249
250
  specification_version: 4
250
251
  summary: Agent = Model + Harness. Nexo is the connective tissue linking RubyLLM to
251
252
  tools, sandboxes, skills, and runs.