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