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 +4 -4
- data/CHANGELOG.md +100 -0
- data/docs/concurrency.md +34 -0
- data/docs/durable-workflows.md +46 -0
- data/docs/loops.md +27 -0
- data/docs/mcp.md +26 -0
- data/docs/rails.md +33 -0
- data/docs/sandboxes.md +96 -39
- data/docs/skills.md +53 -3
- data/lib/nexo/agent.rb +40 -1
- data/lib/nexo/configuration.rb +8 -0
- data/lib/nexo/loops/ruby_llm.rb +30 -2
- data/lib/nexo/sandboxes/container.rb +52 -6
- data/lib/nexo/sandboxes/remote.rb +22 -1
- data/lib/nexo/skills.rb +78 -3
- data/lib/nexo/version.rb +1 -1
- data/lib/nexo/workflow.rb +145 -11
- data/sig/nexo/workflow.rbs +14 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af80f73909a7207f4414d45ae28de58bd36555e8aa186f27a7d76b93b6d6ac85
|
|
4
|
+
data.tar.gz: 91efdbb90334708bc98ddfa099bf1d74f87424fdb6a29bdb0dd83ec6a326e844
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/docs/durable-workflows.md
CHANGED
|
@@ -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
|
|
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`
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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` | ✓ |
|
|
264
|
-
| `exec -i` | ✓ |
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `--
|
|
269
|
-
| `--
|
|
270
|
-
| `--
|
|
271
|
-
| `--
|
|
272
|
-
| `--
|
|
273
|
-
| `--
|
|
274
|
-
|
|
|
275
|
-
| `-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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)
|
|
65
|
-
|
|
66
|
-
|
|
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
|
data/lib/nexo/configuration.rb
CHANGED
|
@@ -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
|
data/lib/nexo/loops/ruby_llm.rb
CHANGED
|
@@ -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
|
|
85
|
-
# on stdin, never interpolated into the argv,
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
414
|
+
return {} unless RESERVED_STATE_KEYS.any? { |k| state.key?(k) }
|
|
364
415
|
|
|
365
|
-
{state: state.except(*
|
|
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.
|
|
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.
|
|
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.
|