nexo_ai 0.1.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +255 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +100 -0
- data/docs/durable-workflows.md +230 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +162 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +839 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo/workflow.rbs +14 -0
- data/sig/nexo_ai.rbs +22 -1
- metadata +186 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Durable workflows — suspend · checkpoint · resume
|
|
2
|
+
A long-running or human-in-the-loop workflow can pause durably and continue later without re-running already-paid-for work.
|
|
3
|
+
|
|
4
|
+
A long-running or human-in-the-loop workflow can **pause durably** and **continue
|
|
5
|
+
later** — possibly in another process — without re-running completed,
|
|
6
|
+
already-paid-for work. Three small primitives compose over the existing run
|
|
7
|
+
persistence (no step-graph engine, no replay log, no scheduler):
|
|
8
|
+
|
|
9
|
+
- **`checkpoint(name) { … }`** runs its block **once** and stores the
|
|
10
|
+
json-serializable result under `name` in the run's `state`. On a later run/resume
|
|
11
|
+
of the *same* run, a present checkpoint returns the stored value **without**
|
|
12
|
+
re-running the block. This is the tool that makes resume cheap and side-effect-safe.
|
|
13
|
+
- **`suspend!(reason:, resume_key: nil)`** pauses the run: it marks the run
|
|
14
|
+
`"suspended"` (a **non-failure** outcome, distinct from `"failed"`) and returns it
|
|
15
|
+
to the caller — `Workflow.run` does **not** raise. Call it *outside* a checkpoint.
|
|
16
|
+
- **`Workflow.resume(run_id, input = {})`** (sync) and
|
|
17
|
+
**`Workflow.resume_later(run_id, input = {})`** (enqueued) continue a suspended
|
|
18
|
+
run, feeding `input` in as `#resume_input`.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class DocumentApproval < Nexo::Workflow
|
|
22
|
+
def call(payload)
|
|
23
|
+
document = checkpoint(:fetch) { fetch_expensive(payload[:id]) } # paid for once
|
|
24
|
+
|
|
25
|
+
# `resume_input` is {} on the first pass, so we pause; on resume the host
|
|
26
|
+
# feeds { approved: true }, so we fall through and publish.
|
|
27
|
+
suspend!(reason: "awaiting approval") unless resume_input[:approved]
|
|
28
|
+
|
|
29
|
+
checkpoint(:publish) { publish!(document) }
|
|
30
|
+
{ done: true }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
run = DocumentApproval.run(id: 42) # reaches suspend!, returns
|
|
35
|
+
run.status # => "suspended"
|
|
36
|
+
run.suspend_reason # => "awaiting approval" (AR store)
|
|
37
|
+
run.state["fetch"] # => the fetched document (checkpoint persisted)
|
|
38
|
+
|
|
39
|
+
# ...later, once a human approves — possibly in another process:
|
|
40
|
+
resumed = DocumentApproval.resume(run.id, approved: true)
|
|
41
|
+
resumed.status # => "done" (the :fetch block did NOT re-run)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A host UI lists paused runs with the `suspended` scope and inspects them with the
|
|
45
|
+
readers (Nexo ships no controllers/views — the UI is your app's job):
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Nexo::WorkflowRun.suspended # scope: all paused runs
|
|
49
|
+
run.suspended? # => true
|
|
50
|
+
run.suspend_reason # => "awaiting approval"
|
|
51
|
+
run.checkpoint_result(:fetch) # => the stored :fetch value, or nil
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For a **durable, cross-process** resume from a background job, enqueue it — the job
|
|
55
|
+
carries the run id plus the (json-safe) resume input; the payload still lives on the
|
|
56
|
+
run:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# The resume input is a positional Hash (queue: is the only keyword), so pass it
|
|
60
|
+
# as `{ approved: true }` — bare `approved: true` would bind as an unknown keyword.
|
|
61
|
+
DocumentApproval.resume_later(run.id, { approved: true }, queue: :nexo)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See [`examples/approval_workflow.rb`](../examples/approval_workflow.rb) for the full
|
|
65
|
+
offline flow (`ruby -Ilib examples/approval_workflow.rb`).
|
|
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
|
+
|
|
113
|
+
## Durable **agent** approval — `:approve` (bridge a mid-run gate to a suspend)
|
|
114
|
+
|
|
115
|
+
The example above suspends at an **explicit** `suspend!` the workflow author placed.
|
|
116
|
+
The `:approve` mode adds the durable, cross-process sibling of `:ask` for the case where a
|
|
117
|
+
`run_agent`-driven agent hits a permission gate **mid-loop** and you want that to
|
|
118
|
+
**pause the run for a human**, not run unchecked and not block a worker. Declare the
|
|
119
|
+
agent under the `:approve` mode:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Scribe < Nexo::Agent
|
|
123
|
+
model ENV.fetch("NEXO_MODEL")
|
|
124
|
+
sandbox :local
|
|
125
|
+
permissions :approve # every gated capability needs a human decision
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class ApprovedWrite < Nexo::Workflow
|
|
129
|
+
sandbox :local
|
|
130
|
+
agent Scribe
|
|
131
|
+
def call(_p) = { content: run_agent("Write 'hi' to notes.txt").content }
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The loop is: **`:approve` gate with no decision → `Nexo::ApprovalRequired` → `run_agent`
|
|
136
|
+
suspends → host renders the pending call → `resume(approved:)` threads the decision back
|
|
137
|
+
through the gate.**
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
run = ApprovedWrite.run # agent reaches the write gate, run suspends
|
|
141
|
+
run.status # => "suspended"
|
|
142
|
+
run.state["__suspend__"]["reason"] # => "approval: notes.txt"
|
|
143
|
+
run.state["__approval__"] # => { "capability" => "write",
|
|
144
|
+
# "tool" => "notes.txt", "args" => nil }
|
|
145
|
+
# "args" carries the tool call arguments only for an MCP-tool approval; a sandbox
|
|
146
|
+
# capability gate (write/shell/fetch/search) records "args" => nil — the pending
|
|
147
|
+
# call is identified by "capability" + "tool".
|
|
148
|
+
|
|
149
|
+
# ...a human approves — possibly in another process (resume_later for the AR store):
|
|
150
|
+
resumed = ApprovedWrite.resume(run.id, approved: true)
|
|
151
|
+
resumed.status # => "done" (the gate allowed the write)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **`Nexo::ApprovalRequired`** is a signal, **distinct from `Permissions::Denied`**:
|
|
155
|
+
`Denied` means "no, adapt" (tools rescue it into `{error:}`); `ApprovalRequired` means
|
|
156
|
+
"pause and ask a human", so tools must **not** rescue it — it propagates out of the
|
|
157
|
+
tool loop and out of `Agent#prompt`, where `run_agent` catches it.
|
|
158
|
+
- **Undecided ⇒ suspend, `approved: false` ⇒ deny.** The default stays safe: an
|
|
159
|
+
unresolved approval never silently allows, and a denial on resume makes the tool return
|
|
160
|
+
`{error:}` (the model adapts) — the run still finishes `"done"`, **without** the gated
|
|
161
|
+
effect, never `"failed"`.
|
|
162
|
+
- **Scope which actions need approval** with the same `ask_when` predicate as `:ask`
|
|
163
|
+
(aliased `approve_when:` for readability) — unset means every gated action needs a
|
|
164
|
+
decision; a falsey predicate auto-allows without one:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
Nexo::Permissions.new(mode: :approve,
|
|
168
|
+
approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") })
|
|
169
|
+
```
|
|
170
|
+
- **Synchronous `:ask` is untouched.** `:ask` (in-process `on_ask`) is still the right
|
|
171
|
+
choice with a human at the keyboard during a synchronous `run`; `:approve` is its
|
|
172
|
+
durable, cross-process sibling for `run_later`/`resume_later`.
|
|
173
|
+
|
|
174
|
+
**Caveats (read before relying on it):**
|
|
175
|
+
- **Re-entry, not replay.** On resume the agent re-drives `#call` from the top; a
|
|
176
|
+
non-idempotent tool call *before* the approval gate re-runs on resume (agent tool calls
|
|
177
|
+
generally aren't checkpointable). Put approval gates **early**, or after the expensive
|
|
178
|
+
work is already `checkpoint`ed by the workflow.
|
|
179
|
+
- **One approval per suspend cycle, global decision.** The `{approved:}` answers whichever
|
|
180
|
+
gate the re-driven agent hits first. A *second* gate after an approved first one simply
|
|
181
|
+
**suspends again** — the next resume decides it. There is no per-tool decision granularity
|
|
182
|
+
in v1.
|
|
183
|
+
- **Cross-process approval needs the ActiveRecord store + ActiveJob** (like all durable resume).
|
|
184
|
+
In-process `resume` works with the Memory store; a Memory run does not survive the process.
|
|
185
|
+
- **Branch depends on upstream `ruby_llm`.** This works because `ruby_llm`'s tool loop lets a
|
|
186
|
+
tool `execute` exception propagate out of `chat.ask` (verified, 1.16.0). If a future
|
|
187
|
+
`ruby_llm` swallows tool exceptions, tool-triggered approval would be constrained — a
|
|
188
|
+
genuine upstream dependency, stated plainly.
|
|
189
|
+
|
|
190
|
+
See [`examples/approval_agent.rb`](../examples/approval_agent.rb) for the live flow
|
|
191
|
+
(`NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb`).
|
|
192
|
+
|
|
193
|
+
The `state` column ships with fresh installs. Apps installed before this feature
|
|
194
|
+
add it with an additive migration:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
rails g nexo:state
|
|
198
|
+
rails db:migrate
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Honest resume semantics — read this before relying on resume
|
|
202
|
+
|
|
203
|
+
Resume **re-enters `#call` from the top** — Ruby has no transparent continuation
|
|
204
|
+
capture, so this is **not** replay:
|
|
205
|
+
|
|
206
|
+
- **Everything *outside* a `checkpoint` re-runs on resume.** Only checkpoint-guarded
|
|
207
|
+
work is skipped (its stored result is returned). Wrap every expensive step and
|
|
208
|
+
every side effect in a `checkpoint`; the idempotency of the non-checkpointed code
|
|
209
|
+
is **your** responsibility.
|
|
210
|
+
- **A crash *inside* a checkpoint re-runs that checkpoint** on resume (at-least-once
|
|
211
|
+
for the in-flight step) — so a checkpoint's side effect should tolerate being
|
|
212
|
+
retried.
|
|
213
|
+
- **Checkpoint values must be json-serializable** — they round-trip the store exactly
|
|
214
|
+
like `result`/`events`.
|
|
215
|
+
- **Cross-process resume needs the ActiveRecord store.** A run suspended under the
|
|
216
|
+
in-memory store resumes only *in-process* (which is what the test suite exercises);
|
|
217
|
+
a run that must survive the process (and be resumed by a controller/job elsewhere)
|
|
218
|
+
needs the AR store with a shared database.
|
|
219
|
+
- **Never `suspend!` inside a `checkpoint` block** (undefined — unsupported), and
|
|
220
|
+
never name a checkpoint `"__suspend__"` (reserved for the suspend metadata) or
|
|
221
|
+
`"__approval__"` (reserved for the pending approval call — see the durable-approval
|
|
222
|
+
section above) — both are keys Nexo stores in `state`.
|
|
223
|
+
|
|
224
|
+
There is **no distinct `"resumed"` status**: resume re-enters `execute`, so a host
|
|
225
|
+
sees the existing `suspended` → `running` → `done` (or `suspended` again)
|
|
226
|
+
transitions over the usual `nexo.workflow.status` notifications. The boot
|
|
227
|
+
`reconcile_interrupted!` sweep leaves `"suspended"` runs **untouched** — an
|
|
228
|
+
intentional pause is never mistaken for an orphaned `"running"` run.
|
|
229
|
+
|
|
230
|
+
← Back to the [README](../README.md)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
Install Nexo, configure the harness, and build your first agent.
|
|
3
|
+
|
|
4
|
+
## Installation
|
|
5
|
+
|
|
6
|
+
Add to your Gemfile:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
gem "nexo_ai"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or install directly:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
gem install nexo_ai
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
In a Rails app, run the install generator to create the conventional layout and an initializer:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
rails g nexo:install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
create app/agents/.keep
|
|
26
|
+
create app/workflows/.keep
|
|
27
|
+
create app/skills/.keep
|
|
28
|
+
create config/initializers/nexo.rb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Configure the harness in one place with `Nexo.configure`. Defaults are safe and
|
|
34
|
+
provider-neutral — there is intentionally no hardcoded model:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Nexo.configure do |config|
|
|
38
|
+
config.default_model = ENV["NEXO_MODEL"] # provider-neutral: no default
|
|
39
|
+
config.default_sandbox = :virtual # :virtual | :local | :docker | :apple | a Hash | a Sandbox
|
|
40
|
+
config.default_permissions = :read_only # :read_only | :auto | :ask | :approve
|
|
41
|
+
config.skills_path = "app/skills"
|
|
42
|
+
config.concurrency = :threaded # :threaded | :async (opt-in fiber offload)
|
|
43
|
+
config.max_in_flight = 8 # Nexo.concurrent fan-out bound
|
|
44
|
+
config.buffer_workflow_events = false # buffer + flush-once workflow events
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Nexo.config.default_sandbox # => :virtual
|
|
48
|
+
Nexo.config.default_permissions # => :read_only
|
|
49
|
+
Nexo.config.default_model # => nil unless set
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`require "nexo"` works in plain Ruby with no Rails loaded.
|
|
53
|
+
|
|
54
|
+
## Build an agent in five lines
|
|
55
|
+
|
|
56
|
+
Subclass `Nexo::Agent`, declare the pieces with class macros, and call `#prompt`. No
|
|
57
|
+
sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific —
|
|
58
|
+
the agent runs on any `ruby_llm`-supported model (set `NEXO_MODEL`, e.g. a local
|
|
59
|
+
`gemma3:12b` via Ollama, or a hosted model):
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
require "nexo"
|
|
63
|
+
|
|
64
|
+
class CodeReviewer < Nexo::Agent
|
|
65
|
+
model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
|
|
66
|
+
sandbox :local
|
|
67
|
+
permissions :read_only
|
|
68
|
+
|
|
69
|
+
instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Defaults are safe: an agent with no `sandbox`/`permissions` declared gets the in-memory
|
|
76
|
+
`:virtual` sandbox and `:read_only` permissions, so an untrusted model has zero host
|
|
77
|
+
access until you explicitly opt in.
|
|
78
|
+
|
|
79
|
+
## Unregistered models — local tags, self-hosted, brand-new releases
|
|
80
|
+
|
|
81
|
+
`ruby_llm` normally validates a model id against its bundled `models.json` registry
|
|
82
|
+
and infers the provider from it. A local Ollama tag (`gemma3:12b`), a self-hosted
|
|
83
|
+
build, or a model newer than the installed registry isn't listed there — so declare
|
|
84
|
+
the `provider` explicitly and set `assume_model_exists` to skip the registry lookup:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class LocalReviewer < Nexo::Agent
|
|
88
|
+
model "gemma3:12b"
|
|
89
|
+
provider :ollama # required once the registry lookup is skipped
|
|
90
|
+
assume_model_exists true # opt out of models.json validation
|
|
91
|
+
|
|
92
|
+
instructions "You are a careful code reviewer."
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both are class macros with the same reader/writer convention as `model`. `provider`
|
|
97
|
+
is passed straight through to `RubyLLM.chat`; `assume_model_exists` defaults to
|
|
98
|
+
`false` (registry validation on). Setting `assume_model_exists` **without** a
|
|
99
|
+
`provider` raises `Nexo::ConfigurationError` — `ruby_llm` can't infer a provider once
|
|
100
|
+
the lookup is skipped. See [`examples/code_reviewer.rb`](../examples/code_reviewer.rb)
|
|
101
|
+
for a runnable Ollama example.
|
|
102
|
+
|
|
103
|
+
← Back to the [README](../README.md)
|
data/docs/loops.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Loop backends — swap the engine, not the agent
|
|
2
|
+
The loop is the engine that drives one prompt to completion; swap it by constructor injection without touching the agent class.
|
|
3
|
+
|
|
4
|
+
The **loop** is the engine that drives one prompt to completion. Swapping it is constructor
|
|
5
|
+
injection (`loop:`) — the agent class never changes. Two backends ship:
|
|
6
|
+
|
|
7
|
+
| | `Loops::RubyLLM` (default) | `Loops::AgentSDK` (opt-in) |
|
|
8
|
+
| ------------------ | ---------------------------------- | ------------------------------------- |
|
|
9
|
+
| Provider neutral | ✅ any `ruby_llm` model | ❌ Anthropic-oriented |
|
|
10
|
+
| Tool source | your sandbox-backed tools | the SDK's own built-in/host tools |
|
|
11
|
+
| Turn cap | observability only (see caveat) | native `max_turns` hard cap |
|
|
12
|
+
| Execution location | your sandbox (virtual/local/remote)| the host process |
|
|
13
|
+
|
|
14
|
+
The whole point: **same agent code, swapped backends**. Both examples are model-agnostic
|
|
15
|
+
(`ENV.fetch("NEXO_MODEL")` — never a hardcoded `"claude-…"`):
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Claude fast path — AgentSDK's own loop + host tools + native max_turns
|
|
19
|
+
claude = Nexo::Agent.new(
|
|
20
|
+
model: ENV.fetch("NEXO_MODEL"),
|
|
21
|
+
sandbox: Nexo::Sandboxes::Local.new(cwd: "/srv/checkout"),
|
|
22
|
+
permissions: Nexo::Permissions.new(mode: :auto),
|
|
23
|
+
loop: Nexo::Loops::AgentSDK.new
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Any-provider path — your sandbox, your tools, human-in-the-loop
|
|
27
|
+
gpt = Nexo::Agent.new(
|
|
28
|
+
model: ENV.fetch("NEXO_MODEL"), # gpt-5.5, gemini, gemma3:12b via Ollama…
|
|
29
|
+
sandbox: Nexo::Sandboxes::Remote.new(client: my_container_client),
|
|
30
|
+
permissions: Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
|
|
31
|
+
SlackApproval.request!(capability: cap, detail: detail)
|
|
32
|
+
}),
|
|
33
|
+
loop: Nexo::Loops::RubyLLM.new
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`Loops::AgentSDK` wraps `RubyLLM::AgentSDK.query` and requires the **optional**
|
|
38
|
+
`ruby_llm-agent_sdk` gem (lazy `require`; a clear `Nexo::MissingDependencyError` if it's
|
|
39
|
+
absent). It maps Nexo's permission modes onto the SDK's own vocabulary:
|
|
40
|
+
|
|
41
|
+
| Nexo mode | AgentSDK `permission_mode` |
|
|
42
|
+
| ------------ | -------------------------- |
|
|
43
|
+
| `:read_only` | `:default` |
|
|
44
|
+
| `:auto` | `:bypass_permissions` |
|
|
45
|
+
| `:ask` | `:default` (human gating stays in Nexo's own `on_ask` path, not delegated to the SDK) |
|
|
46
|
+
| `:approve` | `:default` (durable approval stays in Nexo's own gate; any unmapped mode also falls back to `:default`) |
|
|
47
|
+
|
|
48
|
+
## The turn-cap caveat (read before running untrusted/expensive workloads)
|
|
49
|
+
|
|
50
|
+
`ruby_llm` runs the whole tool loop inside `ask`, so `Loops::RubyLLM` has **no clean public
|
|
51
|
+
hard "stop after N turns" halt** — `before_tool_call` gives turn-count *observability*, not a
|
|
52
|
+
hard stop. (Confirmed: `ruby_llm` 1.16.0's `Chat` exposes no public max-turns/max-iterations
|
|
53
|
+
setting.) Your three real options:
|
|
54
|
+
|
|
55
|
+
(a) use `Loops::AgentSDK` (native `max_turns`) for untrusted/expensive workloads;
|
|
56
|
+
(b) have a tool return `{ error: "turn limit reached, stop and summarize" }` once a turn
|
|
57
|
+
counter trips;
|
|
58
|
+
(c) check whether the installed `ruby_llm` exposes a max-iterations config (in 1.16.0 it
|
|
59
|
+
does not).
|
|
60
|
+
|
|
61
|
+
Do **not** ship `Loops::RubyLLM` for untrusted workloads claiming a hard cap that isn't
|
|
62
|
+
proven.
|
|
63
|
+
|
|
64
|
+
## Verified vs assumed
|
|
65
|
+
|
|
66
|
+
Built against **`ruby_llm` 1.16** and **`ruby_llm-test` 0.2**. The tool body method is
|
|
67
|
+
`#execute`, tools attach with `chat.with_tools(*instances)`, and instructions set with
|
|
68
|
+
`chat.with_instructions`. `Open3.capture3` has no `timeout:` keyword on the target Ruby, so
|
|
69
|
+
`Local#shell` bounds the command with `Timeout.timeout`. These may differ on other
|
|
70
|
+
`ruby_llm` versions.
|
|
71
|
+
|
|
72
|
+
`Loops::RubyLLM`'s turn-count observability uses `RubyLLM::Chat#before_tool_call` /
|
|
73
|
+
`#after_tool_result`, confirmed present on `ruby_llm` 1.16.0 and guarded with `respond_to?`
|
|
74
|
+
so a version lacking them degrades to no observability rather than crashing.
|
|
75
|
+
`Loops::AgentSDK` targets `RubyLLM::AgentSDK.query`; `ruby_llm-agent_sdk` is **not** a
|
|
76
|
+
dependency of this release, so that signature is **assumed** (per the gem's README) and
|
|
77
|
+
verified-on-install — confirm it the moment you add the gem.
|
|
78
|
+
|
|
79
|
+
## Live smoke (optional)
|
|
80
|
+
|
|
81
|
+
The core suite is fully offline and deterministic (models stubbed with `ruby_llm-test`).
|
|
82
|
+
A real end-to-end check is opt-in and env-gated — small local models like Gemma have weak
|
|
83
|
+
tool-calling, so it may be flaky and is never a gating test:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
ollama serve &
|
|
87
|
+
NEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_test.rb
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If Gemma's tool-calling proves too weak, point `NEXO_MODEL` at a stronger model — the gem
|
|
91
|
+
stays provider-neutral; only the smoke target changes.
|
|
92
|
+
|
|
93
|
+
← Back to the [README](../README.md)
|
data/docs/mcp.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# MCP data sources
|
|
2
|
+
Attach one or more MCP servers with a single `mcp` macro — every call gated by a second, fail-closed permission axis.
|
|
3
|
+
|
|
4
|
+
An **MCP server** exposes tools to a model over the [Model Context
|
|
5
|
+
Protocol](https://modelcontextprotocol.io) — Gmail, a filesystem, a fetch endpoint,
|
|
6
|
+
Drive, and so on. Nexo does not implement MCP; it composes the
|
|
7
|
+
[`ruby_llm-mcp`](https://github.com/patvice/ruby_llm-mcp) gem so you attach one or more
|
|
8
|
+
servers with a single `mcp` macro and no client wiring. Because a server is reached
|
|
9
|
+
*through the protocol* (never a vendor SDK), the behavior is identical on Anthropic, a
|
|
10
|
+
local model, or anything else `ruby_llm` supports.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "nexo"
|
|
14
|
+
|
|
15
|
+
class InboxDigest < Nexo::Agent
|
|
16
|
+
model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
|
|
17
|
+
permissions :read_only
|
|
18
|
+
mcp :gmail, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-gmail]
|
|
19
|
+
mcp :fs, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-filesystem /data]
|
|
20
|
+
mcp :fetch, transport: :sse, url: "http://localhost:8080/sse"
|
|
21
|
+
mcp_allow %w[search_threads get_thread]
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Each `mcp` line accumulates a server declaration. `name` and `transport` map onto the
|
|
26
|
+
client's `name:`/`transport_type:`; every other keyword is passed through verbatim as the
|
|
27
|
+
server's `config:` — `command:`/`args:` for `:stdio`, `url:` for `:sse`. The server's tools
|
|
28
|
+
are attached to the chat after the sandbox tools and skills, and fire the same
|
|
29
|
+
`before_tool_call`/`after_tool_result` observability callbacks, so MCP calls appear in a
|
|
30
|
+
run's event log automatically.
|
|
31
|
+
|
|
32
|
+
## Every MCP tool call is gated — and fails closed
|
|
33
|
+
|
|
34
|
+
MCP tools obey a **second permission axis**, separate from the sandbox capability axis,
|
|
35
|
+
because an MCP tool executes inside the server, outside the sandbox. `mcp_allow` is the
|
|
36
|
+
exact-match allow-list threaded into the agent's permissions:
|
|
37
|
+
|
|
38
|
+
| Mode | MCP tool behavior |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `:read_only` (default) | allow **only** tool names listed in `mcp_allow`; everything else is denied |
|
|
41
|
+
| `:ask` | call `on_ask.call(:mcp, {tool:, args:})`; a truthy return allows, else deny |
|
|
42
|
+
| `:approve` | names in `mcp_allow` are pre-approved; any other tool needs a human decision — undecided **suspends the run** (`Nexo::ApprovalRequired`), `approved: true` allows, `approved: false` denies (the durable sibling of `:ask`) |
|
|
43
|
+
| `:auto` | allow every MCP tool |
|
|
44
|
+
|
|
45
|
+
`mcp_allow` defaults to `[]`, so attaching a powerful server under `:read_only` with no
|
|
46
|
+
allow-list denies **every** tool — a misconfigured agent fails closed, not open. A denied
|
|
47
|
+
call returns `{ error: … }` to the model (recoverable) and never raises into the loop —
|
|
48
|
+
identical to the sandbox tools. Escalation (`:auto`, a populated `mcp_allow`, or `:ask`
|
|
49
|
+
with a real `on_ask`) is always explicit in your code. Matching is exact tool-name only —
|
|
50
|
+
no globs or regexes.
|
|
51
|
+
|
|
52
|
+
**Two caveats — read before attaching a server:**
|
|
53
|
+
|
|
54
|
+
1. **MCP tool *effects* are not sandboxed.** The gate covers the authority to *invoke* a
|
|
55
|
+
tool; the tool then runs in the MCP server, outside Nexo's sandbox. Nexo cannot
|
|
56
|
+
constrain what that server does with a call it is authorized to make — attaching a write
|
|
57
|
+
server and allowing a write tool means real writes happen. Gate deliberately, and prefer
|
|
58
|
+
`:read_only` with a tight `mcp_allow`.
|
|
59
|
+
2. **Connection lifecycle.** Clients are built once and **memoized on the agent instance**,
|
|
60
|
+
reused across prompts (the `ruby_llm-mcp` client connects on construction and stays
|
|
61
|
+
connected). A long-lived agent holding stdio/SSE servers should call `Agent#close` when
|
|
62
|
+
done to tear the connections down:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
agent = InboxDigest.new
|
|
66
|
+
agent.prompt("Summarize invoices from this week")
|
|
67
|
+
agent.prompt("Any follow-ups needed?") # reuses the same live MCP connections
|
|
68
|
+
agent.close # stops every attached server
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## HTTP-family servers + an OAuth `token:` provider
|
|
72
|
+
|
|
73
|
+
Beyond `:stdio`, Nexo attaches a server over any HTTP-family transport `ruby_llm-mcp`
|
|
74
|
+
supports — `transport: :http`, `:sse`, or `:streamable`. For an OAuth-authenticated
|
|
75
|
+
hosted server (Gmail, Drive, …) add a `token:` — a static bearer `String`, or a
|
|
76
|
+
**callable** re-read close to connection time. Nexo resolves it and injects an
|
|
77
|
+
`Authorization: Bearer <token>` header per connection:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class InboxTriageHTTP < Nexo::Agent
|
|
81
|
+
model ENV.fetch("NEXO_MODEL")
|
|
82
|
+
permissions :read_only
|
|
83
|
+
|
|
84
|
+
# Hosted Gmail MCP server over HTTP; the host supplies the OAuth access token.
|
|
85
|
+
mcp :gmail,
|
|
86
|
+
transport: :http,
|
|
87
|
+
url: ENV.fetch("GMAIL_MCP_URL"),
|
|
88
|
+
token: -> { Current.user.gmail_access_token } # re-read at connection time
|
|
89
|
+
|
|
90
|
+
# READ tools only — the unchanged gate denies send/trash/modify.
|
|
91
|
+
mcp_allow %w[search_threads get_thread list_messages get_message list_labels]
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A static token (`token: ENV.fetch("GMAIL_TOKEN")`) is equally valid. Under the hood
|
|
96
|
+
Nexo strips `token:` and hands off:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
RubyLLM::MCP.client(
|
|
100
|
+
name: "gmail", transport_type: :http,
|
|
101
|
+
config: { url: "https://…", headers: { "Authorization" => "Bearer <resolved>" } }
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Any other `headers:` you pass are preserved; Nexo's `Authorization` wins. With no
|
|
106
|
+
`token:`, `config:` passes through byte-for-byte (no `headers` key) — the `:stdio` path
|
|
107
|
+
is untouched.
|
|
108
|
+
|
|
109
|
+
**Nexo does not own the OAuth flow.** It performs no authorization-code exchange, no
|
|
110
|
+
token refresh, and keeps no token store — that is your app or an OAuth library. Nexo's
|
|
111
|
+
only job is to call the provider, inject the header, and hand off. The token is never
|
|
112
|
+
logged, persisted, placed in a URL/query string, or emitted in an event (the event path
|
|
113
|
+
carries only tool `name`/`args` and return values — never connection config).
|
|
114
|
+
|
|
115
|
+
**Refresh / reconnect caveat.** `ruby_llm-mcp`'s HTTP-family transports snapshot the
|
|
116
|
+
`headers` hash **at construction** — there is no per-request header callback for a plain
|
|
117
|
+
`headers` Hash. A callable `token:` is therefore resolved **once**, when the client is
|
|
118
|
+
built, and the client is memoized on the agent instance across prompts. So when a token
|
|
119
|
+
**rotates**, tear the connection down and reconnect to pick up the new value:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
agent.close # stops the memoized MCP client
|
|
123
|
+
agent.prompt("…") # a fresh prompt rebuilds the client → token: re-resolved
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**The gate is unchanged.** An HTTP OAuth server's tools are gated exactly like `:stdio`
|
|
127
|
+
tools: denied under `:read_only` until the exact name is in `mcp_allow` (default `[]` ⇒
|
|
128
|
+
deny-all). Attaching an authenticated server adds no permission surface.
|
|
129
|
+
|
|
130
|
+
**Two honest caveats — read before attaching a token:**
|
|
131
|
+
|
|
132
|
+
1. **Refresh may require a reconnect.** Because headers are construction-only,
|
|
133
|
+
a rotated token needs `agent.close` + a fresh prompt (above), not just a new proc
|
|
134
|
+
return. A static token stays constant for the client's life.
|
|
135
|
+
2. **The token is a live credential.** Even gated, an authorized MCP call runs its effect
|
|
136
|
+
server-side — a leaked bearer is a real compromise. Nexo keeps it out of logs, events,
|
|
137
|
+
persisted `WorkflowRun` records, and URLs; your host code must do the same. Nexo does
|
|
138
|
+
not police `ruby_llm-mcp`'s own internal logging of headers — that boundary is yours.
|
|
139
|
+
|
|
140
|
+
`ruby_llm-mcp` is an **optional** dependency — required lazily only when you attach a
|
|
141
|
+
server. Without it installed, `require "nexo"` still loads; building a server raises a clear
|
|
142
|
+
`Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-mcp"`.
|
|
143
|
+
|
|
144
|
+
← Back to the [README](../README.md)
|
data/docs/permissions.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Permissions
|
|
2
|
+
The permission mode is *what* an agent's tools may do — read-only by default, with `:auto`, `:ask`, and `:approve` escalations; the separate MCP tool gate lives in [MCP](mcp.md).
|
|
3
|
+
|
|
4
|
+
Two seams compose the execution environment. The **sandbox** is *where* tools act; the
|
|
5
|
+
**permission mode** is *what* they may do. A denied capability returns `{ error: ... }`
|
|
6
|
+
and the agent loop continues — it does not raise. A path that escapes the workspace raises
|
|
7
|
+
`SecurityError`; an agent built with no resolvable model raises `Nexo::ConfigurationError`.
|
|
8
|
+
|
|
9
|
+
| | `:read` | `:glob` | `:write` | `:shell` | `:fetch` | `:search` |
|
|
10
|
+
| ------------------------ | ------- | ------- | ------------------- | --------------------------------- | ---------- | ---------- |
|
|
11
|
+
| `:read_only` (default) | ✅ | ✅ | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` |
|
|
12
|
+
| `:auto` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
13
|
+
| `:ask` | ✅ | ✅ | per `on_ask` | per `on_ask` | per `on_ask` | per `on_ask` |
|
|
14
|
+
| `:approve` | ✅ | ✅ | per `decision` | per `decision` | per `decision` | per `decision` |
|
|
15
|
+
| `Virtual` sandbox | ✅ | ✅ | ✅ (in-memory) | ❌ `NotImplementedError`→`{error}` | ✅ † | ✅ † |
|
|
16
|
+
| `Local` sandbox | ✅ (guarded) | ✅ | ✅ (guarded) | ✅ (narrowed ENV) | ✅ † | ✅ † |
|
|
17
|
+
| `Container` sandbox | ✅ (guarded) | ✅ | ✅ (guarded, scratch) | ✅ (in container) | ✅ † | ✅ † |
|
|
18
|
+
|
|
19
|
+
`:read`/`:glob` are auto-allowed under **every** mode (they sit in the default
|
|
20
|
+
`allow` list), so `:ask`/`:approve` never prompt for them — only
|
|
21
|
+
`:write`/`:shell`/`:fetch`/`:search` reach the gate. **†** `:fetch` and `:search`
|
|
22
|
+
run in the **host process** (stdlib `net/http` / a host-injected backend), so **no
|
|
23
|
+
sandbox constrains them** — not even a `--network none` container. They are bounded
|
|
24
|
+
only by the capability gate above plus `fetch_allow` / the injected backend.
|
|
25
|
+
|
|
26
|
+
## Human-gated writes (`:ask`)
|
|
27
|
+
|
|
28
|
+
`:ask` mode defers every write/shell action to your callback. Build a `Permissions` with
|
|
29
|
+
an `on_ask` hook and pass it in:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gate = Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
|
|
33
|
+
$stdout.print("Allow #{cap} #{detail}? [y/N] "); $stdin.gets.strip == "y"
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
class Editor < Nexo::Agent
|
|
37
|
+
model ENV.fetch("NEXO_MODEL")
|
|
38
|
+
sandbox :local
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Editor.new(cwd: ".", permissions: gate).prompt("Fix the typo in README.md")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
(The bare `:ask` symbol resolves to `Permissions.new(mode: :ask)` with **no** callback, so
|
|
45
|
+
writes/shell are denied — pass a pre-built `Permissions` with `on_ask` for a real gate.)
|
|
46
|
+
|
|
47
|
+
← Back to the [README](../README.md)
|