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
@@ -0,0 +1,103 @@
1
+ # Getting started
2
+ Install Nexo, configure the harness, and build your first agent.
3
+
4
+ ## Installation
5
+
6
+ Add to your Gemfile:
7
+
8
+ ```ruby
9
+ gem "nexo_ai"
10
+ ```
11
+
12
+ Or install directly:
13
+
14
+ ```sh
15
+ gem install nexo_ai
16
+ ```
17
+
18
+ In a Rails app, run the install generator to create the conventional layout and an initializer:
19
+
20
+ ```sh
21
+ rails g nexo:install
22
+ ```
23
+
24
+ ```
25
+ create app/agents/.keep
26
+ create app/workflows/.keep
27
+ create app/skills/.keep
28
+ create config/initializers/nexo.rb
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ Configure the harness in one place with `Nexo.configure`. Defaults are safe and
34
+ provider-neutral — there is intentionally no hardcoded model:
35
+
36
+ ```ruby
37
+ Nexo.configure do |config|
38
+ config.default_model = ENV["NEXO_MODEL"] # provider-neutral: no default
39
+ config.default_sandbox = :virtual # :virtual | :local | :docker | :apple | a Hash | a Sandbox
40
+ config.default_permissions = :read_only # :read_only | :auto | :ask | :approve
41
+ config.skills_path = "app/skills"
42
+ config.concurrency = :threaded # :threaded | :async (opt-in fiber offload)
43
+ config.max_in_flight = 8 # Nexo.concurrent fan-out bound
44
+ config.buffer_workflow_events = false # buffer + flush-once workflow events
45
+ end
46
+
47
+ Nexo.config.default_sandbox # => :virtual
48
+ Nexo.config.default_permissions # => :read_only
49
+ Nexo.config.default_model # => nil unless set
50
+ ```
51
+
52
+ `require "nexo"` works in plain Ruby with no Rails loaded.
53
+
54
+ ## Build an agent in five lines
55
+
56
+ Subclass `Nexo::Agent`, declare the pieces with class macros, and call `#prompt`. No
57
+ sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific —
58
+ the agent runs on any `ruby_llm`-supported model (set `NEXO_MODEL`, e.g. a local
59
+ `gemma3:12b` via Ollama, or a hosted model):
60
+
61
+ ```ruby
62
+ require "nexo"
63
+
64
+ class CodeReviewer < Nexo::Agent
65
+ model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
66
+ sandbox :local
67
+ permissions :read_only
68
+
69
+ instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
70
+ end
71
+
72
+ CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")
73
+ ```
74
+
75
+ Defaults are safe: an agent with no `sandbox`/`permissions` declared gets the in-memory
76
+ `:virtual` sandbox and `:read_only` permissions, so an untrusted model has zero host
77
+ access until you explicitly opt in.
78
+
79
+ ## Unregistered models — local tags, self-hosted, brand-new releases
80
+
81
+ `ruby_llm` normally validates a model id against its bundled `models.json` registry
82
+ and infers the provider from it. A local Ollama tag (`gemma3:12b`), a self-hosted
83
+ build, or a model newer than the installed registry isn't listed there — so declare
84
+ the `provider` explicitly and set `assume_model_exists` to skip the registry lookup:
85
+
86
+ ```ruby
87
+ class LocalReviewer < Nexo::Agent
88
+ model "gemma3:12b"
89
+ provider :ollama # required once the registry lookup is skipped
90
+ assume_model_exists true # opt out of models.json validation
91
+
92
+ instructions "You are a careful code reviewer."
93
+ end
94
+ ```
95
+
96
+ Both are class macros with the same reader/writer convention as `model`. `provider`
97
+ is passed straight through to `RubyLLM.chat`; `assume_model_exists` defaults to
98
+ `false` (registry validation on). Setting `assume_model_exists` **without** a
99
+ `provider` raises `Nexo::ConfigurationError` — `ruby_llm` can't infer a provider once
100
+ the lookup is skipped. See [`examples/code_reviewer.rb`](../examples/code_reviewer.rb)
101
+ for a runnable Ollama example.
102
+
103
+ ← Back to the [README](../README.md)
data/docs/loops.md ADDED
@@ -0,0 +1,93 @@
1
+ # Loop backends — swap the engine, not the agent
2
+ The loop is the engine that drives one prompt to completion; swap it by constructor injection without touching the agent class.
3
+
4
+ The **loop** is the engine that drives one prompt to completion. Swapping it is constructor
5
+ injection (`loop:`) — the agent class never changes. Two backends ship:
6
+
7
+ | | `Loops::RubyLLM` (default) | `Loops::AgentSDK` (opt-in) |
8
+ | ------------------ | ---------------------------------- | ------------------------------------- |
9
+ | Provider neutral | ✅ any `ruby_llm` model | ❌ Anthropic-oriented |
10
+ | Tool source | your sandbox-backed tools | the SDK's own built-in/host tools |
11
+ | Turn cap | observability only (see caveat) | native `max_turns` hard cap |
12
+ | Execution location | your sandbox (virtual/local/remote)| the host process |
13
+
14
+ The whole point: **same agent code, swapped backends**. Both examples are model-agnostic
15
+ (`ENV.fetch("NEXO_MODEL")` — never a hardcoded `"claude-…"`):
16
+
17
+ ```ruby
18
+ # Claude fast path — AgentSDK's own loop + host tools + native max_turns
19
+ claude = Nexo::Agent.new(
20
+ model: ENV.fetch("NEXO_MODEL"),
21
+ sandbox: Nexo::Sandboxes::Local.new(cwd: "/srv/checkout"),
22
+ permissions: Nexo::Permissions.new(mode: :auto),
23
+ loop: Nexo::Loops::AgentSDK.new
24
+ )
25
+
26
+ # Any-provider path — your sandbox, your tools, human-in-the-loop
27
+ gpt = Nexo::Agent.new(
28
+ model: ENV.fetch("NEXO_MODEL"), # gpt-5.5, gemini, gemma3:12b via Ollama…
29
+ sandbox: Nexo::Sandboxes::Remote.new(client: my_container_client),
30
+ permissions: Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
31
+ SlackApproval.request!(capability: cap, detail: detail)
32
+ }),
33
+ loop: Nexo::Loops::RubyLLM.new
34
+ )
35
+ ```
36
+
37
+ `Loops::AgentSDK` wraps `RubyLLM::AgentSDK.query` and requires the **optional**
38
+ `ruby_llm-agent_sdk` gem (lazy `require`; a clear `Nexo::MissingDependencyError` if it's
39
+ absent). It maps Nexo's permission modes onto the SDK's own vocabulary:
40
+
41
+ | Nexo mode | AgentSDK `permission_mode` |
42
+ | ------------ | -------------------------- |
43
+ | `:read_only` | `:default` |
44
+ | `:auto` | `:bypass_permissions` |
45
+ | `:ask` | `:default` (human gating stays in Nexo's own `on_ask` path, not delegated to the SDK) |
46
+ | `:approve` | `:default` (durable approval stays in Nexo's own gate; any unmapped mode also falls back to `:default`) |
47
+
48
+ ## The turn-cap caveat (read before running untrusted/expensive workloads)
49
+
50
+ `ruby_llm` runs the whole tool loop inside `ask`, so `Loops::RubyLLM` has **no clean public
51
+ hard "stop after N turns" halt** — `before_tool_call` gives turn-count *observability*, not a
52
+ hard stop. (Confirmed: `ruby_llm` 1.16.0's `Chat` exposes no public max-turns/max-iterations
53
+ setting.) Your three real options:
54
+
55
+ (a) use `Loops::AgentSDK` (native `max_turns`) for untrusted/expensive workloads;
56
+ (b) have a tool return `{ error: "turn limit reached, stop and summarize" }` once a turn
57
+ counter trips;
58
+ (c) check whether the installed `ruby_llm` exposes a max-iterations config (in 1.16.0 it
59
+ does not).
60
+
61
+ Do **not** ship `Loops::RubyLLM` for untrusted workloads claiming a hard cap that isn't
62
+ proven.
63
+
64
+ ## Verified vs assumed
65
+
66
+ Built against **`ruby_llm` 1.16** and **`ruby_llm-test` 0.2**. The tool body method is
67
+ `#execute`, tools attach with `chat.with_tools(*instances)`, and instructions set with
68
+ `chat.with_instructions`. `Open3.capture3` has no `timeout:` keyword on the target Ruby, so
69
+ `Local#shell` bounds the command with `Timeout.timeout`. These may differ on other
70
+ `ruby_llm` versions.
71
+
72
+ `Loops::RubyLLM`'s turn-count observability uses `RubyLLM::Chat#before_tool_call` /
73
+ `#after_tool_result`, confirmed present on `ruby_llm` 1.16.0 and guarded with `respond_to?`
74
+ so a version lacking them degrades to no observability rather than crashing.
75
+ `Loops::AgentSDK` targets `RubyLLM::AgentSDK.query`; `ruby_llm-agent_sdk` is **not** a
76
+ dependency of this release, so that signature is **assumed** (per the gem's README) and
77
+ verified-on-install — confirm it the moment you add the gem.
78
+
79
+ ## Live smoke (optional)
80
+
81
+ The core suite is fully offline and deterministic (models stubbed with `ruby_llm-test`).
82
+ A real end-to-end check is opt-in and env-gated — small local models like Gemma have weak
83
+ tool-calling, so it may be flaky and is never a gating test:
84
+
85
+ ```sh
86
+ ollama serve &
87
+ NEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_test.rb
88
+ ```
89
+
90
+ If Gemma's tool-calling proves too weak, point `NEXO_MODEL` at a stronger model — the gem
91
+ stays provider-neutral; only the smoke target changes.
92
+
93
+ ← Back to the [README](../README.md)
data/docs/mcp.md ADDED
@@ -0,0 +1,144 @@
1
+ # MCP data sources
2
+ Attach one or more MCP servers with a single `mcp` macro — every call gated by a second, fail-closed permission axis.
3
+
4
+ An **MCP server** exposes tools to a model over the [Model Context
5
+ Protocol](https://modelcontextprotocol.io) — Gmail, a filesystem, a fetch endpoint,
6
+ Drive, and so on. Nexo does not implement MCP; it composes the
7
+ [`ruby_llm-mcp`](https://github.com/patvice/ruby_llm-mcp) gem so you attach one or more
8
+ servers with a single `mcp` macro and no client wiring. Because a server is reached
9
+ *through the protocol* (never a vendor SDK), the behavior is identical on Anthropic, a
10
+ local model, or anything else `ruby_llm` supports.
11
+
12
+ ```ruby
13
+ require "nexo"
14
+
15
+ class InboxDigest < Nexo::Agent
16
+ model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
17
+ permissions :read_only
18
+ mcp :gmail, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-gmail]
19
+ mcp :fs, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-filesystem /data]
20
+ mcp :fetch, transport: :sse, url: "http://localhost:8080/sse"
21
+ mcp_allow %w[search_threads get_thread]
22
+ end
23
+ ```
24
+
25
+ Each `mcp` line accumulates a server declaration. `name` and `transport` map onto the
26
+ client's `name:`/`transport_type:`; every other keyword is passed through verbatim as the
27
+ server's `config:` — `command:`/`args:` for `:stdio`, `url:` for `:sse`. The server's tools
28
+ are attached to the chat after the sandbox tools and skills, and fire the same
29
+ `before_tool_call`/`after_tool_result` observability callbacks, so MCP calls appear in a
30
+ run's event log automatically.
31
+
32
+ ## Every MCP tool call is gated — and fails closed
33
+
34
+ MCP tools obey a **second permission axis**, separate from the sandbox capability axis,
35
+ because an MCP tool executes inside the server, outside the sandbox. `mcp_allow` is the
36
+ exact-match allow-list threaded into the agent's permissions:
37
+
38
+ | Mode | MCP tool behavior |
39
+ |---|---|
40
+ | `:read_only` (default) | allow **only** tool names listed in `mcp_allow`; everything else is denied |
41
+ | `:ask` | call `on_ask.call(:mcp, {tool:, args:})`; a truthy return allows, else deny |
42
+ | `:approve` | names in `mcp_allow` are pre-approved; any other tool needs a human decision — undecided **suspends the run** (`Nexo::ApprovalRequired`), `approved: true` allows, `approved: false` denies (the durable sibling of `:ask`) |
43
+ | `:auto` | allow every MCP tool |
44
+
45
+ `mcp_allow` defaults to `[]`, so attaching a powerful server under `:read_only` with no
46
+ allow-list denies **every** tool — a misconfigured agent fails closed, not open. A denied
47
+ call returns `{ error: … }` to the model (recoverable) and never raises into the loop —
48
+ identical to the sandbox tools. Escalation (`:auto`, a populated `mcp_allow`, or `:ask`
49
+ with a real `on_ask`) is always explicit in your code. Matching is exact tool-name only —
50
+ no globs or regexes.
51
+
52
+ **Two caveats — read before attaching a server:**
53
+
54
+ 1. **MCP tool *effects* are not sandboxed.** The gate covers the authority to *invoke* a
55
+ tool; the tool then runs in the MCP server, outside Nexo's sandbox. Nexo cannot
56
+ constrain what that server does with a call it is authorized to make — attaching a write
57
+ server and allowing a write tool means real writes happen. Gate deliberately, and prefer
58
+ `:read_only` with a tight `mcp_allow`.
59
+ 2. **Connection lifecycle.** Clients are built once and **memoized on the agent instance**,
60
+ reused across prompts (the `ruby_llm-mcp` client connects on construction and stays
61
+ connected). A long-lived agent holding stdio/SSE servers should call `Agent#close` when
62
+ done to tear the connections down:
63
+
64
+ ```ruby
65
+ agent = InboxDigest.new
66
+ agent.prompt("Summarize invoices from this week")
67
+ agent.prompt("Any follow-ups needed?") # reuses the same live MCP connections
68
+ agent.close # stops every attached server
69
+ ```
70
+
71
+ ## HTTP-family servers + an OAuth `token:` provider
72
+
73
+ Beyond `:stdio`, Nexo attaches a server over any HTTP-family transport `ruby_llm-mcp`
74
+ supports — `transport: :http`, `:sse`, or `:streamable`. For an OAuth-authenticated
75
+ hosted server (Gmail, Drive, …) add a `token:` — a static bearer `String`, or a
76
+ **callable** re-read close to connection time. Nexo resolves it and injects an
77
+ `Authorization: Bearer <token>` header per connection:
78
+
79
+ ```ruby
80
+ class InboxTriageHTTP < Nexo::Agent
81
+ model ENV.fetch("NEXO_MODEL")
82
+ permissions :read_only
83
+
84
+ # Hosted Gmail MCP server over HTTP; the host supplies the OAuth access token.
85
+ mcp :gmail,
86
+ transport: :http,
87
+ url: ENV.fetch("GMAIL_MCP_URL"),
88
+ token: -> { Current.user.gmail_access_token } # re-read at connection time
89
+
90
+ # READ tools only — the unchanged gate denies send/trash/modify.
91
+ mcp_allow %w[search_threads get_thread list_messages get_message list_labels]
92
+ end
93
+ ```
94
+
95
+ A static token (`token: ENV.fetch("GMAIL_TOKEN")`) is equally valid. Under the hood
96
+ Nexo strips `token:` and hands off:
97
+
98
+ ```ruby
99
+ RubyLLM::MCP.client(
100
+ name: "gmail", transport_type: :http,
101
+ config: { url: "https://…", headers: { "Authorization" => "Bearer <resolved>" } }
102
+ )
103
+ ```
104
+
105
+ Any other `headers:` you pass are preserved; Nexo's `Authorization` wins. With no
106
+ `token:`, `config:` passes through byte-for-byte (no `headers` key) — the `:stdio` path
107
+ is untouched.
108
+
109
+ **Nexo does not own the OAuth flow.** It performs no authorization-code exchange, no
110
+ token refresh, and keeps no token store — that is your app or an OAuth library. Nexo's
111
+ only job is to call the provider, inject the header, and hand off. The token is never
112
+ logged, persisted, placed in a URL/query string, or emitted in an event (the event path
113
+ carries only tool `name`/`args` and return values — never connection config).
114
+
115
+ **Refresh / reconnect caveat.** `ruby_llm-mcp`'s HTTP-family transports snapshot the
116
+ `headers` hash **at construction** — there is no per-request header callback for a plain
117
+ `headers` Hash. A callable `token:` is therefore resolved **once**, when the client is
118
+ built, and the client is memoized on the agent instance across prompts. So when a token
119
+ **rotates**, tear the connection down and reconnect to pick up the new value:
120
+
121
+ ```ruby
122
+ agent.close # stops the memoized MCP client
123
+ agent.prompt("…") # a fresh prompt rebuilds the client → token: re-resolved
124
+ ```
125
+
126
+ **The gate is unchanged.** An HTTP OAuth server's tools are gated exactly like `:stdio`
127
+ tools: denied under `:read_only` until the exact name is in `mcp_allow` (default `[]` ⇒
128
+ deny-all). Attaching an authenticated server adds no permission surface.
129
+
130
+ **Two honest caveats — read before attaching a token:**
131
+
132
+ 1. **Refresh may require a reconnect.** Because headers are construction-only,
133
+ a rotated token needs `agent.close` + a fresh prompt (above), not just a new proc
134
+ return. A static token stays constant for the client's life.
135
+ 2. **The token is a live credential.** Even gated, an authorized MCP call runs its effect
136
+ server-side — a leaked bearer is a real compromise. Nexo keeps it out of logs, events,
137
+ persisted `WorkflowRun` records, and URLs; your host code must do the same. Nexo does
138
+ not police `ruby_llm-mcp`'s own internal logging of headers — that boundary is yours.
139
+
140
+ `ruby_llm-mcp` is an **optional** dependency — required lazily only when you attach a
141
+ server. Without it installed, `require "nexo"` still loads; building a server raises a clear
142
+ `Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-mcp"`.
143
+
144
+ ← Back to the [README](../README.md)
@@ -0,0 +1,47 @@
1
+ # Permissions
2
+ The permission mode is *what* an agent's tools may do — read-only by default, with `:auto`, `:ask`, and `:approve` escalations; the separate MCP tool gate lives in [MCP](mcp.md).
3
+
4
+ Two seams compose the execution environment. The **sandbox** is *where* tools act; the
5
+ **permission mode** is *what* they may do. A denied capability returns `{ error: ... }`
6
+ and the agent loop continues — it does not raise. A path that escapes the workspace raises
7
+ `SecurityError`; an agent built with no resolvable model raises `Nexo::ConfigurationError`.
8
+
9
+ | | `:read` | `:glob` | `:write` | `:shell` | `:fetch` | `:search` |
10
+ | ------------------------ | ------- | ------- | ------------------- | --------------------------------- | ---------- | ---------- |
11
+ | `:read_only` (default) | ✅ | ✅ | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` |
12
+ | `:auto` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
13
+ | `:ask` | ✅ | ✅ | per `on_ask` | per `on_ask` | per `on_ask` | per `on_ask` |
14
+ | `:approve` | ✅ | ✅ | per `decision` | per `decision` | per `decision` | per `decision` |
15
+ | `Virtual` sandbox | ✅ | ✅ | ✅ (in-memory) | ❌ `NotImplementedError`→`{error}` | ✅ † | ✅ † |
16
+ | `Local` sandbox | ✅ (guarded) | ✅ | ✅ (guarded) | ✅ (narrowed ENV) | ✅ † | ✅ † |
17
+ | `Container` sandbox | ✅ (guarded) | ✅ | ✅ (guarded, scratch) | ✅ (in container) | ✅ † | ✅ † |
18
+
19
+ `:read`/`:glob` are auto-allowed under **every** mode (they sit in the default
20
+ `allow` list), so `:ask`/`:approve` never prompt for them — only
21
+ `:write`/`:shell`/`:fetch`/`:search` reach the gate. **†** `:fetch` and `:search`
22
+ run in the **host process** (stdlib `net/http` / a host-injected backend), so **no
23
+ sandbox constrains them** — not even a `--network none` container. They are bounded
24
+ only by the capability gate above plus `fetch_allow` / the injected backend.
25
+
26
+ ## Human-gated writes (`:ask`)
27
+
28
+ `:ask` mode defers every write/shell action to your callback. Build a `Permissions` with
29
+ an `on_ask` hook and pass it in:
30
+
31
+ ```ruby
32
+ gate = Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
33
+ $stdout.print("Allow #{cap} #{detail}? [y/N] "); $stdin.gets.strip == "y"
34
+ })
35
+
36
+ class Editor < Nexo::Agent
37
+ model ENV.fetch("NEXO_MODEL")
38
+ sandbox :local
39
+ end
40
+
41
+ Editor.new(cwd: ".", permissions: gate).prompt("Fix the typo in README.md")
42
+ ```
43
+
44
+ (The bare `:ask` symbol resolves to `Permissions.new(mode: :ask)` with **no** callback, so
45
+ writes/shell are denied — pass a pre-built `Permissions` with `on_ask` for a real gate.)
46
+
47
+ ← Back to the [README](../README.md)
data/docs/rails.md ADDED
@@ -0,0 +1,129 @@
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
+ Nexo ships **no queue and no scheduler** — ActiveJob uses whatever adapter your
39
+ app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob
40
+ / `whenever`) stays the host's. Without ActiveJob, `run_later` raises
41
+ `Nexo::MissingDependencyError` — use `run` for synchronous execution.
42
+
43
+ > **⚠️ Needs a shared store.** For a worker in **another process** to find the run,
44
+ > use the ActiveRecord store (`rails g nexo:workflows`) with a real adapter — the
45
+ > run must live in the database, not in a per-process memory store. The in-memory
46
+ > store only works under the `:inline`/`:test` adapters, where the job runs
47
+ > in-process on enqueue.
48
+ >
49
+ > **⚠️ No automatic crash recovery / no automatic retries.** A crashed or retried
50
+ > job re-runs `#call` **from scratch** — Nexo adds no `retry_on` (configure retries
51
+ > in your host job if you want them). Pair with `reconcile_interrupted!` (above) to
52
+ > sweep runs orphaned in `"running"`. For an *intentional* pause-and-continue (a
53
+ > human approval, an external callback), see **[Durable workflows](durable-workflows.md)** —
54
+ > `checkpoint` skips already-paid-for work when a run resumes.
55
+
56
+ ## Live progress — notifications and opt-in Turbo
57
+
58
+ Every run **broadcasts as it happens** over `ActiveSupport::Notifications`,
59
+ decoupled from persistence (events still buffer/persist separately). Two
60
+ notifications fire (a no-op with no ActiveSupport, so the plain-Ruby core stays
61
+ Rails-free):
62
+
63
+ - `nexo.workflow.event` — one per `emit`, payload `{ run_id:, event: }` (the event
64
+ is the string-keyed `{"type" =>, "data" =>, "at" =>}` hash). Fires **live**, even
65
+ when event *persistence* is buffered.
66
+ - `nexo.workflow.status` — on each status transition, payload `{ run_id:, status: }`.
67
+
68
+ The payloads carry only what `emit`/the run already hold — **no payload or
69
+ credential dumps**. Subscribe for logging, metrics, or your own UI:
70
+
71
+ ```ruby
72
+ ActiveSupport::Notifications.subscribe("nexo.workflow.event") do |*, payload|
73
+ Rails.logger.info("[run #{payload[:run_id]}] #{payload[:event]["type"]}")
74
+ end
75
+ ```
76
+
77
+ **Opt-in Turbo mirror.** Set `config.broadcast_events = true` (and have turbo-rails
78
+ present) and the engine subscribes `Nexo::TurboBroadcaster`, which appends each
79
+ event to a per-run Turbo stream, rendering the overridable partial
80
+ `app/views/nexo/_event.html.erb`. To show live progress, add to your own page (Nexo
81
+ ships **no** controllers, routes, or dashboard — the host owns all HTTP + UI):
82
+
83
+ ```erb
84
+ <%= turbo_stream_from "nexo_run_#{@run.id}" %>
85
+ <div id="nexo_run_<%= @run.id %>_events">
86
+ <%# appended events land here %>
87
+ </div>
88
+ ```
89
+
90
+ Override the appearance by defining your own `app/views/nexo/_event.html.erb` in
91
+ the host app — it takes precedence over the engine's default (which renders
92
+ `<div class="nexo-event">type: {data.inspect}</div>`).
93
+
94
+ ```ruby
95
+ Nexo.configure { |c| c.broadcast_events = true } # opt in; requires turbo-rails
96
+ ```
97
+
98
+ > **⚠️ Broadcast reachability.** Broadcasts fire from **wherever the run executes**
99
+ > — under `run_later`, that's the worker process. The cable backend (AnyCable,
100
+ > Solid Cable, Redis) must therefore be reachable from your workers, not just your
101
+ > web dynos. Nexo ships **no cable backend** — broadcasting composes whatever the
102
+ > host configured. Without turbo-rails, `broadcast_events` is a harmless no-op:
103
+ > the notifications still fire, so you can subscribe to them yourself.
104
+
105
+ ## Run helpers for a host UI
106
+
107
+ `Nexo::WorkflowRun` exposes query helpers so a host can build its own runs UI
108
+ without Nexo dictating controllers or views:
109
+
110
+ ```ruby
111
+ Nexo::WorkflowRun::STATUSES # => %w[pending queued running done failed interrupted suspended]
112
+
113
+ Nexo::WorkflowRun.queued # scope: status "queued"
114
+ Nexo::WorkflowRun.running # scope: status "running"
115
+ Nexo::WorkflowRun.finished # scope: status "done" or "failed"
116
+ Nexo::WorkflowRun.suspended # scope: status "suspended" (paused, awaiting resume)
117
+
118
+ run.queued? run.running? run.done? run.failed? run.suspended? # predicates
119
+
120
+ # Artifact access — content only; serving files stays your
121
+ # controller's job (Nexo ships no artifact routes/controllers):
122
+ run.artifact("digest.md") # => {"name" =>, "content" =>, "at" =>} or nil
123
+ run.artifact_content("digest.md") # => "…the body…" or nil
124
+ ```
125
+
126
+ See [`examples/rails_usage.md`](../examples/rails_usage.md) for a controller +
127
+ Turbo-page walkthrough.
128
+
129
+ ← Back to the [README](../README.md)