nexo_ai 0.1.0 → 0.7.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -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 +94 -0
  8. data/docs/durable-workflows.md +184 -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 +129 -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 +705 -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_ai.rbs +22 -1
  92. metadata +185 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f882444cb236ed835107b086528a0411da8e0ef5e8772816b895a074b3be3046
4
- data.tar.gz: f2bf5a3b073b40ddbdb4b76bc9e798caeaa9ead072042a6580e79010a3022ba2
3
+ metadata.gz: 34123b61080f43662220abb5a92686ac3123ef5ae1255a1318f311985a08e304
4
+ data.tar.gz: 46fdf315414f80c500cc7fa33794c5a576839c6b67fcbb0e282d2ad327d354fd
5
5
  SHA512:
6
- metadata.gz: f2c213194e4cdf220cec97d6807d060ef82187163bb2256c802c8545550817892488309df51a91a40b18c5e7411fb7c81afedcf2a297653def6056169f6a80f8
7
- data.tar.gz: b17d7f23f163402c9b0b2f3c119dc87370dc09831daa007054483fd3deda365813234d5879ab2dab126bff0c14c7a0f67f583eda0133f3064c8b55e5a78d469b
6
+ metadata.gz: 6df53f1442c2332977b40a2c3d1024699b2fc7ec9f464714e8dd0f0364dfa906c13c423af438523e7c054f93b61391c2810e44e4bab97a5a087361a82635f993
7
+ data.tar.gz: 961b1ce3a8975f2127a2d0a207d93742e674ea233cb549942fe18c39b3cc4b742f61bf7b41fe6eadca815ee863cae956362a18acf9731450089e55bcfbf57204
data/.document ADDED
@@ -0,0 +1,3 @@
1
+ README.md
2
+ docs
3
+ lib
data/CHANGELOG.md CHANGED
@@ -1,5 +1,237 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-07-10
4
+
5
+ The harness fills out: MCP data sources, a web-content capability, durable
6
+ workflows (suspend/checkpoint/resume) with an approval bridge, continuing agent
7
+ sessions, a container sandbox, Rails runtime primitives, and a documentation
8
+ restructure — followed by a security-and-lifecycle hardening pass over the whole
9
+ Sandbox + Permissions seam.
10
+
11
+ ### Added
12
+
13
+ - **MCP data sources.** `Agent.mcp` attaches Model Context Protocol servers
14
+ (stdio/SSE/HTTP) via `ruby_llm-mcp` (soft, optional dependency). Every MCP tool
15
+ call passes a second gate — `Permissions#authorize_mcp!` — scoped by the
16
+ `mcp_allow` exact-match allow-list (default `[]`, so `:read_only` fails closed).
17
+ `MCP::GatedTool` is a plain delegating wrapper; teardown is `Agent#close`.
18
+ - **MCP over HTTP with an OAuth token provider.** `MCP.build(token:)` accepts a
19
+ static string or a callable and injects `Authorization: Bearer …` into the
20
+ HTTP-family transports. A rotated token is resolved once per client build, so
21
+ rotation needs `Agent#close` + a fresh prompt (documented reconnect caveat).
22
+ - **Web-content capability (`:fetch`).** `Nexo::Tools::Fetch` (stdlib `net/http`
23
+ GET) is a two-lock capability: the `:fetch` permission (default-denied under
24
+ `:read_only`) plus a host in the subdomain-aware `fetch_allow` list. An SSRF
25
+ guard resolves the host once, pins the vetted IP, and denies loopback/private/
26
+ link-local/CGNAT/unspecified ranges. Responses are byte-capped.
27
+ - **Web search capability (`:search`).** `Nexo::Tools::WebSearch` with an
28
+ injected backend (`search_backend`), gated like `:fetch`.
29
+ - **Input staging + named artifacts.** `Workflow#stage` writes files into the
30
+ run's sandbox before work begins; `Workflow#artifact` records named outputs
31
+ (`content:` or a trusted ERB `from:` template) on the run, persisted immediately.
32
+ Both reuse the sandbox + WorkflowRun seams; a data-only workflow builds nothing.
33
+ - **Workflow → agent glue.** `Workflow#run_agent(prompt, max_turns:)` runs the
34
+ declared `agent` inside the run's shared sandbox and forwards every loop event
35
+ through `emit`.
36
+ - **Durable workflows.** `Workflow#suspend!`, `#checkpoint`, and `Workflow.resume`
37
+ add a `"suspended"` status and a JSON `state` column: a workflow can pause for
38
+ input, persist completed checkpoints, and resume (sync or via `resume_later`)
39
+ without re-running checkpointed work. `resume` atomically claims a suspended run
40
+ (no double execution); checkpoint values are JSON-normalized for Memory/AR parity.
41
+ - **Durable approval bridge.** Under `permissions :approve`, an undecided
42
+ sensitive tool call raises `ApprovalRequired`, which `run_agent` records under a
43
+ reserved `__approval__` state key and turns into a `suspend!`; `resume(approved:)`
44
+ threads the human decision back into the agent's permissions.
45
+ - **Continuing agent sessions.** `Nexo::Session` persists a chat across prompts
46
+ via ruby_llm's `acts_as_chat` (host-owned schema), with idempotent instruction
47
+ re-application on every resume and once-only observability wiring.
48
+ - **Container sandbox.** `Sandboxes::Container` runs an agent's tools inside a
49
+ throwaway, hardened OCI container via the `docker` (default) or Apple `container`
50
+ CLI — no vendor gem, argv arrays only (no shell-string interpolation). Ephemeral
51
+ by default; opt-in `reconnect: true` reattaches by an exact identity label.
52
+ - **Rails runtime primitives.** `Nexo::WorkflowJob` (ActiveJob), Turbo mirroring,
53
+ and `ActiveSupport::Notifications` (`nexo.workflow.status` / `nexo.workflow.event`)
54
+ — all Rails-optional and guarded, no-ops when their dependency is absent.
55
+ - **Unified sandbox resolver.** `Sandboxes.resolve` is the single place a sandbox
56
+ declaration (symbol / Hash / pre-built instance) becomes a concrete `Sandbox`.
57
+ - **`provider` / `assume_model_exists` agent macros** for non-registry models
58
+ (e.g. Ollama), now documented in the getting-started guide.
59
+ - **Documentation restructure.** The README is a slim index; thirteen topic guides
60
+ live under `docs/`, and `rake doc` / `rake doc:coverage` (a 100%-or-fail gate)
61
+ render and enforce RDoc coverage.
62
+
63
+ ### Security
64
+
65
+ - **MCP gate no longer fails open under `:approve`.** `authorize_mcp!` gained an
66
+ `:approve` branch and a fail-closed `else raise Denied` backstop, so no mode
67
+ falls through ungated.
68
+ - **Closed shell/command injection in `Container#glob` and `Remote#glob`.** The
69
+ model-supplied pattern is passed as a positional argument, never interpolated
70
+ into a command line — a `:read_only` agent can no longer reach command execution
71
+ through the always-allowed `:glob` capability.
72
+ - **`Local` sandbox hardening.** `glob` guards the pattern against escapes and
73
+ filters symlinked matches; `read`/`write` resolve symlinks via `realpath` so a
74
+ symlink inside `cwd` pointing outside it no longer escapes the sandbox.
75
+ - **Fetch SSRF hardened.** Resolve-once IP pinning removes the DNS-rebinding
76
+ window; the deny set adds `0.0.0.0/8`, CGNAT `100.64.0.0/10`, and the IPv6
77
+ unspecified address.
78
+
79
+ ### Fixed
80
+
81
+ - **Sandbox lifecycle — no container leak.** `Agent#close` now closes the sandbox
82
+ it owns; `Workflow.execute` releases the run's sandbox on every terminal path.
83
+ A borrowed sandbox (injected via `sandbox:`, e.g. `run_agent`) is left to its
84
+ owner, so multiple `run_agent` calls no longer tear down a shared container.
85
+ - **Workflow lifecycle correctness.** A non-`StandardError` (path-escape
86
+ `SecurityError`, shell-less `NotImplementedError`) now marks a run `"failed"`
87
+ instead of stranding it `"running"`; `resume` claims the run atomically; a
88
+ non-approval resume is no longer coerced into a silent denial; reserved
89
+ `__suspend__` / `__approval__` state is cleared on a successful resume; status
90
+ notifications fire for `queued`/`interrupted`; `resume` honors the original
91
+ `buffer_events`.
92
+ - **Ambiguous workflow payloads raise.** `run` / `run_later` reject a positional
93
+ payload combined with leftover keywords instead of silently dropping keys.
94
+ - **Agent config is inherited.** Subclassing a configured agent copies its macros
95
+ instead of reverting to defaults.
96
+ - **Session/loop fixes.** The base `Loop#run` contract includes `chat:`;
97
+ `Loops::AgentSDK` rejects a session chat with a clear error and emits the
98
+ terminal `:done` event; durable-session instruction application collapses to one
99
+ copy per resume even without an `instructions` macro; the Memory session path
100
+ adopts the agent owning the live chat so `close`/`prompt` hit live resources.
101
+ - **Miscellaneous.** Memory `RunStore` is mutex-guarded and mirrors the AR read
102
+ helpers; `engine.rb` is Zeitwerk-ignored; `Agent#close` is exception-safe per
103
+ client; `skills`/`mcp_allow`/`fetch_allow` accumulate (deduped) like `mcp`;
104
+ `ReadFile` caps its output; `WebSearch` and `stage` tolerate string- or
105
+ symbol-keyed input; dead `save!` and `turns`-counter code removed.
106
+
107
+ ## [0.6.0] - 2026-07-01
108
+
109
+ ### Added
110
+
111
+ - **Opt-in fiber concurrency (`async`).** `Nexo.concurrent(max_in_flight:) { |c| c.add { … } }`
112
+ runs many agent/workflow calls inside one `async` reactor, bounded by an
113
+ `Async::Semaphore` and coordinated by an `Async::Barrier`. Results come back in
114
+ **submission order**; the first task to raise is re-raised (errors are never
115
+ swallowed) and the remaining in-flight tasks are stopped. `max_in_flight`
116
+ (default `Nexo.config.max_in_flight`, i.e. `8`) keeps fan-out under provider
117
+ rate limits — the reason to prefer it over a hand-rolled `Async {}`.
118
+ - **`async` is a SOFT/optional dependency** — lazily required only when a
119
+ concurrency feature is used. `require "nexo"` with `async` absent does not
120
+ raise; using `Nexo.concurrent` (or the `:async` sandbox offload) without it
121
+ raises `Nexo::MissingDependencyError` with install guidance
122
+ (`gem "async", "~> 2.0"`).
123
+ - **`Nexo::Configuration` gains three settings:** `concurrency`
124
+ (`:threaded` default | `:async`), `max_in_flight` (`8`), and
125
+ `buffer_workflow_events` (`false`).
126
+ - **`Sandboxes::Local` async offload.** `read`/`write`/`glob`/`shell` route
127
+ through a private `#offload`; when `Nexo.config.concurrency == :async` the
128
+ blocking file/subprocess I/O runs on a worker thread so it doesn't stall the
129
+ reactor, otherwise it runs inline (byte-for-byte the previous behavior, zero
130
+ overhead). The path-escape `SecurityError` guard, narrowed ENV, and
131
+ `Timeout`-wrapped `Open3.capture3` are all preserved unchanged.
132
+ - **`Workflow.run(payload, buffer_events:)` buffered emit.** With
133
+ `buffer_events: true` (default `Nexo.config.buffer_workflow_events`) events are
134
+ buffered in memory and flushed to the store exactly once (in `run`'s `ensure`,
135
+ so they persist on success and failure), avoiding a blocking per-event DB write
136
+ under a reactor. The default (unbuffered) path is unchanged from 0.4.0.
137
+
138
+ ### Notes
139
+
140
+ - `Loops::RubyLLM` needs **no changes** to run inside a reactor — `ruby_llm`'s
141
+ Faraday `net/http` adapter already yields on socket I/O under Ruby's fiber
142
+ scheduler. A regression test proves a prompt driven from inside `Async {}`
143
+ returns unchanged.
144
+ - `Async::WorkerPool` is not present in the installed `async` (2.x), so the
145
+ offload primitive is `Thread.new(&block).value` (re-raises block exceptions in
146
+ the caller).
147
+
148
+ ### Changed
149
+
150
+ - Raised minimum Ruby to 3.3; `Nexo.generate_run_id` now uses
151
+ `SecureRandom.uuid_v7` unconditionally (dropped the 3.2 UUID v4 fallback).
152
+
153
+ ## [0.5.0] - 2026-06-29
154
+
155
+ ### Added
156
+
157
+ - **Pluggable loop backends.** `Nexo::Loop` is the new engine seam:
158
+ `#run(agent:, prompt:, max_turns:, &on_event)`. Swap the loop by constructor
159
+ injection (`loop:`) without changing the agent class.
160
+ - `Nexo::Loops::RubyLLM` (DEFAULT, provider-neutral) — the Spec 1 `Agent#prompt`
161
+ loop logic extracted verbatim. It runs on any `ruby_llm` model and contains no
162
+ vendor code. Wires turn-count *observability* through `RubyLLM::Chat`'s
163
+ `before_tool_call`/`after_tool_result` callbacks (guarded by `respond_to?`),
164
+ forwarding `:tool_call`/`:tool_result`/`:done` to an optional `on_event` block.
165
+ - `Nexo::Loops::AgentSDK` (opt-in, Anthropic-oriented) — wraps
166
+ `RubyLLM::AgentSDK.query` for native `max_turns`, permission modes, and the
167
+ SDK's built-in tools. `ruby_llm-agent_sdk` is a SOFT/optional dependency,
168
+ required lazily; a clear `Nexo::MissingDependencyError` is raised when it's
169
+ absent. Not a dependency of this release (its `.query` signature is
170
+ verified-on-install).
171
+ - `Nexo::Sandboxes::Remote` — run an agent's tools inside a remote container by
172
+ injecting any client satisfying the four-method contract (`read`/`write`/
173
+ `exec`/`close`). Contains zero vendor code; switching providers is swapping the
174
+ injected object. Vendor SDKs are adapted with a tiny documented shim.
175
+ - `Agent` gains a `loop:` option (default `Loops::RubyLLM.new`); `#prompt`
176
+ delegates to it. New readers `#permission_mode` (Nexo→AgentSDK mode mapping:
177
+ `:read_only`→`:default`, `:auto`→`:bypass_permissions`, `:ask`→`:default`) and
178
+ `#allowed_tools` support the AgentSDK backend. `Permissions#mode` is now
179
+ readable.
180
+
181
+ ### Documented
182
+
183
+ - README: the loop/sandbox matrix, the two-configurations / same-agent example,
184
+ the Remote shim pattern, the Nexo→AgentSDK permission mapping, and the turn-cap
185
+ caveat (`Loops::RubyLLM` has no proven hard turn cap — observability only).
186
+
187
+ ## [0.4.0] - 2026-06-29
188
+
189
+ ### Added
190
+
191
+ - **Skills.** Drop a `SKILL.md` package into `app/skills/<name>/` and attach it
192
+ to an agent with a single `skills :name` class macro — Nexo composes the
193
+ existing `ruby_llm-skills` gem to load the skill's instructions, with zero
194
+ loader setup. Skills guide *reasoning*; the sandbox-backed tools from Spec 1
195
+ still perform *execution*.
196
+ - `Nexo::Skills.load!` lazily requires `ruby_llm-skills` (a SOFT/optional runtime
197
+ dependency) and raises `Nexo::MissingDependencyError` with install guidance when
198
+ it is absent; `require "nexo"` without the gem still loads cleanly.
199
+ - `Nexo::Skills.find(name)` resolves a skill from `Nexo.config.skills_path`
200
+ (default `app/skills` under Rails), raising `Nexo::Error` that names the missing
201
+ `SKILL.md` path when not found.
202
+ - `Nexo::Agent.skills(*names)` class macro; `#chat` layers each declared skill's
203
+ instructions on top of the agent's own, in declaration order, after the
204
+ sandbox-backed tools.
205
+ - A `nexo:skill NAME` generator scaffolding `app/skills/NAME/SKILL.md` (valid
206
+ Agent Skills frontmatter) plus a kept `references/` directory.
207
+
208
+ ### Safety
209
+
210
+ - Skill packages contribute **instructions only**: a loaded skill ships no
211
+ independent tools, and Nexo deliberately does not attach `ruby_llm-skills`'
212
+ progressive-disclosure tool (which reads files outside the sandbox). A skill's
213
+ `references/`/`scripts/` files are reached through Nexo's own permission-gated,
214
+ sandbox-backed tools, so attaching a skill never widens what an agent can do.
215
+
216
+ ## [0.3.0] - 2026-06-29
217
+
218
+ ### Added
219
+
220
+ - **WorkflowRun lifecycle primitive.** Subclass `Nexo::Workflow`, implement
221
+ `#call(payload)`, and run it with `MyWorkflow.run(payload)` to get back a
222
+ persisted run record carrying a UUID v7 runId, `status`, `payload`, `result`,
223
+ `error`, and an ordered, inspectable event log.
224
+ - `#emit(type, data)` appends ordered events (persisted incrementally) and
225
+ `Nexo::Workflow.logs(run_id)` / the `nexo:logs[run_id]` rake task inspect them.
226
+ - A storage seam `Nexo::RunStore` with two interchangeable backends: an
227
+ in-memory store (plain Ruby, offline) and an ActiveRecord store (Rails),
228
+ selected automatically by `Nexo::RunStore.default`.
229
+ - `Nexo::WorkflowRun` ActiveRecord model and a `nexo:workflows` generator that
230
+ installs a portable (`json` columns, UUID string primary key) migration.
231
+ - `Nexo.generate_run_id` (time-ordered UUID v7).
232
+ - A guarded `Nexo::Engine` wiring the generator, rake task, and model into a
233
+ host Rails app; the core still runs in plain Ruby with no Rails loaded.
234
+
3
235
  ## [0.1.0] - 2026-06-21
4
236
 
5
237
  - Initial release
data/README.md CHANGED
@@ -4,10 +4,15 @@
4
4
  > sandboxes, skills, and runs.
5
5
 
6
6
  A model alone forgets everything the moment a response ends. The harness is everything
7
- else. Nexo gives the fragmented RubyLLM ecosystem one front door with safe defaults —
7
+ else. Nexo gives the RubyLLM ecosystem one cohesive front door with safe defaults —
8
8
  build a working agent in five lines without wiring anything.
9
9
 
10
- **What Nexo adds (everything else it composes):**
10
+ ## Compose, don't reimplement
11
+
12
+ Nexo does not rebuild skill loading, the tool-call loop, MCP, or structured output — those
13
+ already live in the RubyLLM ecosystem (`ruby_llm` core, `ruby_llm-skills`, `ruby_llm-mcp`,
14
+ `ruby_llm-schema`). Nexo **composes** them behind one front door and adds only the two pieces
15
+ the ecosystem is missing:
11
16
 
12
17
  - **Sandbox + Permissions seam** — pluggable execution environment (virtual / local /
13
18
  remote) with explicit authorization gating. Default: `:virtual` + `:read_only`.
@@ -28,10 +33,76 @@ Or install directly:
28
33
  gem install nexo_ai
29
34
  ```
30
35
 
36
+ In a Rails app, run the install generator to create the conventional layout and an initializer:
37
+
38
+ ```sh
39
+ rails g nexo:install
40
+ ```
41
+
42
+ ```
43
+ create app/agents/.keep
44
+ create app/workflows/.keep
45
+ create app/skills/.keep
46
+ create config/initializers/nexo.rb
47
+ ```
48
+
49
+ ## Build an agent in five lines
50
+
51
+ Subclass `Nexo::Agent`, declare the pieces with class macros, and call `#prompt`. No
52
+ sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific —
53
+ the agent runs on any `ruby_llm`-supported model (set `NEXO_MODEL`, e.g. a local
54
+ `gemma3:12b` via Ollama, or a hosted model):
55
+
56
+ ```ruby
57
+ require "nexo"
58
+
59
+ class CodeReviewer < Nexo::Agent
60
+ model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
61
+ sandbox :local
62
+ permissions :read_only
63
+
64
+ instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
65
+ end
66
+
67
+ CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")
68
+ ```
69
+
70
+ Defaults are safe: an agent with no `sandbox`/`permissions` declared gets the in-memory
71
+ `:virtual` sandbox and `:read_only` permissions, so an untrusted model has zero host
72
+ access until you explicitly opt in.
73
+
74
+ > **Safe by default:** agents start `:virtual` + `:read_only` — an untrusted model has zero host access until you explicitly opt in.
75
+
76
+ ## Documentation
77
+
78
+ | Guide | What's inside |
79
+ |-------|---------------|
80
+ | [Getting started](docs/getting-started.md) | install, configuration, first agent |
81
+ | [Sandboxes](docs/sandboxes.md) | Virtual / Local / Remote / Container + hardened defaults |
82
+ | [Permissions](docs/permissions.md) | modes, the gate, MCP gate, :ask |
83
+ | [Tools](docs/tools.md) | ReadFile / WriteFile / Shell / Glob |
84
+ | [Loop backends](docs/loops.md) | RubyLLM vs AgentSDK, turn-cap caveat |
85
+ | [Workflows](docs/workflows.md) | lifecycle, staging, artifacts, run_agent |
86
+ | [Durable workflows](docs/durable-workflows.md) | checkpoint / suspend / resume |
87
+ | [Skills](docs/skills.md) | SKILL.md packages, gated tools |
88
+ | [MCP](docs/mcp.md) | mcp macro, fail-closed gate, transports |
89
+ | [Web](docs/web.md) | fetch tool + SSRF guard, search tool + injected backend |
90
+ | [Sessions](docs/sessions.md) | continuing, addressable memory |
91
+ | [Rails](docs/rails.md) | engine, run_later, broadcasting, generators |
92
+ | [Concurrency](docs/concurrency.md) | opt-in async, buffered emit, fiber servers |
93
+
31
94
  ## Requirements
32
95
 
33
- - Ruby 3.2+
34
- - [ruby_llm](https://github.com/crmne/ruby_llm) ~> 1.0
96
+ - Ruby 3.3+
97
+ - [ruby_llm](https://github.com/crmne/ruby_llm) >= 1.16
98
+ - [ruby_llm-skills](https://github.com/kieranklaassen/ruby_llm-skills) — optional, only
99
+ when you use the `skills` macro
100
+ - [ruby_llm-mcp](https://github.com/patvice/ruby_llm-mcp) — optional, only when you attach
101
+ an MCP server with the `mcp` macro
102
+ - `ruby_llm-agent_sdk` — optional (not yet published), only when you choose the
103
+ Anthropic-oriented `Loops::AgentSDK` backend. Its exact API is verified-on-install
104
+ (see [Loops](docs/loops.md)); until it ships, `Loops::AgentSDK` raises a clear
105
+ `Nexo::MissingDependencyError`.
35
106
 
36
107
  ## Status
37
108
 
data/Rakefile CHANGED
@@ -7,4 +7,33 @@ Minitest::TestTask.create
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ # Documentation build + coverage gate (Spec 17). RDoc is a Ruby default gem, so
11
+ # neither `rake doc` nor `rake doc:coverage` adds a runtime or dev dependency.
12
+ require "rdoc/task"
13
+
14
+ # RDoc renders the README/guides (which carry UTF-8 glyphs) by reading them with
15
+ # Encoding.default_external; some CI environments start US-ASCII, under which the
16
+ # HTML generation raises Encoding::InvalidByteSequenceError. Pin UTF-8 so the doc
17
+ # build is environment-independent (the test suite pins the same in test_helper).
18
+ Encoding.default_external = Encoding::UTF_8 if Encoding.default_external == Encoding::US_ASCII
19
+
20
+ # Naming the task :doc gives us `rake doc` (generate the API site into doc/,
21
+ # gitignored) plus, for free, `rake doc:coverage` — RDoc's own coverage report.
22
+ # Verified against rdoc 8.0.0: the coverage run exits non-zero (SystemExit) unless
23
+ # every public API is documented, so `rake doc:coverage` is a real CI gate with no
24
+ # bespoke parser to maintain. The generated site includes the README landing page,
25
+ # the docs/*.md guides, and the lib/**/*.rb API.
26
+ RDoc::Task.new(:doc) do |rd|
27
+ rd.main = "README.md"
28
+ rd.rdoc_files.include("README.md", "docs/*.md", "lib/**/*.rb")
29
+ # Generator template files are boilerplate copied verbatim into a host app
30
+ # (migrations land in the host's db/migrate, the initializer in config/) — they
31
+ # are not part of the gem's Ruby API, so they belong in neither the generated
32
+ # API site nor the coverage denominator.
33
+ rd.rdoc_files.exclude("lib/generators/**/templates/*.rb")
34
+ rd.rdoc_dir = "doc"
35
+ end
36
+
37
+ # NOTE: `default` intentionally stays test + standard — `rake doc`/`doc:coverage`
38
+ # are not part of the default run, so `bundle exec rake test` is untouched.
10
39
  task default: %i[test standard]
@@ -0,0 +1 @@
1
+ <div class="nexo-event"><%= event["type"] %>: <%= event["data"].inspect %></div>
@@ -0,0 +1,94 @@
1
+ # Concurrency (opt-in async)
2
+ Async is entirely optional — Nexo runs synchronously by default and only needs the `async` gem when you use a fan-out feature.
3
+
4
+ Async is **entirely optional**. Nexo installs and runs synchronously with no
5
+ `async` gem present, and only complains if you actually use a concurrency
6
+ feature. The `async` gem is a soft dependency — add it yourself when you want
7
+ fan-out:
8
+
9
+ ```ruby
10
+ gem "async", "~> 2.0"
11
+ ```
12
+
13
+ Two facts make this cheap:
14
+
15
+ - **LLM calls are already async-compatible.** `ruby_llm` speaks HTTP over
16
+ Faraday's `net/http` adapter, which yields on socket I/O under Ruby's fiber
17
+ scheduler. `Loops::RubyLLM` therefore runs unchanged inside a reactor — no API
18
+ change, no rewrite. Wrapping a *single* `agent.prompt` in `Async {}` gains
19
+ nothing; async only pays off under fan-out.
20
+ - **The value Nexo adds is rate-bounded fan-out.** `Nexo.concurrent` bounds
21
+ in-flight work so you don't trip provider rate limits, and propagates the first
22
+ error instead of swallowing it.
23
+
24
+ ## `Nexo.concurrent` — bounded fan-out
25
+
26
+ ```ruby
27
+ # 100 docs, but never more than 8 provider calls in flight; results in doc order.
28
+ results = Nexo.concurrent(max_in_flight: 8) do |c|
29
+ Document.find_each { |d| c.add { SummarizeDocument.run(doc_id: d.id, text: d.body).result } }
30
+ end
31
+ ```
32
+
33
+ Every block added with `c.add { … }` runs inside **one** `async` reactor,
34
+ capped at `max_in_flight` in flight (an `Async::Semaphore`) and coordinated by an
35
+ `Async::Barrier`. Results come back as an Array in **submission order** (not
36
+ completion order). On the first task that raises, that error is **re-raised** and
37
+ the remaining in-flight tasks are stopped — errors are never swallowed.
38
+ `max_in_flight` defaults to `Nexo.config.max_in_flight` (`8`) and is the single
39
+ most important knob for staying under provider rate limits.
40
+
41
+ Using `Nexo.concurrent` with `async` not installed raises
42
+ `Nexo::MissingDependencyError` with install guidance.
43
+
44
+ ## `Sandboxes::Local` offload
45
+
46
+ Under a reactor, blocking file/subprocess I/O would stall every other fiber. Flip
47
+ the switch and `Sandboxes::Local` offloads its `read`/`write`/`glob`/`shell` to a
48
+ worker thread:
49
+
50
+ ```ruby
51
+ Nexo.configure { |c| c.concurrency = :async } # default is :threaded
52
+ ```
53
+
54
+ The decision is driven by config, not by scheduler detection: under `:async` the
55
+ blocking block runs on a worker thread so the reactor keeps serving other fibers;
56
+ under `:threaded` (the default) it runs inline with zero overhead — byte-for-byte
57
+ the synchronous behavior. Offloading changes neither return values nor the
58
+ security properties: the path-escape guard, narrowed ENV, and `Timeout`-wrapped
59
+ subprocess are all preserved. (`Sandboxes::Virtual` is pure memory and
60
+ `Sandboxes::Remote` is already HTTP/fiber-friendly — neither needs offload.)
61
+
62
+ ## `Workflow` buffered emit
63
+
64
+ Each `emit` normally persists immediately. Under a reactor that per-event DB write
65
+ blocks the whole loop, so `Workflow.run` takes a `buffer_events:` flag (default
66
+ `Nexo.config.buffer_workflow_events`, `false`):
67
+
68
+ ```ruby
69
+ run = SummarizeDocument.run({doc_id: 1, text: body}, buffer_events: true)
70
+ # events buffer in memory and flush to the store exactly once, on completion
71
+ ```
72
+
73
+ With buffering on, events accumulate in memory and flush in a single
74
+ `save_events!` at the end of the run (on both success and failure). The default
75
+ (unbuffered) behavior is unchanged.
76
+
77
+ ## Running under Rails / a fiber server
78
+
79
+ Async DB work is the sharp edge. Under a fiber server such as
80
+ [Falcon](https://github.com/socketry/falcon), many concurrent queries can exhaust
81
+ the ActiveRecord connection pool, so:
82
+
83
+ - **Raise `DB_POOL`** (the connection-pool size) to cover your in-flight
84
+ concurrency.
85
+ - On **Rails 7.1+**, consider
86
+ `config.active_record.async_query_executor`.
87
+ - Prefer `buffer_events: true` for workflows so each run writes its event log
88
+ once instead of per event.
89
+
90
+ Note that DB work under a reactor is *offloaded/pooled*, not truly fiber-async —
91
+ Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber
92
+ scheduler), see the [`async` guide](https://socketry.github.io/async/).
93
+
94
+ ← Back to the [README](../README.md)
@@ -0,0 +1,184 @@
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
+ ## Durable **agent** approval — `:approve` (bridge a mid-run gate to a suspend)
68
+
69
+ The example above suspends at an **explicit** `suspend!` the workflow author placed.
70
+ The `:approve` mode adds the durable, cross-process sibling of `:ask` for the case where a
71
+ `run_agent`-driven agent hits a permission gate **mid-loop** and you want that to
72
+ **pause the run for a human**, not run unchecked and not block a worker. Declare the
73
+ agent under the `:approve` mode:
74
+
75
+ ```ruby
76
+ class Scribe < Nexo::Agent
77
+ model ENV.fetch("NEXO_MODEL")
78
+ sandbox :local
79
+ permissions :approve # every gated capability needs a human decision
80
+ end
81
+
82
+ class ApprovedWrite < Nexo::Workflow
83
+ sandbox :local
84
+ agent Scribe
85
+ def call(_p) = { content: run_agent("Write 'hi' to notes.txt").content }
86
+ end
87
+ ```
88
+
89
+ The loop is: **`:approve` gate with no decision → `Nexo::ApprovalRequired` → `run_agent`
90
+ suspends → host renders the pending call → `resume(approved:)` threads the decision back
91
+ through the gate.**
92
+
93
+ ```ruby
94
+ run = ApprovedWrite.run # agent reaches the write gate, run suspends
95
+ run.status # => "suspended"
96
+ run.state["__suspend__"]["reason"] # => "approval: notes.txt"
97
+ run.state["__approval__"] # => { "capability" => "write",
98
+ # "tool" => "notes.txt", "args" => nil }
99
+ # "args" carries the tool call arguments only for an MCP-tool approval; a sandbox
100
+ # capability gate (write/shell/fetch/search) records "args" => nil — the pending
101
+ # call is identified by "capability" + "tool".
102
+
103
+ # ...a human approves — possibly in another process (resume_later for the AR store):
104
+ resumed = ApprovedWrite.resume(run.id, approved: true)
105
+ resumed.status # => "done" (the gate allowed the write)
106
+ ```
107
+
108
+ - **`Nexo::ApprovalRequired`** is a signal, **distinct from `Permissions::Denied`**:
109
+ `Denied` means "no, adapt" (tools rescue it into `{error:}`); `ApprovalRequired` means
110
+ "pause and ask a human", so tools must **not** rescue it — it propagates out of the
111
+ tool loop and out of `Agent#prompt`, where `run_agent` catches it.
112
+ - **Undecided ⇒ suspend, `approved: false` ⇒ deny.** The default stays safe: an
113
+ unresolved approval never silently allows, and a denial on resume makes the tool return
114
+ `{error:}` (the model adapts) — the run still finishes `"done"`, **without** the gated
115
+ effect, never `"failed"`.
116
+ - **Scope which actions need approval** with the same `ask_when` predicate as `:ask`
117
+ (aliased `approve_when:` for readability) — unset means every gated action needs a
118
+ decision; a falsey predicate auto-allows without one:
119
+
120
+ ```ruby
121
+ Nexo::Permissions.new(mode: :approve,
122
+ approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") })
123
+ ```
124
+ - **Synchronous `:ask` is untouched.** `:ask` (in-process `on_ask`) is still the right
125
+ choice with a human at the keyboard during a synchronous `run`; `:approve` is its
126
+ durable, cross-process sibling for `run_later`/`resume_later`.
127
+
128
+ **Caveats (read before relying on it):**
129
+ - **Re-entry, not replay.** On resume the agent re-drives `#call` from the top; a
130
+ non-idempotent tool call *before* the approval gate re-runs on resume (agent tool calls
131
+ generally aren't checkpointable). Put approval gates **early**, or after the expensive
132
+ work is already `checkpoint`ed by the workflow.
133
+ - **One approval per suspend cycle, global decision.** The `{approved:}` answers whichever
134
+ gate the re-driven agent hits first. A *second* gate after an approved first one simply
135
+ **suspends again** — the next resume decides it. There is no per-tool decision granularity
136
+ in v1.
137
+ - **Cross-process approval needs the ActiveRecord store + ActiveJob** (like all durable resume).
138
+ In-process `resume` works with the Memory store; a Memory run does not survive the process.
139
+ - **Branch depends on upstream `ruby_llm`.** This works because `ruby_llm`'s tool loop lets a
140
+ tool `execute` exception propagate out of `chat.ask` (verified, 1.16.0). If a future
141
+ `ruby_llm` swallows tool exceptions, tool-triggered approval would be constrained — a
142
+ genuine upstream dependency, stated plainly.
143
+
144
+ See [`examples/approval_agent.rb`](../examples/approval_agent.rb) for the live flow
145
+ (`NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb`).
146
+
147
+ The `state` column ships with fresh installs. Apps installed before this feature
148
+ add it with an additive migration:
149
+
150
+ ```sh
151
+ rails g nexo:state
152
+ rails db:migrate
153
+ ```
154
+
155
+ ## Honest resume semantics — read this before relying on resume
156
+
157
+ Resume **re-enters `#call` from the top** — Ruby has no transparent continuation
158
+ capture, so this is **not** replay:
159
+
160
+ - **Everything *outside* a `checkpoint` re-runs on resume.** Only checkpoint-guarded
161
+ work is skipped (its stored result is returned). Wrap every expensive step and
162
+ every side effect in a `checkpoint`; the idempotency of the non-checkpointed code
163
+ is **your** responsibility.
164
+ - **A crash *inside* a checkpoint re-runs that checkpoint** on resume (at-least-once
165
+ for the in-flight step) — so a checkpoint's side effect should tolerate being
166
+ retried.
167
+ - **Checkpoint values must be json-serializable** — they round-trip the store exactly
168
+ like `result`/`events`.
169
+ - **Cross-process resume needs the ActiveRecord store.** A run suspended under the
170
+ in-memory store resumes only *in-process* (which is what the test suite exercises);
171
+ a run that must survive the process (and be resumed by a controller/job elsewhere)
172
+ needs the AR store with a shared database.
173
+ - **Never `suspend!` inside a `checkpoint` block** (undefined — unsupported), and
174
+ never name a checkpoint `"__suspend__"` (reserved for the suspend metadata) or
175
+ `"__approval__"` (reserved for the pending approval call — see the durable-approval
176
+ section above) — both are keys Nexo stores in `state`.
177
+
178
+ There is **no distinct `"resumed"` status**: resume re-enters `execute`, so a host
179
+ sees the existing `suspended` → `running` → `done` (or `suspended` again)
180
+ transitions over the usual `nexo.workflow.status` notifications. The boot
181
+ `reconcile_interrupted!` sweep leaves `"suspended"` runs **untouched** — an
182
+ intentional pause is never mistaken for an orphaned `"running"` run.
183
+
184
+ ← Back to the [README](../README.md)