nexo_ai 0.1.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +94 -0
  8. data/docs/durable-workflows.md +184 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +129 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +705 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo_ai.rbs +22 -1
  92. metadata +185 -2
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)
@@ -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