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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +255 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +100 -0
  8. data/docs/durable-workflows.md +230 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +162 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +839 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo/workflow.rbs +14 -0
  92. data/sig/nexo_ai.rbs +22 -1
  93. 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)
@@ -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)