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
data/docs/rails.md ADDED
@@ -0,0 +1,162 @@
1
+ # Rails
2
+ Rails wiring: background execution with `run_later`, live progress broadcasting, and run-query helpers for a host UI.
3
+
4
+ The install generator (`rails g nexo:install`) is covered in [Getting started](getting-started.md);
5
+ the per-feature generators (`rails g nexo:workflows`, `nexo:artifacts`, `nexo:state`, `nexo:skill`)
6
+ live with their topics in [Workflows](workflows.md), [Durable workflows](durable-workflows.md), and
7
+ [Skills](skills.md).
8
+
9
+ ## Background execution — `run_later`
10
+
11
+ `MyWorkflow.run_later(payload)` enqueues the run on your **existing ActiveJob
12
+ adapter** and hands back the run **immediately** (status `"queued"`), so a
13
+ controller can return while the work happens in the background. The job carries
14
+ **only the run id** — the payload lives on the run record, so no arguments (and
15
+ no secrets) travel through the queue. When the worker picks it up, it reconstitutes
16
+ the workflow and calls the same `execute` the sync path uses, so an async run
17
+ reaches the identical `done`/`failed` lifecycle, event log, and status
18
+ notifications:
19
+
20
+ ```ruby
21
+ class GenerateReport < Nexo::Workflow
22
+ def call(payload) = { url: build_report(payload[:account_id]) }
23
+ end
24
+
25
+ run = GenerateReport.run_later(account_id: 42) # returns at once
26
+ run.status # => "queued"
27
+ # ...the worker runs it in the background; later:
28
+ Nexo::RunStore.default.find(run.id).status # => "done"
29
+ ```
30
+
31
+ Route jobs to a dedicated queue per call or globally:
32
+
33
+ ```ruby
34
+ GenerateReport.run_later(account_id: 42, queue: :nexo) # per-call
35
+ Nexo.configure { |c| c.job_queue = :nexo } # or a global default
36
+ ```
37
+
38
+ ### Scheduling a future run or resume
39
+
40
+ `run_later` and `resume_later` accept `wait:` (a duration) or `wait_until:` (an
41
+ absolute time), forwarded straight to **the installed ActiveJob's own**
42
+ `.set(...)` scheduler — Nexo adds no scheduler of its own. Use them to defer an
43
+ initial enqueue ("send this digest at 9am") or to let a suspended run wake itself
44
+ on a timer, symmetrically:
45
+
46
+ ```ruby
47
+ # Defer the initial enqueue until tomorrow morning.
48
+ DailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)
49
+
50
+ # Let a suspended run wake itself up in an hour (no separate scheduled job).
51
+ MyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)
52
+ ```
53
+
54
+ The run's status is unchanged — a scheduled `run_later` is still `"queued"` (no
55
+ `"scheduled"` status is invented), and a scheduled `resume_later` leaves the run
56
+ `"suspended"` until the job fires. Passing **both** `wait:` and `wait_until:` in
57
+ one call raises `ArgumentError` (checked before any run is created or job
58
+ enqueued) — the installed ActiveJob would otherwise silently keep just one. With
59
+ neither given, the enqueue is byte-for-byte the immediate one above.
60
+
61
+ > **⚠️ `wait:`/`wait_until:`/`queue:` are scheduling options, not payload.** Like
62
+ > `queue:` already was, a bare-keyword call consumes them as options: `run_later(wait:
63
+ > 60)` schedules the job **60 seconds out** and leaves the payload `{}` — it does
64
+ > **not** store `"wait" => 60` as data. A payload that legitimately needs a key named
65
+ > `"wait"` must be passed as an explicit positional Hash: `run_later({wait: "value"})`.
66
+
67
+ This is still "no scheduler, no cron" — `wait:`/`wait_until:` schedule a **single**
68
+ future run/resume via ActiveJob; recurring schedules stay the host's (see the "no
69
+ queue and no scheduler" note below).
70
+
71
+ Nexo ships **no queue and no scheduler** — ActiveJob uses whatever adapter your
72
+ app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob
73
+ / `whenever`) stays the host's. Without ActiveJob, `run_later` raises
74
+ `Nexo::MissingDependencyError` — use `run` for synchronous execution.
75
+
76
+ > **⚠️ Needs a shared store.** For a worker in **another process** to find the run,
77
+ > use the ActiveRecord store (`rails g nexo:workflows`) with a real adapter — the
78
+ > run must live in the database, not in a per-process memory store. The in-memory
79
+ > store only works under the `:inline`/`:test` adapters, where the job runs
80
+ > in-process on enqueue.
81
+ >
82
+ > **⚠️ No automatic crash recovery / no automatic retries.** A crashed or retried
83
+ > job re-runs `#call` **from scratch** — Nexo adds no `retry_on` (configure retries
84
+ > in your host job if you want them). Pair with `reconcile_interrupted!` (above) to
85
+ > sweep runs orphaned in `"running"`. For an *intentional* pause-and-continue (a
86
+ > human approval, an external callback), see **[Durable workflows](durable-workflows.md)** —
87
+ > `checkpoint` skips already-paid-for work when a run resumes.
88
+
89
+ ## Live progress — notifications and opt-in Turbo
90
+
91
+ Every run **broadcasts as it happens** over `ActiveSupport::Notifications`,
92
+ decoupled from persistence (events still buffer/persist separately). Two
93
+ notifications fire (a no-op with no ActiveSupport, so the plain-Ruby core stays
94
+ Rails-free):
95
+
96
+ - `nexo.workflow.event` — one per `emit`, payload `{ run_id:, event: }` (the event
97
+ is the string-keyed `{"type" =>, "data" =>, "at" =>}` hash). Fires **live**, even
98
+ when event *persistence* is buffered.
99
+ - `nexo.workflow.status` — on each status transition, payload `{ run_id:, status: }`.
100
+
101
+ The payloads carry only what `emit`/the run already hold — **no payload or
102
+ credential dumps**. Subscribe for logging, metrics, or your own UI:
103
+
104
+ ```ruby
105
+ ActiveSupport::Notifications.subscribe("nexo.workflow.event") do |*, payload|
106
+ Rails.logger.info("[run #{payload[:run_id]}] #{payload[:event]["type"]}")
107
+ end
108
+ ```
109
+
110
+ **Opt-in Turbo mirror.** Set `config.broadcast_events = true` (and have turbo-rails
111
+ present) and the engine subscribes `Nexo::TurboBroadcaster`, which appends each
112
+ event to a per-run Turbo stream, rendering the overridable partial
113
+ `app/views/nexo/_event.html.erb`. To show live progress, add to your own page (Nexo
114
+ ships **no** controllers, routes, or dashboard — the host owns all HTTP + UI):
115
+
116
+ ```erb
117
+ <%= turbo_stream_from "nexo_run_#{@run.id}" %>
118
+ <div id="nexo_run_<%= @run.id %>_events">
119
+ <%# appended events land here %>
120
+ </div>
121
+ ```
122
+
123
+ Override the appearance by defining your own `app/views/nexo/_event.html.erb` in
124
+ the host app — it takes precedence over the engine's default (which renders
125
+ `<div class="nexo-event">type: {data.inspect}</div>`).
126
+
127
+ ```ruby
128
+ Nexo.configure { |c| c.broadcast_events = true } # opt in; requires turbo-rails
129
+ ```
130
+
131
+ > **⚠️ Broadcast reachability.** Broadcasts fire from **wherever the run executes**
132
+ > — under `run_later`, that's the worker process. The cable backend (AnyCable,
133
+ > Solid Cable, Redis) must therefore be reachable from your workers, not just your
134
+ > web dynos. Nexo ships **no cable backend** — broadcasting composes whatever the
135
+ > host configured. Without turbo-rails, `broadcast_events` is a harmless no-op:
136
+ > the notifications still fire, so you can subscribe to them yourself.
137
+
138
+ ## Run helpers for a host UI
139
+
140
+ `Nexo::WorkflowRun` exposes query helpers so a host can build its own runs UI
141
+ without Nexo dictating controllers or views:
142
+
143
+ ```ruby
144
+ Nexo::WorkflowRun::STATUSES # => %w[pending queued running done failed interrupted suspended]
145
+
146
+ Nexo::WorkflowRun.queued # scope: status "queued"
147
+ Nexo::WorkflowRun.running # scope: status "running"
148
+ Nexo::WorkflowRun.finished # scope: status "done" or "failed"
149
+ Nexo::WorkflowRun.suspended # scope: status "suspended" (paused, awaiting resume)
150
+
151
+ run.queued? run.running? run.done? run.failed? run.suspended? # predicates
152
+
153
+ # Artifact access — content only; serving files stays your
154
+ # controller's job (Nexo ships no artifact routes/controllers):
155
+ run.artifact("digest.md") # => {"name" =>, "content" =>, "at" =>} or nil
156
+ run.artifact_content("digest.md") # => "…the body…" or nil
157
+ ```
158
+
159
+ See [`examples/rails_usage.md`](../examples/rails_usage.md) for a controller +
160
+ Turbo-page walkthrough.
161
+
162
+ ← Back to the [README](../README.md)
data/docs/sandboxes.md ADDED
@@ -0,0 +1,290 @@
1
+ # Sandboxes
2
+ The sandbox is *where* an agent's tools act — pick in-memory Virtual, host-backed Local, a throwaway Container, or a Remote you inject.
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
+ - **`Virtual`** (default) — in-memory, zero host access. `#shell` raises
27
+ `NotImplementedError` on purpose: in-memory means no command execution. That is the
28
+ safety property, not a gap.
29
+ - **`Local`** — host filesystem + shell, for trusted dev/CI. Two guards: every path is
30
+ expanded against `cwd` and must stay inside it (else `SecurityError`), and the shell sees
31
+ only `PATH`, `HOME`, `LANG` (plus explicit `env:` additions) — never the full process
32
+ environment.
33
+ - **`Container`** — run the tools inside a throwaway local container via the `docker`
34
+ (default) or Apple `container` CLI. Shell-out only, no client gem. **Hardened by default**
35
+ (no network, dropped caps, read-only rootfs + ephemeral scratch, read-only host binds);
36
+ every hardening is an explicit opt-out. See [Container sandbox](#container-sandbox--docker--apple-container) below.
37
+ - **`Remote`** — run the tools inside a remote container (E2B / Daytona / Modal / Docker /
38
+ your own) by injecting a client. Escalating to `:remote` is always an explicit choice in
39
+ your code — the default stays `:virtual`.
40
+
41
+ ## Safety refinements — safer, more legible real-FS sandboxes
42
+
43
+ Five small refinements tighten the real-filesystem sandboxes (`Local`, `Container`) and make
44
+ the execution environment more legible to the model. Each wires into an existing seam — no new
45
+ sandbox tier, no new capability, no new dependency. Every one *tightens* a default or *narrows*
46
+ scope; none widens authority silently.
47
+
48
+ - **Self-describing sandbox (`Sandbox#instructions`).** A real-FS sandbox appends one plain-text
49
+ system message describing where the agent runs, so a weak local tool-caller (e.g. Ollama/Gemma)
50
+ knows its environment. `Local` → *"You run on the host machine, cwd /path/to/repo. The real host
51
+ filesystem and shell are reachable; file access is guarded to /path/to/repo."*; `Container` →
52
+ *"You run inside a docker container (image node:22-slim), cwd /workspace, network none…"*.
53
+ `Virtual` says nothing (`#instructions` is `nil`). Ordering in the system messages: **agent
54
+ instructions → sandbox instructions → skill instructions**. Provider-neutral, injected through
55
+ the existing `with_instructions` path.
56
+
57
+ - **Capability-gated tool attach (`Sandbox#supports?`).** A `:virtual` agent no longer advertises
58
+ a `Shell` tool it can never run — `Agent#chat` attaches `Shell` only when
59
+ `@sandbox.supports?(:shell)`. `Local`/`Container` support all four capabilities; `Virtual`
60
+ supports everything but `:shell`. `ReadFile`/`WriteFile`/`Glob` are always attached.
61
+
62
+ - **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`,
63
+ `git log`) is truncated before it reaches the model, so a single command can't blow a small
64
+ context window. `Tools::Shell` wraps `stdout`/`stderr` through
65
+ `OutputTruncator.call(text, max_lines: 200, max_chars: 16_000)` — strips ANSI escapes, keeps the
66
+ **last** `max_lines` lines, appends a `…[truncated N lines]` marker, then caps at `max_chars`.
67
+ The integer `status` passes through untouched. Pure line/char truncation — **no tokenizer**;
68
+ configurable via the kwargs only (no global config, no per-agent macro).
69
+
70
+ - **Read-before-write + stale guard (real-FS only).** Within a session, the agent is blocked from
71
+ overwriting a file it never read, or one that changed underneath it. `Agent#chat` builds one
72
+ `Nexo::ReadTracker` per chat and threads it into `ReadFile` (records `(path, mtime)` on a
73
+ successful read) and `WriteFile` (enforces): overwriting an existing, un-read file returns
74
+ `{error: "read <path> before overwriting it"}`; a file whose mtime changed since the read returns
75
+ `{error: "stale: <path> changed since you read it"}`; a new file writes freely. The guard is
76
+ **real-FS only** — skipped entirely on `Virtual` (nil `mtime`) and when no tracker is passed
77
+ (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past
78
+ the stale check (read-before-write is the primary guard). Clobber-safety within a session only —
79
+ no versioning, locking, or VCS semantics.
80
+
81
+ - **Scoped `:ask` predicate (`ask_when`).** Under `:ask`, `Permissions.new(ask_when: ->(cap, detail)
82
+ { … })` scopes *which* actions actually prompt a human. When the predicate returns falsey the
83
+ action is auto-allowed **without** calling `on_ask`; truthy (or when `ask_when` is unset) falls
84
+ through to `on_ask` exactly as before. Unset = ask for everything (backward-compatible). It only
85
+ ever *narrows* what is auto-allowed from the "ask for everything" baseline — it never widens
86
+ authority, and it applies to `authorize!` only (the separate MCP ask axis is unchanged).
87
+
88
+ ```ruby
89
+ # Only prompt for writes under /protected; auto-allow everything else.
90
+ perms = Nexo::Permissions.new(
91
+ mode: :ask,
92
+ on_ask: ->(cap, detail) { ask_the_human(cap, detail) },
93
+ ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") }
94
+ )
95
+ ```
96
+
97
+ ## Remote sandbox — bring your own container
98
+
99
+ `Sandboxes::Remote` contains **zero vendor code**. It wraps any object that satisfies a
100
+ four-method contract — `read`, `write`, `exec`, `close` — and delegates the `Sandbox`
101
+ interface to it. Switching providers is swapping the injected object, nothing else:
102
+
103
+ ```ruby
104
+ sandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)
105
+ # read(path) -> client.read(path)
106
+ # write(path, content) -> client.write(path, content)
107
+ # shell(cmd, timeout:) -> client.exec(cmd, timeout:)
108
+ # glob(pattern) -> client.exec(<pattern as a positional $1, never interpolated>)
109
+ # close -> client.close
110
+ ```
111
+
112
+ Vendor SDKs rarely expose exactly `read/write/exec/close`, so adapt them with a tiny shim
113
+ object. Keep the vendor gem a **soft** dependency behind a lazy `require` that raises
114
+ `Nexo::MissingDependencyError` when it's absent:
115
+
116
+ ```ruby
117
+ # A ~10-line adapter wrapping a hypothetical vendor client to the four-method contract.
118
+ class E2BAdapter
119
+ def initialize(api_key:)
120
+ require "e2b" # soft dep — lazy, only when you actually use it
121
+ @sbx = E2B::Sandbox.create(api_key: api_key)
122
+ rescue LoadError
123
+ raise Nexo::MissingDependencyError, "E2BAdapter needs `gem \"e2b\"` in your Gemfile."
124
+ end
125
+
126
+ def read(path) = @sbx.files.read(path)
127
+ def write(path, content) = @sbx.files.write(path, content)
128
+ def exec(cmd, timeout: 30) = (r = @sbx.commands.run(cmd, timeout: timeout)
129
+ {stdout: r.stdout, stderr: r.stderr, status: r.exit_code})
130
+ def close = @sbx.kill
131
+ end
132
+
133
+ agent = Nexo::Agent.new(model: ENV.fetch("NEXO_MODEL"),
134
+ sandbox: Nexo::Sandboxes::Remote.new(client: E2BAdapter.new(api_key: ENV["E2B_API_KEY"])))
135
+ ```
136
+
137
+ Nexo ships **only** `Remote` plus this documented pattern — purpose-built
138
+ `Sandboxes::E2B` / `Sandboxes::Daytona` classes are a possible future addition, deliberately
139
+ left out of v1 because their vendor client APIs aren't pinned yet.
140
+
141
+ ## Container sandbox — Docker / Apple Container
142
+
143
+ `Sandboxes::Container` runs an agent's tools inside a **throwaway OCI container** via the
144
+ `docker` (default) or Apple `container` CLI — shell-out only through `Open3`, **no client
145
+ gem**, no Compose, no image builder. A model-driven agent never touches your host filesystem
146
+ or shell directly. Declare it with the `sandbox` macro (`image:` is required — there is no
147
+ default image):
148
+
149
+ ```ruby
150
+ class ContainerReviewer < Nexo::Agent
151
+ model ENV.fetch("NEXO_MODEL")
152
+ sandbox :docker, image: "node:22-slim",
153
+ binds: { Dir.pwd => { to: "/workspace/repo", mode: :ro } }
154
+ end
155
+ ```
156
+
157
+ The container `cwd` defaults to `/workspace` (a container path, **not** your host directory);
158
+ the host dir enters only through a `binds:` entry.
159
+
160
+ ### `runtime:` — one class, two CLIs
161
+
162
+ `sandbox :docker` (or `runtime: :docker`) shells out to `docker`; `sandbox :apple`
163
+ (`runtime: :apple`) shells out to Apple's `container` binary. The `run`/`exec` surface is
164
+ largely shared; where the CLIs diverge (networking especially) the class branches on the
165
+ runtime. **Apple `container` parity is NOT yet verified** — the flags are encoded from the
166
+ reference mapping, not confirmed against a live daemon, so every Apple flag must be verified
167
+ before trust (the networking flag in particular; see the parity table below). An unknown
168
+ runtime raises `Nexo::ConfigurationError`.
169
+
170
+ ### Hardened by default — every knob an explicit opt-out
171
+
172
+ All of the following are applied to the `run` argv by default and individually invertible:
173
+
174
+ | Concern | Default | Loosen with |
175
+ |---|---|---|
176
+ | Network | `--network none` (no egress) | `network: :bridge` / `:host` / a network name |
177
+ | Capabilities | `--cap-drop ALL` | `cap_add: %w[NET_BIND_SERVICE ...]` |
178
+ | Rootfs | `--read-only` | `readonly_rootfs: false` |
179
+ | Writable scratch | `--tmpfs <cwd>:rw` (ephemeral), only when `readonly_rootfs` | a `:rw` host bind for persistence |
180
+ | Privilege escalation | `--security-opt no-new-privileges` | (not exposed) |
181
+ | PIDs | `--pids-limit 512` (fork-bomb guard) | `pids_limit:` (`nil` omits the flag) |
182
+ | Memory / CPU | unset (host decides) | `memory:` / `cpus:` |
183
+ | User / uid | left to the image | `user:` (opt-in defense-in-depth) |
184
+ | Host binds | **read-only** (`:ro`) | per-bind `{ to:, mode: :rw }` |
185
+ | Env vars | none | `env: { "KEY" => "val" }` → one `-e KEY=val` per entry |
186
+
187
+ Bind spec forms:
188
+
189
+ ```ruby
190
+ binds: { "/host/proj" => "/workspace/proj" } # -> :ro
191
+ binds: { "/host/proj" => { to: "/workspace/proj", mode: :rw } } # -> :rw
192
+ ```
193
+
194
+ **Non-root is not forced.** The image's own uid is respected; `user:` is an opt-in. The other
195
+ hardening (`--cap-drop ALL`, `--security-opt no-new-privileges`, `--read-only`,
196
+ `--network none`) applies **regardless of uid**.
197
+
198
+ Every argument is passed to `Open3` as an **array**, never string-interpolated, so file
199
+ contents and commands can't break out of the argv. Paths are expanded against the container
200
+ `cwd` and a path that escapes raises `SecurityError`. A denied/failed **tool** op surfaces as
201
+ `{ error: ... }` through the gated tool layer; the **sandbox** itself raises only on misuse —
202
+ a missing binary (`Nexo::ConfigurationError` naming the binary), a path escape (`SecurityError`),
203
+ or a container start failure (`Nexo::Error`).
204
+
205
+ ### Lifecycle — ephemeral by default, opt-in reconnect
206
+
207
+ The container starts **lazily** on first tool use and its id is memoized.
208
+
209
+ - **Ephemeral (default, `reconnect: false`):** `close` force-removes the container
210
+ (`<bin> rm -f <id>`) and clears the memo. Idempotent — safe with nothing started or called
211
+ twice. A standalone container-backed agent tears its container down on `Agent#close` (the
212
+ agent owns the sandbox it resolved). A workflow driving one through `run_agent` shares the
213
+ run's sandbox — `run_agent` borrows it, so teardown happens once at the end of the run in
214
+ `Workflow.execute`'s `ensure` (on done, suspended, or failed), never per `run_agent` call.
215
+ - **Reconnect (`name:` + `reconnect: true`):** every container is tagged at `run` with an
216
+ **exact identity label** — `--label nexo.sandbox.id=<name>`. On start the sandbox looks up
217
+ that container by the **exact label filter** (`docker ps -aqf label=nexo.sandbox.id=<name>`),
218
+ **not** a name substring, and reuses/restarts it instead of creating a new one; `close`
219
+ leaves it running/stopped so a later sandbox with the same name reattaches. The default
220
+ container name is `nexo-<run-id>`.
221
+ - **Exact match, never a substring.** A container merely *named* `<name>x` is never
222
+ reattached — the label filter is exact. This closes the substring-reattach safety hole:
223
+ a long-lived session can never silently bind to the wrong environment.
224
+ - **Ambiguity raises, never guesses.** If more than one container carries the same identity
225
+ label, reconnect raises `Nexo::Error` (`ambiguous reconnect: <n> containers labeled
226
+ <name>`) rather than pick one. Zero matches falls through to a fresh `run`; a
227
+ daemon/CLI failure fails open to the create path.
228
+ - **Reconnect never crosses runtimes.** The lookup shells the runtime-specific binary and
229
+ each runtime keeps its own id namespace, so a `:docker` container is never reattached by an
230
+ `:apple` sandbox or vice versa.
231
+
232
+ ### Honest caveats
233
+
234
+ - **Network-none breaks installs.** `npm install` / `bundle install` need egress; with the
235
+ default `network: :none` they fail. Pass `network: :bridge` or bake dependencies into the
236
+ image.
237
+ - **Read-only rootfs needs the scratch.** With `--read-only`, only the tmpfs at `cwd` (and any
238
+ `:rw` bind) is writable, and the tmpfs is **ephemeral** — lost on `close`. Persist via a
239
+ `:rw` bind (this is where staged files and artifacts land).
240
+ - **Non-root is recommended, not forced.** The default hardening holds regardless of uid; set
241
+ `user:` for defense-in-depth.
242
+ - **Apple `container` parity is NOT yet verified** — especially networking. Every Apple flag
243
+ in the parity table below is UNVERIFIED; confirm the flag/subcommand against Apple's CLI
244
+ before trusting the `:apple` runtime in production.
245
+ - **Reconnect is Docker-only today.** `reconnect: true` combined with `runtime: :apple` raises
246
+ `Nexo::ConfigurationError` at the point reconnect would run. Apple's `container` CLI has no
247
+ **live-verified** exact `label=` filter, and a name-substring match is unsafe (it can attach
248
+ the wrong container), so reconnect refuses rather than risk it. Use `runtime: :docker` for
249
+ reconnect, or run an ephemeral `:apple` sandbox (`reconnect: false`). If a future Group 0 run
250
+ confirms an exact-identity mechanism on Apple, wire that verified mechanism instead of raising.
251
+
252
+ #### Apple `container` parity table (Group 0)
253
+
254
+ This table is filled **only** from live Group 0 runs against a real Apple `container` runtime —
255
+ never from assumption. Apple's runtime is macOS-only and is **not** present in CI (or in the
256
+ environment this spec shipped from), so the divergences below are **UNVERIFIED** and marked as
257
+ such. Until a maintainer runs Group 0 on Apple hardware and records the results here, treat the
258
+ `:apple` runtime as **Docker-flag-shaped but unconfirmed** and reconnect as unsupported (it
259
+ raises). Do not silently trust any Apple hardening flag until its row reads `same`.
260
+
261
+ | Subcommand / flag | Docker | Apple `container` | Divergence → action |
262
+ |---|---|---|---|
263
+ | `run -d` | ✓ | _unverified_ | verify before trust |
264
+ | `exec -i` | ✓ | _unverified_ | verify before trust |
265
+ | `ps -aqf` | ✓ | _unverified_ | verify before trust |
266
+ | `start <id>` | ✓ | _unverified_ | verify before trust |
267
+ | `rm -f <id>` | ✓ | _unverified_ | verify before trust |
268
+ | `--label` / `label=` filter | ✓ (exact) | _unverified_ | **reconnect raises `ConfigurationError` until confirmed** |
269
+ | `--network` | ✓ | _unverified_ | expected to diverge — verify before trust |
270
+ | `--tmpfs` | ✓ | _unverified_ | verify before trust |
271
+ | `--read-only` | ✓ | _unverified_ | verify before trust |
272
+ | `--cap-drop` / `--cap-add` | ✓ | _unverified_ | verify before trust |
273
+ | `--security-opt no-new-privileges` | ✓ | _unverified_ | verify before trust |
274
+ | `--pids-limit` | ✓ | _unverified_ | verify before trust |
275
+ | `-w` / `-v` / `-e` / `--user` / `--memory` / `--cpus` | ✓ | _unverified_ | verify before trust |
276
+
277
+ Keep-alive (Docker, Group 0): `tail -f /dev/null` holds Alpine / Debian-slim / ruby-slim open —
278
+ **to be confirmed on the maintainer's live daemon** (busybox-portable by construction; `sleep
279
+ infinity` is not and was replaced for exactly this reason).
280
+
281
+ > **Reduced-guarantee posture.** Where a hardening flag turns out to have no Apple equivalent,
282
+ > the container stays functional but the guarantee is **reduced** — and that reduction is
283
+ > documented here, never silently dropped. Until the table above is filled from a live run, a
284
+ > maintainer must not assume any given `:apple` hardening flag is honored.
285
+
286
+ Live container runs are exercised by `NEXO_LIVE`-gated smoke
287
+ (`test/sandboxes/container_live_test.rb`); the core suite asserts argv construction with no
288
+ daemon. See `examples/container_review.rb` for a runnable end-to-end example.
289
+
290
+ ← Back to the [README](../README.md)
data/docs/sessions.md ADDED
@@ -0,0 +1,99 @@
1
+ # Sessions — continuing, addressable memory
2
+ A session is a remembering instance of an agent, addressed by `(agent_name, instance_id)`, that accumulates context across invocations.
3
+
4
+ A `Workflow` is fire-and-finish. A **`Nexo::Session`** is the other half: a
5
+ *remembering* instance of an agent, addressed by `(agent_name, instance_id)`, that
6
+ accumulates context across separate invocations.
7
+
8
+ ```ruby
9
+ Nexo::Session.resume(Assistant, "user-42").prompt("My name is Mac.")
10
+ # ... a later request, job, or process ...
11
+ Nexo::Session.resume(Assistant, "user-42").prompt("What is my name?")
12
+ # => "...Mac..." — the persisted thread carried the earlier turn
13
+ ```
14
+
15
+ `resume` finds-or-creates the one thread for that pair (the pair is unique — one
16
+ thread per pair) and returns a session whose `#prompt` appends to it. `#prompt`
17
+ takes the same `max_turns:` and `&on_event` block as `Agent#prompt`, yielding the
18
+ same `(:tool_call | :tool_result | :done, payload)` events. Extra keywords are
19
+ forwarded to the agent constructor (e.g. `Nexo::Session.resume(Assistant, "u1", cwd: repo)`).
20
+
21
+ **A session adds only memory + addressability — never authority.** Its sandbox,
22
+ permissions (default `:read_only`), skills, MCP servers, and `fetch_allow` are
23
+ exactly the agent's; opening or resuming a session never widens what the agent can
24
+ do. The persisted record supplies the *thread*; the agent supplies the
25
+ *tools/skills/instructions* onto it.
26
+
27
+ ## Composition — `acts_as_chat`, owned by the host
28
+
29
+ Message persistence is **RubyLLM's `acts_as_chat`** — Nexo defines no message table
30
+ and serializes nothing. The host Rails app owns all four persistence models (`Chat`,
31
+ `Message`, `ToolCall`, `Model`), generated by ruby_llm's own installer:
32
+
33
+ ```bash
34
+ rails g ruby_llm:install # generates the Chat/Message/ToolCall/Model models + migrations
35
+ ```
36
+
37
+ **One setup step beyond the installer:** the session chat model must be addressable,
38
+ so add two columns and a **unique composite index** to the generated `chats` table:
39
+
40
+ ```ruby
41
+ class AddNexoAddressingToChats < ActiveRecord::Migration[8.0]
42
+ def change
43
+ add_column :chats, :agent_name, :string
44
+ add_column :chats, :instance_id, :string
45
+ add_index :chats, [:agent_name, :instance_id], unique: true
46
+ end
47
+ end
48
+ ```
49
+
50
+ Tell Nexo which model hosts sessions (only if it isn't ruby_llm's default `Chat`):
51
+
52
+ ```ruby
53
+ Nexo.configure { |c| c.session_chat_model = "Chat" } # default; a String class name,
54
+ # constantized lazily at resume time
55
+ ```
56
+
57
+ ## Rails-only durability — plain Ruby is in-memory
58
+
59
+ Durable sessions require ActiveRecord. Backend selection guards on
60
+ `defined?(::ActiveRecord::Base)` **and** the host chat model being defined (mirroring
61
+ how `RunStore` only uses the AR store when `Nexo::WorkflowRun` is present):
62
+
63
+ - **Rails (durable):** the thread is a `chats` row; `acts_as_chat`'s callbacks persist
64
+ every message. It survives across requests, jobs, and process restarts.
65
+ - **Plain Ruby (in-memory):** a process-wide store holds a live `RubyLLM::Chat` per
66
+ pair. The thread lives only for the process — a fresh process starts empty. This is
67
+ documented, non-durable behavior, not a bug.
68
+
69
+ Re-applying the agent's instructions on every resume is **idempotent**: `acts_as_chat`
70
+ stores instructions as `role: :system` messages, and Nexo re-applies them with
71
+ `with_instructions` (replace semantics) so the stored thread keeps exactly one copy
72
+ across resumes rather than accumulating duplicate system messages. The runtime tools
73
+ (the four sandbox tools + MCP + fetch) are re-attached each resume — they are not
74
+ persisted, and that is correct.
75
+
76
+ ## Retention, PII, and `#close` — the honest trade-off
77
+
78
+ A continuing session is a persistence surface, and that has real costs:
79
+
80
+ - **Stored messages persist until you delete them, and may contain sensitive data.**
81
+ A long-lived thread accumulates whatever the user and tools put into it. Nexo does
82
+ **not** redact, expire, or GC anything — retention is your responsibility. Treat the
83
+ `chats`/`messages` tables as PII stores and apply your own retention policy.
84
+ - **Close sessions that hold resources.** If the agent declares MCP servers (stdio/SSE)
85
+ or fetch, a session holds live subprocesses/sockets. Call `#close` when done — it
86
+ delegates to `Agent#close`, tearing those down (idempotent, safe with nothing held):
87
+
88
+ ```ruby
89
+ session = Nexo::Session.resume(InboxAssistant, "user-42")
90
+ begin
91
+ session.prompt("Summarize my unread threads.")
92
+ ensure
93
+ session.close # releases the agent's MCP/stdio/SSE connections
94
+ end
95
+ ```
96
+
97
+ See `examples/chat_session.rb` for a runnable, env-gated two-prompt resume.
98
+
99
+ ← Back to the [README](../README.md)
data/docs/skills.md ADDED
@@ -0,0 +1,68 @@
1
+ # Skills
2
+ A skill is a `SKILL.md` package that teaches the model *how you want a task done* — reasoning, layered on with one macro.
3
+
4
+ A **skill** is a `SKILL.md` package — frontmatter plus instructions — that teaches the
5
+ model *how you want a task done*. Skills guide **reasoning**; the sandbox-backed tools
6
+ above perform **execution**. Nexo does not implement skill loading; it composes the
7
+ [`ruby_llm-skills`](https://github.com/kieranklaassen/ruby_llm-skills) gem so you attach a
8
+ skill with one macro and no loader setup.
9
+
10
+ Drop a package under `app/skills/` (or scaffold one — see below):
11
+
12
+ ```
13
+ app/skills/
14
+ └── triage/
15
+ ├── SKILL.md # frontmatter (name, description) + process steps
16
+ └── references/ # supporting docs the skill can cite
17
+ ```
18
+
19
+ ```markdown
20
+ ---
21
+ name: triage
22
+ description: Triage incoming issues by severity and route them to the right owner.
23
+ ---
24
+
25
+ # Triage
26
+
27
+ ## Process
28
+ 1. Classify the issue severity.
29
+ 2. Route to the right owner.
30
+ ```
31
+
32
+ Reference it with the `skills` macro — its instructions are layered on top of the agent's
33
+ own, in declaration order:
34
+
35
+ ```ruby
36
+ require "nexo"
37
+
38
+ class TriageAgent < Nexo::Agent
39
+ model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
40
+ skills :triage # one macro, no loader wiring
41
+ end
42
+
43
+ TriageAgent.new.chat # chat built with the base sandbox tools + the skill's instructions
44
+ ```
45
+
46
+ Scaffold a new skill package with the generator (creates a valid `SKILL.md` plus a
47
+ `references/` directory):
48
+
49
+ ```sh
50
+ rails g nexo:skill triage
51
+ # create app/skills/triage/references/.keep
52
+ # create app/skills/triage/SKILL.md
53
+ ```
54
+
55
+ `ruby_llm-skills` is an **optional** dependency — required lazily only when you use a skill.
56
+ Without it installed, `require "nexo"` still loads; touching a skill raises a clear
57
+ `Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-skills"`. Referencing a
58
+ skill that does not exist raises `Nexo::Error` naming the missing `SKILL.md` path.
59
+
60
+ ## Skill tools stay gated
61
+
62
+ A skill contributes **instructions only**. A loaded skill ships no independent tools, and
63
+ Nexo deliberately does not attach `ruby_llm-skills`' progressive-disclosure tool (which
64
+ reads files outside the sandbox). The model reaches a skill's `references/`/`scripts/`
65
+ files through Nexo's own permission-gated, sandbox-backed tools — so **attaching a skill
66
+ never widens what an agent can do** beyond its configured sandbox/permission mode.
67
+
68
+ ← Back to the [README](../README.md)
data/docs/tools.md ADDED
@@ -0,0 +1,38 @@
1
+ # Tools
2
+ Nexo attaches four sandbox-backed tools — `ReadFile`, `WriteFile`, `Shell`, and `Glob` — each gated by the [sandbox](sandboxes.md) and [permission](permissions.md) seams.
3
+
4
+ Which tools attach depends on what the sandbox supports:
5
+
6
+ - **Capability-gated tool attach (`Sandbox#supports?`).** A `:virtual` agent no longer advertises
7
+ a `Shell` tool it can never run — `Agent#chat` attaches `Shell` only when
8
+ `@sandbox.supports?(:shell)`. `Local`/`Container` support all four capabilities; `Virtual`
9
+ supports everything but `:shell`. `ReadFile`/`WriteFile`/`Glob` are always attached.
10
+
11
+ `Shell` truncates unbounded command output before it reaches the model:
12
+
13
+ - **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`,
14
+ `git log`) is truncated before it reaches the model, so a single command can't blow a small
15
+ context window. `Tools::Shell` wraps `stdout`/`stderr` through
16
+ `OutputTruncator.call(text, max_lines: 200, max_chars: 16_000)` — strips ANSI escapes, keeps the
17
+ **last** `max_lines` lines, appends a `…[truncated N lines]` marker, then caps at `max_chars`.
18
+ The integer `status` passes through untouched. Pure line/char truncation — **no tokenizer**;
19
+ configurable via the kwargs only (no global config, no per-agent macro).
20
+
21
+ `WriteFile` enforces a read-before-write guard so the agent can't clobber files blindly:
22
+
23
+ - **Read-before-write + stale guard (real-FS only).** Within a session, the agent is blocked from
24
+ overwriting a file it never read, or one that changed underneath it. `Agent#chat` builds one
25
+ `Nexo::ReadTracker` per chat and threads it into `ReadFile` (records `(path, mtime)` on a
26
+ successful read) and `WriteFile` (enforces): overwriting an existing, un-read file returns
27
+ `{error: "read <path> before overwriting it"}`; a file whose mtime changed since the read returns
28
+ `{error: "stale: <path> changed since you read it"}`; a new file writes freely. The guard is
29
+ **real-FS only** — skipped entirely on `Virtual` (nil `mtime`) and when no tracker is passed
30
+ (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past
31
+ the stale check (read-before-write is the primary guard). Clobber-safety within a session only —
32
+ no versioning, locking, or VCS semantics.
33
+
34
+ These are sandbox refinements as much as tool behavior — see [Sandboxes](sandboxes.md) for the
35
+ guard details behind each capability. The `fetch` tool for reading the web lives in
36
+ [Web](web.md), gated by its own `:fetch` capability plus a host allow-list.
37
+
38
+ ← Back to the [README](../README.md)