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
data/docs/workflows.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Workflows
|
|
2
|
+
A workflow is a finite job with a stable runId, status, payload, result, and an inspectable event log.
|
|
3
|
+
|
|
4
|
+
An **agent** accumulates context — it keeps a conversation going. A **workflow**
|
|
5
|
+
fires and finishes: a finite job with a stable runId, a `status`, a `payload`, a
|
|
6
|
+
`result`, and an ordered, inspectable event log. Subclass `Nexo::Workflow`,
|
|
7
|
+
implement `#call(payload)`, and run it:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "nexo"
|
|
11
|
+
|
|
12
|
+
class SummarizeDocument < Nexo::Workflow
|
|
13
|
+
def call(payload)
|
|
14
|
+
emit(:started, doc_id: payload[:doc_id])
|
|
15
|
+
summary = payload[:text].to_s.slice(0, 280) # pure Ruby — no Agent needed
|
|
16
|
+
emit(:summarized, length: summary.length)
|
|
17
|
+
{ summary: summary }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
run = SummarizeDocument.run(doc_id: 123, text: "Long text…")
|
|
22
|
+
run.id # => "0191d6b2-…" (UUID v7 string, time-ordered)
|
|
23
|
+
run.status # => "done"
|
|
24
|
+
run.result # => { "summary" => "Long text…" }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`#call` receives a **symbol-keyed** payload; the stored `payload` and `result`
|
|
28
|
+
read back **string-keyed** (they survive a JSON round-trip identically whether
|
|
29
|
+
the run lives in memory or in the database).
|
|
30
|
+
|
|
31
|
+
## Failure model — workflows re-raise
|
|
32
|
+
|
|
33
|
+
A workflow that raises is recorded as `failed` with the error message **and the
|
|
34
|
+
exception still propagates** to your caller:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
run = BoomWorkflow.run # raises — but the run is persisted as failed first
|
|
38
|
+
# => RuntimeError: kaboom
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This is deliberately the opposite of a Nexo *tool* failure, which returns
|
|
42
|
+
`{ error: … }` and never raises into the agent loop. A tool error is recoverable
|
|
43
|
+
context for the model; a workflow failure is a job that did not complete.
|
|
44
|
+
|
|
45
|
+
By default a failed run is **not** retried — the exception is yours to handle. For
|
|
46
|
+
long-running or human-in-the-loop jobs that must pause and continue later (possibly
|
|
47
|
+
in another process), Nexo adds durable **checkpoints**, `suspend!`, and `resume` on
|
|
48
|
+
top of this same lifecycle — see [Durable workflows](durable-workflows.md). Runs
|
|
49
|
+
orphaned in `"running"` by a crashed worker are swept to `"interrupted"` by
|
|
50
|
+
[`reconcile_interrupted!`](#reconciling-interrupted-runs).
|
|
51
|
+
|
|
52
|
+
## The event log — `emit` and `nexo logs`
|
|
53
|
+
|
|
54
|
+
`emit(:type, data)` appends an ordered event (`type`, `data`, `at`) and persists
|
|
55
|
+
it incrementally. Inspect a run's log in plain Ruby:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Nexo::Workflow.logs(run.id) { |ev| puts "#{ev["at"]} #{ev["type"]}" }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or, in a Rails app, from the terminal:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
$ bundle exec rake "nexo:logs[0191d6b2-7c4a-7e1f-9a3b-2f5c8d1e6b00]"
|
|
65
|
+
[2026-06-29T14:02:01Z] started {"doc_id"=>123}
|
|
66
|
+
[2026-06-29T14:02:01Z] summarized {"length"=>280}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## With or without Rails
|
|
70
|
+
|
|
71
|
+
With no Rails loaded, runs record to an in-memory store — workflows run, emit,
|
|
72
|
+
and `Nexo::Workflow.logs` works, all offline with no database. In a Rails app,
|
|
73
|
+
install the migration and runs persist to a `nexo_workflow_runs` table:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
rails g nexo:workflows
|
|
77
|
+
rails db:migrate
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The same `Workflow` code drives either backend; `Nexo::RunStore.default` selects
|
|
81
|
+
ActiveRecord when it is available and the in-memory store otherwise. The schema
|
|
82
|
+
uses portable `json` columns (SQLite and PostgreSQL alike) and a UUID string
|
|
83
|
+
primary key.
|
|
84
|
+
|
|
85
|
+
## Input staging and artifacts
|
|
86
|
+
|
|
87
|
+
A run owns a **sandbox** — declared with the `sandbox` class macro (default
|
|
88
|
+
`:virtual`, the safe in-memory sandbox; `:local` for the host filesystem rooted
|
|
89
|
+
at the `cwd` macro, default `Dir.pwd`). It is resolved **lazily**: a data-only
|
|
90
|
+
workflow that never touches files builds nothing, so the plain `emit`/`result`
|
|
91
|
+
path stays free.
|
|
92
|
+
|
|
93
|
+
A `Workflow` accepts the **same sandbox forms as an `Agent`** — they share one
|
|
94
|
+
resolver (`Nexo::Sandboxes.resolve`), so the two can't drift. Alongside
|
|
95
|
+
`:virtual`/`:local` and a pre-built `Nexo::Sandbox` instance, a workflow can
|
|
96
|
+
declare a hardened container just like an agent:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class BuildInContainer < Nexo::Workflow
|
|
100
|
+
sandbox :docker, image: "node:22-slim" # or :apple, or { type: :docker, ... }
|
|
101
|
+
def call(_payload) = { ok: true }
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The container runs its tools — and the shared sandbox any agent driven via
|
|
106
|
+
`run_agent` inherits — inside the same hardened image (`network: none`, dropped
|
|
107
|
+
capabilities, read-only rootfs by default). As with an agent, `image:` is
|
|
108
|
+
required, and the host `cwd` applies only to `:local`: a container keeps its own
|
|
109
|
+
`/workspace` (see [Container sandbox](sandboxes.md#container-sandbox--docker--apple-container)).
|
|
110
|
+
|
|
111
|
+
`stage(files)` writes provided inputs into that sandbox *before* your `#call`
|
|
112
|
+
work begins. It takes either a `{ "path" => "content" }` hash or an array of
|
|
113
|
+
`{ path:, content: }` hashes, emits a `:staged` event with the count, and returns
|
|
114
|
+
the count staged.
|
|
115
|
+
|
|
116
|
+
`artifact(name, content:)` records a **named deliverable** on the run — a digest,
|
|
117
|
+
a report, an improved file, a generated script. The body is written to the
|
|
118
|
+
sandbox at `/artifacts/<name>` (so later steps can read it) and recorded on the
|
|
119
|
+
run. `run.artifacts` reads it back as an **ordered array** of string-keyed hashes
|
|
120
|
+
(`{"name" =>, "content" =>, "at" =>}`) in both stores:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class BuildDigest < Nexo::Workflow
|
|
124
|
+
def call(payload)
|
|
125
|
+
stage(payload[:files]) # baseline + extras into the sandbox
|
|
126
|
+
artifact("digest.md", content: summarize(sandbox.read("/workspace/baseline.md")))
|
|
127
|
+
{ ok: true }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
run = BuildDigest.run(files: [{ path: "baseline.md", content: "…" }])
|
|
132
|
+
run.artifacts.first["name"] # => "digest.md"
|
|
133
|
+
run.artifacts.first["content"] # => "…the digest body…"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
You can also render an artifact from a **template you control** with `from:` —
|
|
137
|
+
no templating engine, just stdlib `ERB`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# from: is a real disk file when it exists, else a staged sandbox path.
|
|
141
|
+
artifact("digest.md", from: "app/templates/digest.md.erb",
|
|
142
|
+
locals: { title: "Weekly", baseline: sandbox.read("/workspace/baseline.md") })
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> **⚠️ Templates are code, not data.** `ERB` executes arbitrary Ruby. A template
|
|
146
|
+
> passed to `artifact(from:)` **must** be a trusted, developer-authored file —
|
|
147
|
+
> **never** model output or user-uploaded content. Rendering a model-generated
|
|
148
|
+
> or uploaded template is remote code execution. If a body is untrusted, pass it
|
|
149
|
+
> as `content:` (inert data), not as a `from:` template.
|
|
150
|
+
|
|
151
|
+
See [`examples/artifact_from_template.rb`](../examples/artifact_from_template.rb) for
|
|
152
|
+
the full offline flow (`ruby -Ilib examples/artifact_from_template.rb`).
|
|
153
|
+
|
|
154
|
+
The `artifacts` column ships with fresh installs. Apps installed before this
|
|
155
|
+
release add it with:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
rails g nexo:artifacts
|
|
159
|
+
rails db:migrate
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Tasks & Actions
|
|
163
|
+
|
|
164
|
+
A workflow can **declare and drive an agent** so the two primitives Nexo owns —
|
|
165
|
+
a `Workflow` (the run lifecycle) and an `Agent` (the skilled, sandbox-backed
|
|
166
|
+
model loop) — compose into one recipe: *stage inputs → run the agent → capture
|
|
167
|
+
artifacts*. The `agent` class macro names the `Agent` subclass this workflow
|
|
168
|
+
drives; `run_agent(prompt, max_turns: 25)` runs it **bound to the run's own
|
|
169
|
+
sandbox**, forwards every tool call/result and the final response into the run
|
|
170
|
+
log as `agent_*` events, and closes the agent afterward (tearing down any MCP
|
|
171
|
+
servers). It returns the agent's response — read `response.content`.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class ReviewBaseline < Nexo::Workflow
|
|
175
|
+
agent CodeReviewer # the Agent subclass this workflow drives
|
|
176
|
+
|
|
177
|
+
def call(payload)
|
|
178
|
+
stage(payload[:files]) # inputs into the run's sandbox
|
|
179
|
+
resp = run_agent("Review the staged baseline and report OK or the issues.")
|
|
180
|
+
artifact("review.md", content: resp.content) # capture the agent's output
|
|
181
|
+
{ content: resp.content }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Because it composes the existing agent loop's observability seam through the
|
|
187
|
+
same `emit` path, a driven run reads as one coherent story —
|
|
188
|
+
`Nexo::Workflow.logs(run.id)` (and `nexo:logs`) interleaves the workflow's own
|
|
189
|
+
events with the agent's:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
[…] staged {"count"=>1}
|
|
193
|
+
[…] agent_tool_call {"name"=>"read_file", "args"=>{"path"=>"/workspace/baseline.md"}}
|
|
194
|
+
[…] agent_tool_result {"ok"=>true, "content"=>"…"}
|
|
195
|
+
[…] agent_done {"content"=>"REVIEW OK"}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The **same** workflow runs two ways with no code difference — Nexo stays
|
|
199
|
+
*schedulable*, never a scheduler.
|
|
200
|
+
|
|
201
|
+
**As a scheduled Task** — invoke it from a background job (the scheduling itself
|
|
202
|
+
lives in the host: cron / GoodJob / `whenever` / any job system):
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
class ReviewBaselineJob < ApplicationJob
|
|
206
|
+
def perform(files:)
|
|
207
|
+
ReviewBaseline.run(files: files) # same run entry point
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# scheduled elsewhere in the host — Nexo does not schedule:
|
|
212
|
+
ReviewBaselineJob.perform_later(files: nightly_baseline)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**As an interactive Action** — invoke the *same* `run` from a controller after
|
|
216
|
+
staging the uploaded files:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
class ReviewsController < ApplicationController
|
|
220
|
+
def create
|
|
221
|
+
files = params[:files].map { |f| { path: f.original_filename, content: f.read } }
|
|
222
|
+
run = ReviewBaseline.run(files: files) # identical call — no code difference
|
|
223
|
+
redirect_to review_path(run.id)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
> **⚠️ Shared-sandbox precedence.** Under `run_agent` the agent uses the
|
|
229
|
+
> **workflow's** sandbox; the agent's own `sandbox` class macro is **ignored**
|
|
230
|
+
> (it only applies when the agent runs standalone via `.new.prompt`). The agent
|
|
231
|
+
> keeps its **own** `permissions`, `skills`, `mcp`, and `mcp_allow`: the workflow
|
|
232
|
+
> provides the *where* (sandbox), the agent owns the *what* (permissions) and the
|
|
233
|
+
> *how* (skills/instructions). Driving an agent never widens its authority — its
|
|
234
|
+
> safe default (`:read_only`) is untouched.
|
|
235
|
+
|
|
236
|
+
See [`examples/inbox_digest_task.rb`](../examples/inbox_digest_task.rb) for a live
|
|
237
|
+
example that wraps the MCP-backed `InboxTriage` agent in a workflow and captures
|
|
238
|
+
the digest as an artifact.
|
|
239
|
+
|
|
240
|
+
## Reconciling interrupted runs
|
|
241
|
+
|
|
242
|
+
A crashed worker leaves runs stuck in `"running"`. `Nexo::Workflow.reconcile_interrupted!`
|
|
243
|
+
is a **one-shot boot/deploy sweep** that rewrites only `"running"` → `"interrupted"`
|
|
244
|
+
(never touching `"done"` or `"failed"`) and returns the count. It is **never
|
|
245
|
+
auto-invoked** — call it from a boot hook or the shipped rake task:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
bundle exec rake nexo:reconcile
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
> This is **not** a liveness check. It cannot tell a genuinely-running run in
|
|
252
|
+
> another process from an orphaned one — so run it once at boot, *before* any
|
|
253
|
+
> worker starts new runs, not while workers are live.
|
|
254
|
+
|
|
255
|
+
← Back to the [README](../README.md)
|
data/examples/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Nexo examples
|
|
2
|
+
|
|
3
|
+
Each example is a small, runnable script. Two kinds:
|
|
4
|
+
|
|
5
|
+
- **Offline** — no model, no network, no API key. Run them as-is to see the
|
|
6
|
+
primitive work.
|
|
7
|
+
- **Live** (`NEXO_LIVE=1`) — needs a real tool-calling model (`NEXO_MODEL` takes
|
|
8
|
+
any ruby_llm-supported model id; nothing is provider-specific) and sometimes
|
|
9
|
+
an external service (an MCP server, docker, an API key).
|
|
10
|
+
|
|
11
|
+
Run everything from the repo root with `ruby -Ilib examples/<name>.rb`.
|
|
12
|
+
|
|
13
|
+
## Offline (start here)
|
|
14
|
+
|
|
15
|
+
| Example | Shows |
|
|
16
|
+
|---|---|
|
|
17
|
+
| [`artifact_from_template.rb`](artifact_from_template.rb) | Staging input files into a run's sandbox + rendering a named artifact from a trusted ERB template (Spec 7) |
|
|
18
|
+
| [`approval_workflow.rb`](approval_workflow.rb) | Durable human-in-the-loop: `checkpoint` + `suspend!` + `resume` (Spec 13) |
|
|
19
|
+
|
|
20
|
+
## Live — agents
|
|
21
|
+
|
|
22
|
+
| Example | Shows | Extra requirements |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| [`code_reviewer.rb`](code_reviewer.rb) | The minimal agent against a local Ollama model, with a skill and token accounting | Ollama running locally |
|
|
25
|
+
| [`chat_session.rb`](chat_session.rb) | A continuing, addressable `Nexo::Session` that remembers prior turns (Spec 10) | — |
|
|
26
|
+
| [`container_review.rb`](container_review.rb) | Agent tools running inside a locked-down OCI container (Spec 12) | `docker` (or Apple `container`) |
|
|
27
|
+
| [`news_summary.rb`](news_summary.rb) | Read-only web fetch scoped by `fetch_allow` (Spec 9) | — |
|
|
28
|
+
| [`news_search.rb`](news_search.rb) | Host-injected `search_backend` + fetch (Spec 19) | a search backend you inject |
|
|
29
|
+
|
|
30
|
+
## Live — MCP
|
|
31
|
+
|
|
32
|
+
| Example | Shows | Extra requirements |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| [`mcp_filesystem.rb`](mcp_filesystem.rb) | The MCP seam + permission gate with the official filesystem server — no credentials needed; **start here for MCP** | `npx` |
|
|
35
|
+
| [`inbox_digest.rb`](inbox_digest.rb) | Gmail through a stdio MCP server + the `email_triage` skill, read tools only | a Gmail MCP server + OAuth |
|
|
36
|
+
| [`inbox_digest_http.rb`](inbox_digest_http.rb) | The same digest over a hosted HTTP MCP server with a host-supplied OAuth bearer token (Spec 18) | a hosted Gmail MCP server |
|
|
37
|
+
| [`inbox_digest_task.rb`](inbox_digest_task.rb) | The digest as a Workflow **Task**: `agent` macro + `run_agent` + a named artifact (Spec 8) | same as `inbox_digest.rb` |
|
|
38
|
+
|
|
39
|
+
## Live — workflows
|
|
40
|
+
|
|
41
|
+
| Example | Shows | Extra requirements |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| [`approval_agent.rb`](approval_agent.rb) | The `:approve` permission mode bridged to a durable suspend/resume (Spec 16) | — |
|
|
44
|
+
|
|
45
|
+
## Skills used by the examples
|
|
46
|
+
|
|
47
|
+
The `skills/` directory holds the SKILL.md packages the examples reference
|
|
48
|
+
(`email_triage`, `news_summary`, `ruby-code-review`). The examples point
|
|
49
|
+
`Nexo.config.skills_path` here; in a Rails host the default is `app/skills`.
|
|
50
|
+
|
|
51
|
+
See also [`rails_usage.md`](rails_usage.md) for the Rails host-app walkthrough.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verification example — Spec 16 (Durable Approval): bridge an agent's :approve
|
|
4
|
+
# gate to a durable suspend/resume.
|
|
5
|
+
#
|
|
6
|
+
# The workflow drives an agent under the :approve permission mode. The moment the
|
|
7
|
+
# agent tries a sensitive capability (a write) the gate raises
|
|
8
|
+
# Nexo::ApprovalRequired, which propagates out of the tool loop; run_agent catches
|
|
9
|
+
# it, records the pending call under run.state["__approval__"], and SUSPENDS the
|
|
10
|
+
# run (a background worker returns — nothing blocks). Later, a host approves and
|
|
11
|
+
# resumes: the decision is threaded into the agent, the same gate now ALLOWS, and
|
|
12
|
+
# the run completes.
|
|
13
|
+
#
|
|
14
|
+
# This needs a live tool-calling model (the agent must actually decide to write),
|
|
15
|
+
# so it is env-gated:
|
|
16
|
+
#
|
|
17
|
+
# NEXO_LIVE=1 NEXO_MODEL=gpt-4o-mini OPENAI_API_KEY=... ruby -Ilib examples/approval_agent.rb
|
|
18
|
+
#
|
|
19
|
+
# Nothing provider-specific: set NEXO_MODEL to any ruby_llm-supported tool-calling
|
|
20
|
+
# model. Offline, the same bridge is exercised by test/workflow_approval_test.rb
|
|
21
|
+
# with a spy agent (no API key, no network).
|
|
22
|
+
#
|
|
23
|
+
# In a real host app you would use the ActiveRecord store (`rails g nexo:workflows`)
|
|
24
|
+
# so the suspended run survives across processes, and resume from a controller/job
|
|
25
|
+
# once a human clicks "approve" (`MyWorkflow.resume_later(run.id, approved: true)`).
|
|
26
|
+
require "nexo"
|
|
27
|
+
|
|
28
|
+
# A local, in-memory sandbox is enough — the agent writes a file the model chooses;
|
|
29
|
+
# :approve gates that write for a human decision.
|
|
30
|
+
class Scribe < Nexo::Agent
|
|
31
|
+
model ENV.fetch("NEXO_MODEL", "gpt-4o-mini")
|
|
32
|
+
sandbox :local
|
|
33
|
+
permissions :approve # every write/shell needs a human decision (undecided ⇒ suspend)
|
|
34
|
+
instructions "You write short files when asked. Use the write tool."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class ApprovedWrite < Nexo::Workflow
|
|
38
|
+
cwd Dir.pwd
|
|
39
|
+
sandbox :local
|
|
40
|
+
agent Scribe
|
|
41
|
+
|
|
42
|
+
def call(_payload)
|
|
43
|
+
resp = run_agent("Write the text 'approved!' to notes/approval_demo.txt")
|
|
44
|
+
{content: resp.content}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if __FILE__ == $PROGRAM_NAME
|
|
49
|
+
abort "set NEXO_LIVE=1 to run this live example" unless ENV["NEXO_LIVE"]
|
|
50
|
+
|
|
51
|
+
# 1) First pass: the agent hits the :approve gate on its write → the run suspends.
|
|
52
|
+
run = ApprovedWrite.run
|
|
53
|
+
puts "status after first pass: #{run.status}" # => "suspended"
|
|
54
|
+
puts "suspend reason: #{run.state["__suspend__"]["reason"]}"
|
|
55
|
+
puts "pending approval: #{run.state["__approval__"].inspect}" # capability/tool/args
|
|
56
|
+
|
|
57
|
+
# 2) A human approves. Resume threads {approved: true} into the agent, so the
|
|
58
|
+
# same gate now allows and the run completes. (Denying — resume(approved:
|
|
59
|
+
# false) — instead lets the tool return {error:}; the model adapts and the run
|
|
60
|
+
# still completes "done", without the write.)
|
|
61
|
+
resumed = ApprovedWrite.resume(run.id, approved: true)
|
|
62
|
+
puts "status after resume: #{resumed.status}" # => "done"
|
|
63
|
+
puts "result: #{resumed.result.inspect}"
|
|
64
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verification example — Spec 13: a durable human-in-the-loop (HITL) workflow.
|
|
4
|
+
#
|
|
5
|
+
# The workflow fetches a document (an expensive/side-effectful step, guarded by a
|
|
6
|
+
# `checkpoint` so it is paid for exactly once), then `suspend!`s to wait for a human
|
|
7
|
+
# approval. Later — possibly in another process — a controller/job calls
|
|
8
|
+
# `Workflow.resume(run_id, approved: true)`; the workflow re-enters `#call` from the
|
|
9
|
+
# top, SKIPS the already-completed `:fetch` checkpoint, and publishes.
|
|
10
|
+
#
|
|
11
|
+
# Fully offline (Memory store, in-process resume) — no API key, no network:
|
|
12
|
+
#
|
|
13
|
+
# ruby -Ilib examples/approval_workflow.rb
|
|
14
|
+
#
|
|
15
|
+
# In a real host app you would use the ActiveRecord store (`rails g nexo:workflows`)
|
|
16
|
+
# so the suspended run survives across processes, suspend from a controller action,
|
|
17
|
+
# and resume from a different request/job once a human clicks "approve".
|
|
18
|
+
require "nexo"
|
|
19
|
+
|
|
20
|
+
class DocumentApproval < Nexo::Workflow
|
|
21
|
+
def call(payload)
|
|
22
|
+
# Expensive/side-effectful: fetched once, then reused on resume. A crash before
|
|
23
|
+
# suspend would re-run it (at-least-once); a crash after suspend never re-fetches.
|
|
24
|
+
document = checkpoint(:fetch) do
|
|
25
|
+
emit(:fetching, id: payload[:id])
|
|
26
|
+
"contents of document ##{payload[:id]}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gate on the resume input. On the first pass it is `{}`, so we pause the run.
|
|
30
|
+
# On resume the host feeds `{approved: true}`, so we fall through and publish.
|
|
31
|
+
unless resume_input[:approved]
|
|
32
|
+
emit(:awaiting_approval, id: payload[:id])
|
|
33
|
+
suspend!(reason: "awaiting human approval", resume_key: "doc-#{payload[:id]}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
published = checkpoint(:publish) do
|
|
37
|
+
emit(:publishing, id: payload[:id])
|
|
38
|
+
"published: #{document}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{published: published, approved_by: resume_input[:approver]}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if __FILE__ == $PROGRAM_NAME
|
|
46
|
+
# 1) A controller kicks off the run. It reaches `suspend!` and returns paused.
|
|
47
|
+
run = DocumentApproval.run(id: 42)
|
|
48
|
+
puts "status after first pass: #{run.status}" # => "suspended"
|
|
49
|
+
puts "suspend reason: #{run.state["__suspend__"]["reason"]}"
|
|
50
|
+
puts "fetch checkpoint stored: #{run.state["fetch"].inspect}"
|
|
51
|
+
|
|
52
|
+
# 2) Time passes; a human approves. A LATER request/job resumes the run, feeding
|
|
53
|
+
# the approval in as `input`. #call re-runs from the top but skips :fetch.
|
|
54
|
+
resumed = DocumentApproval.resume(run.id, approved: true, approver: "alice")
|
|
55
|
+
puts "status after resume: #{resumed.status}" # => "done"
|
|
56
|
+
puts "result: #{resumed.result.inspect}"
|
|
57
|
+
puts "publish checkpoint: #{resumed.state["publish"].inspect}"
|
|
58
|
+
|
|
59
|
+
# The event log shows :fetching fired ONCE (checkpoint skipped on resume) while
|
|
60
|
+
# :publishing fired on the resume pass.
|
|
61
|
+
puts "events: #{resumed.events.map { |e| e["type"] }.inspect}"
|
|
62
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verification example — Spec 7 (Input Staging + Artifacts). Runs OFFLINE, no model.
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates the deliverable shape end to end: stage a baseline + additional files into
|
|
6
|
+
# the run's Virtual sandbox, then render a digest ARTIFACT from a trusted developer template.
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/artifact_from_template.rb
|
|
9
|
+
#
|
|
10
|
+
# This is the mechanics of use case #2 (file-upload -> artifact) with the "improve" step
|
|
11
|
+
# left out (that needs an agent — see Spec 8 / examples/inbox_digest_task.rb).
|
|
12
|
+
require "nexo"
|
|
13
|
+
|
|
14
|
+
class BuildDigest < Nexo::Workflow
|
|
15
|
+
# Default sandbox is :virtual (safe, in-memory) — nothing touches the host.
|
|
16
|
+
def call(payload)
|
|
17
|
+
# Stage the provided files (baseline + extras + the template) into the run sandbox.
|
|
18
|
+
stage(payload[:files])
|
|
19
|
+
emit(:staged, files: payload[:files].map { |f| f[:path] })
|
|
20
|
+
|
|
21
|
+
# Render a NAMED artifact from a TRUSTED developer template (ERB runs Ruby — never
|
|
22
|
+
# render model output or uploads here).
|
|
23
|
+
artifact("digest.md",
|
|
24
|
+
from: "/workspace/digest.md.erb",
|
|
25
|
+
locals: {title: payload[:title], baseline: sandbox.read("/workspace/baseline.md")})
|
|
26
|
+
|
|
27
|
+
{ok: true}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if __FILE__ == $PROGRAM_NAME
|
|
32
|
+
Nexo::RunStore::Memory.reset! if defined?(Nexo::RunStore::Memory)
|
|
33
|
+
|
|
34
|
+
files = [
|
|
35
|
+
{path: "baseline.md", content: "- item one\n- item two\n"},
|
|
36
|
+
{path: "extra.md", content: "- item three\n"},
|
|
37
|
+
# a trusted, developer-authored ERB template (checked into the repo, not uploaded)
|
|
38
|
+
{path: "digest.md.erb", content: <<~ERB}
|
|
39
|
+
# <%= title %>
|
|
40
|
+
|
|
41
|
+
Baseline captured:
|
|
42
|
+
<%= baseline %>
|
|
43
|
+
Generated by Nexo.
|
|
44
|
+
ERB
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
run = BuildDigest.run(title: "Weekly Digest", files: files)
|
|
48
|
+
|
|
49
|
+
puts "run status: #{run.status}"
|
|
50
|
+
digest = run.artifacts.find { |a| (a["name"] || a[:name]) == "digest.md" }
|
|
51
|
+
puts "---- artifact: digest.md ----"
|
|
52
|
+
puts(digest["content"] || digest[:content])
|
|
53
|
+
# In a Rails Action, a controller would read run.artifacts here to show/download the result.
|
|
54
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verification example (Spec 10): a continuing, addressable Nexo::Session that
|
|
4
|
+
# remembers a prior turn across two separate `resume` calls.
|
|
5
|
+
#
|
|
6
|
+
# NEXO_LIVE=1 NEXO_MODEL=... ruby -Ilib examples/chat_session.rb
|
|
7
|
+
#
|
|
8
|
+
# Run in plain Ruby like this, the thread lives in memory for the process only
|
|
9
|
+
# (Nexo::Session::MemoryStore) — the two prompts below still share it, so the
|
|
10
|
+
# agent recalls the name. In a Rails host with ruby_llm's acts_as_chat models
|
|
11
|
+
# (see the README "Sessions" section) the very same code is DURABLE: the thread
|
|
12
|
+
# is a `chats` row addressed by (agent_name, instance_id) and survives across
|
|
13
|
+
# processes. Nexo ships no migration for those models — the host owns them via
|
|
14
|
+
# `rails g ruby_llm:install`, plus the agent_name/instance_id columns + a unique
|
|
15
|
+
# composite index.
|
|
16
|
+
#
|
|
17
|
+
# A session adds only memory + addressability. The agent's sandbox, permissions
|
|
18
|
+
# (default :read_only), skills, MCP, and fetch_allow all apply unchanged.
|
|
19
|
+
require "nexo"
|
|
20
|
+
|
|
21
|
+
class Assistant < Nexo::Agent
|
|
22
|
+
model ENV.fetch("NEXO_MODEL")
|
|
23
|
+
instructions "You are a helpful assistant. Remember what the user tells you."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if __FILE__ == $PROGRAM_NAME
|
|
27
|
+
abort "set NEXO_LIVE=1 to run this live example" unless ENV["NEXO_LIVE"]
|
|
28
|
+
|
|
29
|
+
id = "user-42"
|
|
30
|
+
|
|
31
|
+
# First invocation: tell the agent something worth remembering.
|
|
32
|
+
first = Nexo::Session.resume(Assistant, id)
|
|
33
|
+
begin
|
|
34
|
+
puts first.prompt("My name is Mac.").content
|
|
35
|
+
ensure
|
|
36
|
+
first.close # releases any MCP/fetch resources the agent holds (none here)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A LATER invocation — a fresh Session over the same (Assistant, "user-42")
|
|
40
|
+
# thread. In Rails this could be a different process entirely; the persisted
|
|
41
|
+
# thread carries the earlier turn forward.
|
|
42
|
+
second = Nexo::Session.resume(Assistant, id)
|
|
43
|
+
begin
|
|
44
|
+
answer = second.prompt("What is my name?")
|
|
45
|
+
puts answer.content # expect the reply to include "Mac"
|
|
46
|
+
ensure
|
|
47
|
+
second.close
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The five-line agent, pointed at a LOCAL model via Ollama — plus a skill and
|
|
4
|
+
# per-prompt token accounting. Runs against any ruby_llm-supported model; nothing
|
|
5
|
+
# here is vendor-specific.
|
|
6
|
+
#
|
|
7
|
+
# NEXO_MODEL=gemma3:12b ruby -Ilib examples/code_reviewer.rb
|
|
8
|
+
#
|
|
9
|
+
# `provider :ollama` + `assume_model_exists true` skip ruby_llm's models.json
|
|
10
|
+
# registry lookup so unregistered local tags (gemma3:12b, self-hosted builds)
|
|
11
|
+
# work without waiting for a registry update.
|
|
12
|
+
require "bundler/setup"
|
|
13
|
+
require "nexo_ai"
|
|
14
|
+
|
|
15
|
+
RubyLLM.configure do |config|
|
|
16
|
+
# Ollama itself needs no key; set API_KEY only if a local proxy requires one.
|
|
17
|
+
config.ollama_api_key = ENV["API_KEY"] if ENV["API_KEY"]
|
|
18
|
+
config.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://127.0.0.1:11434/v1")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Nexo.configure do |config|
|
|
22
|
+
config.skills_path = File.expand_path("skills", __dir__)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class CodeReviewer < Nexo::Agent
|
|
26
|
+
model ENV.fetch("NEXO_MODEL") # any ruby_llm-supported model; e.g. gemma3:12b
|
|
27
|
+
provider :ollama
|
|
28
|
+
assume_model_exists true
|
|
29
|
+
sandbox :virtual # in-memory: the agent reads only what we stage below
|
|
30
|
+
permissions :read_only
|
|
31
|
+
|
|
32
|
+
instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
|
|
33
|
+
skills "ruby-code-review" # examples/skills/ruby-code-review/SKILL.md
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if __FILE__ == $PROGRAM_NAME
|
|
37
|
+
agent = CodeReviewer.new
|
|
38
|
+
|
|
39
|
+
totals = Hash.new(0)
|
|
40
|
+
show_stats = ->(response) do
|
|
41
|
+
totals[:input] += response.input_tokens.to_i
|
|
42
|
+
totals[:output] += response.output_tokens.to_i
|
|
43
|
+
puts "— #{response.model_id}: in=#{response.input_tokens} out=#{response.output_tokens}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stage this very file into the agent's in-memory sandbox: the review target.
|
|
47
|
+
puts "Staging code_reviewer.rb into the sandbox"
|
|
48
|
+
agent.sandbox.write("code_reviewer.rb", File.read(__FILE__))
|
|
49
|
+
|
|
50
|
+
puts "\nReviewing file"
|
|
51
|
+
response = agent.prompt("Review the code_reviewer.rb file")
|
|
52
|
+
puts response.content
|
|
53
|
+
show_stats.call(response)
|
|
54
|
+
|
|
55
|
+
# Deliberate denial demo: :read_only means the write tool refuses, so the model
|
|
56
|
+
# must report that it cannot delete/modify anything.
|
|
57
|
+
puts "\nAsking for a delete (expected to be refused under :read_only)"
|
|
58
|
+
response = agent.prompt("Delete file code_reviewer.rb")
|
|
59
|
+
puts response.content
|
|
60
|
+
show_stats.call(response)
|
|
61
|
+
|
|
62
|
+
puts "-----"
|
|
63
|
+
puts "Session totals: in=#{totals[:input]} out=#{totals[:output]} total=#{totals[:input] + totals[:output]}"
|
|
64
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verification example — the Container sandbox running an agent's tools inside a
|
|
4
|
+
# throwaway, hardened OCI container via the `docker` (default) or Apple
|
|
5
|
+
# `container` CLI. No vendor gem: shell-out only.
|
|
6
|
+
#
|
|
7
|
+
# The agent reads the mounted repo but never touches your host directly — the
|
|
8
|
+
# container has no network, dropped capabilities, a read-only rootfs with an
|
|
9
|
+
# ephemeral writable scratch at /workspace, and the host repo bind-mounted
|
|
10
|
+
# READ-ONLY. When the run ends, `agent.close` tears the container down.
|
|
11
|
+
#
|
|
12
|
+
# NEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/container_review.rb /path/to/repo
|
|
13
|
+
#
|
|
14
|
+
# Point at Apple's runtime on a Mac by setting NEXO_CONTAINER_RUNTIME=apple.
|
|
15
|
+
# Nothing here is provider-specific: NEXO_MODEL takes any ruby_llm-supported model.
|
|
16
|
+
require "nexo"
|
|
17
|
+
|
|
18
|
+
class ContainerReviewer < Nexo::Agent
|
|
19
|
+
model ENV.fetch("NEXO_MODEL") # provider-neutral; e.g. gemma3:12b via Ollama
|
|
20
|
+
permissions :read_only # safe default
|
|
21
|
+
|
|
22
|
+
instructions <<~PROMPT
|
|
23
|
+
You run inside a locked-down container. Read files under /workspace/repo and
|
|
24
|
+
report issues. You have no network and cannot modify the host.
|
|
25
|
+
PROMPT
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Builds the container sandbox declaration for a given host repo path. The repo
|
|
29
|
+
# enters ONLY as a read-only bind at /workspace/repo — the model can read it but
|
|
30
|
+
# can't modify your host. (Built per-run, not in the class body, so the repo
|
|
31
|
+
# argument is honored: a class-level `binds: {Dir.pwd => ...}` would freeze the
|
|
32
|
+
# load-time CWD into the mount.)
|
|
33
|
+
def container_sandbox_for(repo)
|
|
34
|
+
{
|
|
35
|
+
type: (ENV["NEXO_CONTAINER_RUNTIME"] || "docker").to_sym,
|
|
36
|
+
image: ENV.fetch("NEXO_CONTAINER_IMAGE", "node:22-slim"),
|
|
37
|
+
binds: {repo => {to: "/workspace/repo", mode: :ro}}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if __FILE__ == $PROGRAM_NAME
|
|
42
|
+
abort "set NEXO_LIVE=1 to run this live example" unless ENV["NEXO_LIVE"]
|
|
43
|
+
|
|
44
|
+
repo = File.expand_path(ARGV[0] || Dir.pwd)
|
|
45
|
+
agent = ContainerReviewer.new(sandbox: container_sandbox_for(repo))
|
|
46
|
+
begin
|
|
47
|
+
response = agent.prompt("Review the code under /workspace/repo and summarize any issues.")
|
|
48
|
+
puts response.content
|
|
49
|
+
ensure
|
|
50
|
+
agent.close # releases MCP clients (none here) AND force-removes the ephemeral container
|
|
51
|
+
end
|
|
52
|
+
end
|