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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f882444cb236ed835107b086528a0411da8e0ef5e8772816b895a074b3be3046
4
- data.tar.gz: f2bf5a3b073b40ddbdb4b76bc9e798caeaa9ead072042a6580e79010a3022ba2
3
+ metadata.gz: 7e3ef801100c09633b3bc0eba7f1c5444abc3a28d39b093b3f556a6e6a193304
4
+ data.tar.gz: 770aa194ebef30adc05049890df4611914d9af49ce5bc37460d142dce0a5277d
5
5
  SHA512:
6
- metadata.gz: f2c213194e4cdf220cec97d6807d060ef82187163bb2256c802c8545550817892488309df51a91a40b18c5e7411fb7c81afedcf2a297653def6056169f6a80f8
7
- data.tar.gz: b17d7f23f163402c9b0b2f3c119dc87370dc09831daa007054483fd3deda365813234d5879ab2dab126bff0c14c7a0f67f583eda0133f3064c8b55e5a78d469b
6
+ metadata.gz: 201531d77a85255355cf77d34d0dd676843f20dee2149627226a1f9068c5b6dfcc7e9e4d92e33b357536326f35e861d9fc1d1d6230bac1e5eac5db9314e3ec75
7
+ data.tar.gz: aff13284d24b6465a10a34b60847d6ceece755b8d8791b688777a6ec7538d7506db82922a9e83d4382a66d3045ee45ee3a4c9eede4d2b8a37e4021e9e134a934
data/.document ADDED
@@ -0,0 +1,3 @@
1
+ README.md
2
+ docs
3
+ lib
data/CHANGELOG.md CHANGED
@@ -1,5 +1,260 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-07-16
4
+
5
+ Durability enhancements for workflows: a suspended run can now wake itself on a
6
+ timer, and independent checkpoints run concurrently, each persisting as it
7
+ completes. Both build on the existing ActiveJob and `Nexo.concurrent` seams — no
8
+ schema, run-status, or store change.
9
+
10
+ ### Added
11
+
12
+ - **Scheduled enqueue/resume.** `Workflow.run_later` and `Workflow.resume_later`
13
+ accept `wait:` (a duration) or `wait_until:` (an absolute time), forwarded to the
14
+ installed ActiveJob's own `.set(...)` scheduler — so a suspended run can wake
15
+ itself on a timer and an initial enqueue can be deferred, without Nexo adding a
16
+ scheduler. Passing both raises `ArgumentError`; with neither the enqueue is
17
+ unchanged. Status stays `"queued"`/`"suspended"` (no new status). No retry
18
+ semantics are added.
19
+ - **Parallel checkpoints.** `Workflow#checkpoint_all(name => callable, …)` runs
20
+ independent checkpoints concurrently through the existing `Nexo.concurrent`
21
+ driver, persisting **each step as it completes** so a resume after a partial
22
+ failure re-runs only the still-missing steps. Each newly-completed step emits a
23
+ `"checkpoint"`-typed event (name-only) on the existing `nexo.workflow.event`
24
+ seam. Reuses `state`/`save_state!` — no schema, run-status, or store change.
25
+
26
+ ## [0.7.0] - 2026-07-10
27
+
28
+ The harness fills out: MCP data sources, a web-content capability, durable
29
+ workflows (suspend/checkpoint/resume) with an approval bridge, continuing agent
30
+ sessions, a container sandbox, Rails runtime primitives, and a documentation
31
+ restructure — followed by a security-and-lifecycle hardening pass over the whole
32
+ Sandbox + Permissions seam.
33
+
34
+ ### Added
35
+
36
+ - **MCP data sources.** `Agent.mcp` attaches Model Context Protocol servers
37
+ (stdio/SSE/HTTP) via `ruby_llm-mcp` (soft, optional dependency). Every MCP tool
38
+ call passes a second gate — `Permissions#authorize_mcp!` — scoped by the
39
+ `mcp_allow` exact-match allow-list (default `[]`, so `:read_only` fails closed).
40
+ `MCP::GatedTool` is a plain delegating wrapper; teardown is `Agent#close`.
41
+ - **MCP over HTTP with an OAuth token provider.** `MCP.build(token:)` accepts a
42
+ static string or a callable and injects `Authorization: Bearer …` into the
43
+ HTTP-family transports. A rotated token is resolved once per client build, so
44
+ rotation needs `Agent#close` + a fresh prompt (documented reconnect caveat).
45
+ - **Web-content capability (`:fetch`).** `Nexo::Tools::Fetch` (stdlib `net/http`
46
+ GET) is a two-lock capability: the `:fetch` permission (default-denied under
47
+ `:read_only`) plus a host in the subdomain-aware `fetch_allow` list. An SSRF
48
+ guard resolves the host once, pins the vetted IP, and denies loopback/private/
49
+ link-local/CGNAT/unspecified ranges. Responses are byte-capped.
50
+ - **Web search capability (`:search`).** `Nexo::Tools::WebSearch` with an
51
+ injected backend (`search_backend`), gated like `:fetch`.
52
+ - **Input staging + named artifacts.** `Workflow#stage` writes files into the
53
+ run's sandbox before work begins; `Workflow#artifact` records named outputs
54
+ (`content:` or a trusted ERB `from:` template) on the run, persisted immediately.
55
+ Both reuse the sandbox + WorkflowRun seams; a data-only workflow builds nothing.
56
+ - **Workflow → agent glue.** `Workflow#run_agent(prompt, max_turns:)` runs the
57
+ declared `agent` inside the run's shared sandbox and forwards every loop event
58
+ through `emit`.
59
+ - **Durable workflows.** `Workflow#suspend!`, `#checkpoint`, and `Workflow.resume`
60
+ add a `"suspended"` status and a JSON `state` column: a workflow can pause for
61
+ input, persist completed checkpoints, and resume (sync or via `resume_later`)
62
+ without re-running checkpointed work. `resume` atomically claims a suspended run
63
+ (no double execution); checkpoint values are JSON-normalized for Memory/AR parity.
64
+ - **Durable approval bridge.** Under `permissions :approve`, an undecided
65
+ sensitive tool call raises `ApprovalRequired`, which `run_agent` records under a
66
+ reserved `__approval__` state key and turns into a `suspend!`; `resume(approved:)`
67
+ threads the human decision back into the agent's permissions.
68
+ - **Continuing agent sessions.** `Nexo::Session` persists a chat across prompts
69
+ via ruby_llm's `acts_as_chat` (host-owned schema), with idempotent instruction
70
+ re-application on every resume and once-only observability wiring.
71
+ - **Container sandbox.** `Sandboxes::Container` runs an agent's tools inside a
72
+ throwaway, hardened OCI container via the `docker` (default) or Apple `container`
73
+ CLI — no vendor gem, argv arrays only (no shell-string interpolation). Ephemeral
74
+ by default; opt-in `reconnect: true` reattaches by an exact identity label.
75
+ - **Rails runtime primitives.** `Nexo::WorkflowJob` (ActiveJob), Turbo mirroring,
76
+ and `ActiveSupport::Notifications` (`nexo.workflow.status` / `nexo.workflow.event`)
77
+ — all Rails-optional and guarded, no-ops when their dependency is absent.
78
+ - **Unified sandbox resolver.** `Sandboxes.resolve` is the single place a sandbox
79
+ declaration (symbol / Hash / pre-built instance) becomes a concrete `Sandbox`.
80
+ - **`provider` / `assume_model_exists` agent macros** for non-registry models
81
+ (e.g. Ollama), now documented in the getting-started guide.
82
+ - **Documentation restructure.** The README is a slim index; thirteen topic guides
83
+ live under `docs/`, and `rake doc` / `rake doc:coverage` (a 100%-or-fail gate)
84
+ render and enforce RDoc coverage.
85
+
86
+ ### Security
87
+
88
+ - **MCP gate no longer fails open under `:approve`.** `authorize_mcp!` gained an
89
+ `:approve` branch and a fail-closed `else raise Denied` backstop, so no mode
90
+ falls through ungated.
91
+ - **Closed shell/command injection in `Container#glob` and `Remote#glob`.** The
92
+ model-supplied pattern is passed as a positional argument, never interpolated
93
+ into a command line — a `:read_only` agent can no longer reach command execution
94
+ through the always-allowed `:glob` capability.
95
+ - **`Local` sandbox hardening.** `glob` guards the pattern against escapes and
96
+ filters symlinked matches; `read`/`write` resolve symlinks via `realpath` so a
97
+ symlink inside `cwd` pointing outside it no longer escapes the sandbox.
98
+ - **Fetch SSRF hardened.** Resolve-once IP pinning removes the DNS-rebinding
99
+ window; the deny set adds `0.0.0.0/8`, CGNAT `100.64.0.0/10`, and the IPv6
100
+ unspecified address.
101
+
102
+ ### Fixed
103
+
104
+ - **Sandbox lifecycle — no container leak.** `Agent#close` now closes the sandbox
105
+ it owns; `Workflow.execute` releases the run's sandbox on every terminal path.
106
+ A borrowed sandbox (injected via `sandbox:`, e.g. `run_agent`) is left to its
107
+ owner, so multiple `run_agent` calls no longer tear down a shared container.
108
+ - **Workflow lifecycle correctness.** A non-`StandardError` (path-escape
109
+ `SecurityError`, shell-less `NotImplementedError`) now marks a run `"failed"`
110
+ instead of stranding it `"running"`; `resume` claims the run atomically; a
111
+ non-approval resume is no longer coerced into a silent denial; reserved
112
+ `__suspend__` / `__approval__` state is cleared on a successful resume; status
113
+ notifications fire for `queued`/`interrupted`; `resume` honors the original
114
+ `buffer_events`.
115
+ - **Ambiguous workflow payloads raise.** `run` / `run_later` reject a positional
116
+ payload combined with leftover keywords instead of silently dropping keys.
117
+ - **Agent config is inherited.** Subclassing a configured agent copies its macros
118
+ instead of reverting to defaults.
119
+ - **Session/loop fixes.** The base `Loop#run` contract includes `chat:`;
120
+ `Loops::AgentSDK` rejects a session chat with a clear error and emits the
121
+ terminal `:done` event; durable-session instruction application collapses to one
122
+ copy per resume even without an `instructions` macro; the Memory session path
123
+ adopts the agent owning the live chat so `close`/`prompt` hit live resources.
124
+ - **Miscellaneous.** Memory `RunStore` is mutex-guarded and mirrors the AR read
125
+ helpers; `engine.rb` is Zeitwerk-ignored; `Agent#close` is exception-safe per
126
+ client; `skills`/`mcp_allow`/`fetch_allow` accumulate (deduped) like `mcp`;
127
+ `ReadFile` caps its output; `WebSearch` and `stage` tolerate string- or
128
+ symbol-keyed input; dead `save!` and `turns`-counter code removed.
129
+
130
+ ## [0.6.0] - 2026-07-01
131
+
132
+ ### Added
133
+
134
+ - **Opt-in fiber concurrency (`async`).** `Nexo.concurrent(max_in_flight:) { |c| c.add { … } }`
135
+ runs many agent/workflow calls inside one `async` reactor, bounded by an
136
+ `Async::Semaphore` and coordinated by an `Async::Barrier`. Results come back in
137
+ **submission order**; the first task to raise is re-raised (errors are never
138
+ swallowed) and the remaining in-flight tasks are stopped. `max_in_flight`
139
+ (default `Nexo.config.max_in_flight`, i.e. `8`) keeps fan-out under provider
140
+ rate limits — the reason to prefer it over a hand-rolled `Async {}`.
141
+ - **`async` is a SOFT/optional dependency** — lazily required only when a
142
+ concurrency feature is used. `require "nexo"` with `async` absent does not
143
+ raise; using `Nexo.concurrent` (or the `:async` sandbox offload) without it
144
+ raises `Nexo::MissingDependencyError` with install guidance
145
+ (`gem "async", "~> 2.0"`).
146
+ - **`Nexo::Configuration` gains three settings:** `concurrency`
147
+ (`:threaded` default | `:async`), `max_in_flight` (`8`), and
148
+ `buffer_workflow_events` (`false`).
149
+ - **`Sandboxes::Local` async offload.** `read`/`write`/`glob`/`shell` route
150
+ through a private `#offload`; when `Nexo.config.concurrency == :async` the
151
+ blocking file/subprocess I/O runs on a worker thread so it doesn't stall the
152
+ reactor, otherwise it runs inline (byte-for-byte the previous behavior, zero
153
+ overhead). The path-escape `SecurityError` guard, narrowed ENV, and
154
+ `Timeout`-wrapped `Open3.capture3` are all preserved unchanged.
155
+ - **`Workflow.run(payload, buffer_events:)` buffered emit.** With
156
+ `buffer_events: true` (default `Nexo.config.buffer_workflow_events`) events are
157
+ buffered in memory and flushed to the store exactly once (in `run`'s `ensure`,
158
+ so they persist on success and failure), avoiding a blocking per-event DB write
159
+ under a reactor. The default (unbuffered) path is unchanged from 0.4.0.
160
+
161
+ ### Notes
162
+
163
+ - `Loops::RubyLLM` needs **no changes** to run inside a reactor — `ruby_llm`'s
164
+ Faraday `net/http` adapter already yields on socket I/O under Ruby's fiber
165
+ scheduler. A regression test proves a prompt driven from inside `Async {}`
166
+ returns unchanged.
167
+ - `Async::WorkerPool` is not present in the installed `async` (2.x), so the
168
+ offload primitive is `Thread.new(&block).value` (re-raises block exceptions in
169
+ the caller).
170
+
171
+ ### Changed
172
+
173
+ - Raised minimum Ruby to 3.3; `Nexo.generate_run_id` now uses
174
+ `SecureRandom.uuid_v7` unconditionally (dropped the 3.2 UUID v4 fallback).
175
+
176
+ ## [0.5.0] - 2026-06-29
177
+
178
+ ### Added
179
+
180
+ - **Pluggable loop backends.** `Nexo::Loop` is the new engine seam:
181
+ `#run(agent:, prompt:, max_turns:, &on_event)`. Swap the loop by constructor
182
+ injection (`loop:`) without changing the agent class.
183
+ - `Nexo::Loops::RubyLLM` (DEFAULT, provider-neutral) — the Spec 1 `Agent#prompt`
184
+ loop logic extracted verbatim. It runs on any `ruby_llm` model and contains no
185
+ vendor code. Wires turn-count *observability* through `RubyLLM::Chat`'s
186
+ `before_tool_call`/`after_tool_result` callbacks (guarded by `respond_to?`),
187
+ forwarding `:tool_call`/`:tool_result`/`:done` to an optional `on_event` block.
188
+ - `Nexo::Loops::AgentSDK` (opt-in, Anthropic-oriented) — wraps
189
+ `RubyLLM::AgentSDK.query` for native `max_turns`, permission modes, and the
190
+ SDK's built-in tools. `ruby_llm-agent_sdk` is a SOFT/optional dependency,
191
+ required lazily; a clear `Nexo::MissingDependencyError` is raised when it's
192
+ absent. Not a dependency of this release (its `.query` signature is
193
+ verified-on-install).
194
+ - `Nexo::Sandboxes::Remote` — run an agent's tools inside a remote container by
195
+ injecting any client satisfying the four-method contract (`read`/`write`/
196
+ `exec`/`close`). Contains zero vendor code; switching providers is swapping the
197
+ injected object. Vendor SDKs are adapted with a tiny documented shim.
198
+ - `Agent` gains a `loop:` option (default `Loops::RubyLLM.new`); `#prompt`
199
+ delegates to it. New readers `#permission_mode` (Nexo→AgentSDK mode mapping:
200
+ `:read_only`→`:default`, `:auto`→`:bypass_permissions`, `:ask`→`:default`) and
201
+ `#allowed_tools` support the AgentSDK backend. `Permissions#mode` is now
202
+ readable.
203
+
204
+ ### Documented
205
+
206
+ - README: the loop/sandbox matrix, the two-configurations / same-agent example,
207
+ the Remote shim pattern, the Nexo→AgentSDK permission mapping, and the turn-cap
208
+ caveat (`Loops::RubyLLM` has no proven hard turn cap — observability only).
209
+
210
+ ## [0.4.0] - 2026-06-29
211
+
212
+ ### Added
213
+
214
+ - **Skills.** Drop a `SKILL.md` package into `app/skills/<name>/` and attach it
215
+ to an agent with a single `skills :name` class macro — Nexo composes the
216
+ existing `ruby_llm-skills` gem to load the skill's instructions, with zero
217
+ loader setup. Skills guide *reasoning*; the sandbox-backed tools from Spec 1
218
+ still perform *execution*.
219
+ - `Nexo::Skills.load!` lazily requires `ruby_llm-skills` (a SOFT/optional runtime
220
+ dependency) and raises `Nexo::MissingDependencyError` with install guidance when
221
+ it is absent; `require "nexo"` without the gem still loads cleanly.
222
+ - `Nexo::Skills.find(name)` resolves a skill from `Nexo.config.skills_path`
223
+ (default `app/skills` under Rails), raising `Nexo::Error` that names the missing
224
+ `SKILL.md` path when not found.
225
+ - `Nexo::Agent.skills(*names)` class macro; `#chat` layers each declared skill's
226
+ instructions on top of the agent's own, in declaration order, after the
227
+ sandbox-backed tools.
228
+ - A `nexo:skill NAME` generator scaffolding `app/skills/NAME/SKILL.md` (valid
229
+ Agent Skills frontmatter) plus a kept `references/` directory.
230
+
231
+ ### Safety
232
+
233
+ - Skill packages contribute **instructions only**: a loaded skill ships no
234
+ independent tools, and Nexo deliberately does not attach `ruby_llm-skills`'
235
+ progressive-disclosure tool (which reads files outside the sandbox). A skill's
236
+ `references/`/`scripts/` files are reached through Nexo's own permission-gated,
237
+ sandbox-backed tools, so attaching a skill never widens what an agent can do.
238
+
239
+ ## [0.3.0] - 2026-06-29
240
+
241
+ ### Added
242
+
243
+ - **WorkflowRun lifecycle primitive.** Subclass `Nexo::Workflow`, implement
244
+ `#call(payload)`, and run it with `MyWorkflow.run(payload)` to get back a
245
+ persisted run record carrying a UUID v7 runId, `status`, `payload`, `result`,
246
+ `error`, and an ordered, inspectable event log.
247
+ - `#emit(type, data)` appends ordered events (persisted incrementally) and
248
+ `Nexo::Workflow.logs(run_id)` / the `nexo:logs[run_id]` rake task inspect them.
249
+ - A storage seam `Nexo::RunStore` with two interchangeable backends: an
250
+ in-memory store (plain Ruby, offline) and an ActiveRecord store (Rails),
251
+ selected automatically by `Nexo::RunStore.default`.
252
+ - `Nexo::WorkflowRun` ActiveRecord model and a `nexo:workflows` generator that
253
+ installs a portable (`json` columns, UUID string primary key) migration.
254
+ - `Nexo.generate_run_id` (time-ordered UUID v7).
255
+ - A guarded `Nexo::Engine` wiring the generator, rake task, and model into a
256
+ host Rails app; the core still runs in plain Ruby with no Rails loaded.
257
+
3
258
  ## [0.1.0] - 2026-06-21
4
259
 
5
260
  - 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,100 @@
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
+ Inside a durable workflow, `Workflow#checkpoint_all` is the workflow-durability
45
+ flavored sibling of `Nexo.concurrent`: it drives this same bounded fan-out but
46
+ persists each step to the run's `state` as it lands, so a resume only re-runs what
47
+ never completed. See [Parallel checkpoints](durable-workflows.md#parallel-checkpoints--checkpoint_all)
48
+ in the durable-workflows guide.
49
+
50
+ ## `Sandboxes::Local` offload
51
+
52
+ Under a reactor, blocking file/subprocess I/O would stall every other fiber. Flip
53
+ the switch and `Sandboxes::Local` offloads its `read`/`write`/`glob`/`shell` to a
54
+ worker thread:
55
+
56
+ ```ruby
57
+ Nexo.configure { |c| c.concurrency = :async } # default is :threaded
58
+ ```
59
+
60
+ The decision is driven by config, not by scheduler detection: under `:async` the
61
+ blocking block runs on a worker thread so the reactor keeps serving other fibers;
62
+ under `:threaded` (the default) it runs inline with zero overhead — byte-for-byte
63
+ the synchronous behavior. Offloading changes neither return values nor the
64
+ security properties: the path-escape guard, narrowed ENV, and `Timeout`-wrapped
65
+ subprocess are all preserved. (`Sandboxes::Virtual` is pure memory and
66
+ `Sandboxes::Remote` is already HTTP/fiber-friendly — neither needs offload.)
67
+
68
+ ## `Workflow` buffered emit
69
+
70
+ Each `emit` normally persists immediately. Under a reactor that per-event DB write
71
+ blocks the whole loop, so `Workflow.run` takes a `buffer_events:` flag (default
72
+ `Nexo.config.buffer_workflow_events`, `false`):
73
+
74
+ ```ruby
75
+ run = SummarizeDocument.run({doc_id: 1, text: body}, buffer_events: true)
76
+ # events buffer in memory and flush to the store exactly once, on completion
77
+ ```
78
+
79
+ With buffering on, events accumulate in memory and flush in a single
80
+ `save_events!` at the end of the run (on both success and failure). The default
81
+ (unbuffered) behavior is unchanged.
82
+
83
+ ## Running under Rails / a fiber server
84
+
85
+ Async DB work is the sharp edge. Under a fiber server such as
86
+ [Falcon](https://github.com/socketry/falcon), many concurrent queries can exhaust
87
+ the ActiveRecord connection pool, so:
88
+
89
+ - **Raise `DB_POOL`** (the connection-pool size) to cover your in-flight
90
+ concurrency.
91
+ - On **Rails 7.1+**, consider
92
+ `config.active_record.async_query_executor`.
93
+ - Prefer `buffer_events: true` for workflows so each run writes its event log
94
+ once instead of per event.
95
+
96
+ Note that DB work under a reactor is *offloaded/pooled*, not truly fiber-async —
97
+ Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber
98
+ scheduler), see the [`async` guide](https://socketry.github.io/async/).
99
+
100
+ ← Back to the [README](../README.md)