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.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- 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)
|
data/docs/permissions.md
ADDED
|
@@ -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)
|