nexo_ai 0.8.1 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af80f73909a7207f4414d45ae28de58bd36555e8aa186f27a7d76b93b6d6ac85
4
- data.tar.gz: 91efdbb90334708bc98ddfa099bf1d74f87424fdb6a29bdb0dd83ec6a326e844
3
+ metadata.gz: 8815820e94035d1d5c68defa4e862111c95572852098b735e885eaf98b6a3407
4
+ data.tar.gz: 57ca3d98c0d1acd6877276e9322715a7e003928b2da258866b1e7ccabcf33b29
5
5
  SHA512:
6
- metadata.gz: 985b074e6c59e6e5aa2353beef2fd14890efceb5f8cd20d7c3611ced5861f3fdf7a086a12b67a90ba2fbd8358c5288ac3c1e774bb3c2bb512a0868f2cc053b75
7
- data.tar.gz: '091eff209c74596c4acb2b767182c21fd7f717416fc8a8ef9b3150e245b65d11ab185d8eb546d63dd508d36f7e81faced7c182fcd26fcbe3ad2998561e06965f'
6
+ metadata.gz: 8ad1a55220bcddd2ea1e2d06ea2bc2c680ae4f2a52c1cd8f24e0942f66ad38e38c6b1dc992fcc766b720400083bb07b500feaae9285fbb671e203bfa3f46e0f4
7
+ data.tar.gz: cea3ce8167dae3b4e44a5d87c84e42f04a333c7cd7e5aae8ad236133b821ba10fd3688e63851035d2c1b494b9c533807fa29bfbfd1c3439ed8c8ce6473fe1e52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2026-08-20
4
+
5
+ Durable workflows without a database.
6
+
7
+ ### Added
8
+
9
+ - `Nexo.config.run_store` — set a store instance and it wins over the automatic
10
+ choice (ActiveRecord under Rails, in-memory otherwise).
11
+ - `Nexo::RunStore::Disk` — a file-backed store, one JSON document per run under a
12
+ directory, written atomically (temp file + rename). This is what makes `checkpoint`,
13
+ `suspend!`/`resume` and a run's recorded artifacts available to a host with no
14
+ database: all of them read state back from the store, and the in-memory one dies
15
+ with the process. `Disk::Run` subclasses `Memory::Run`, so the run shape and every
16
+ read helper are shared and host code behaves identically on either store. `#all`
17
+ lists the directory newest-first. `claim_for_resume!` re-reads from disk so a stale
18
+ copy cannot win a claim; it is atomic within a process, not across machines.
19
+
20
+ ## [0.9.0] - 2026-08-20
21
+
22
+ Skills and sandboxes learn to talk about the environment, and an agent's output finally
23
+ survives its sandbox. A skill can state what it needs (`compatibility:`, which was parsed
24
+ and then dropped), a sandbox can report what it has (`Sandbox#environment`), and an agent
25
+ can require the two to match before the first turn. Separately, an agent's declared output
26
+ is now collected before teardown — a workflow releases its sandbox on **every** terminal
27
+ path, `suspended` included, so pausing for a human approval used to destroy everything the
28
+ run had produced on a container tier while the identical code on `:local` kept it. Along
29
+ the way `Workflow#artifact` turned out never to have worked outside `:virtual`. Verified
30
+ end to end against Docker 29.4.0 and Apple `container` 1.2.2.
31
+
32
+ ### Added
33
+
34
+ - **A skill's `compatibility:` frontmatter now reaches the model.** `apply_instructions`
35
+ passed only `skill.content`, so `compatibility:` — the Agent Skills spec's own field for
36
+ stating what a skill needs in order to run — was parsed and then dropped. It is now
37
+ appended to the skill's body as a labelled `Compatibility: …` line. Skills that do not
38
+ set it contribute exactly their body, byte for byte, as before. `license:` and
39
+ `allowed-tools:` stay unsurfaced on purpose: the first is prompt noise, and the second
40
+ would be a second source of truth about what an agent may do, competing with
41
+ `Nexo::Permissions`.
42
+ - **`Sandbox#environment` — a sandbox can report what it actually provides.** One POSIX
43
+ `sh` round trip returns the commands on `PATH` (with versions) and the locale, memoized
44
+ for the sandbox's lifetime and extensible per call. It never raises: a shell-less
45
+ sandbox and a probe that could not run both answer empty, carrying the reason under
46
+ `:error`, because "there is no ruby" and "I never got to look" have different fixes.
47
+ Deliberately coarse — commands and locale, never packages.
48
+ - **`requires` on an agent, checked before the first turn.**
49
+ `requires commands: {"ruby" => ">= 3.1"}, locale: :utf8` raises `Nexo::EnvironmentError`
50
+ listing every unmet requirement at once, instead of letting the run reach
51
+ `sh: ruby: not found` several turns in. Declaring nothing is the default and costs no
52
+ probe. Motivating case: a container has **no locale even when it has a full toolchain**,
53
+ under which Ruby's default external encoding is `US-ASCII` and a bare `File.read` on a
54
+ UTF-8 file raises — measured on Docker and Apple `container` alike.
55
+ - **`Nexo::EnvironmentError`** (a `ConfigurationError`) for the above: the fix is in the
56
+ image or the sandbox wiring rather than in the Ruby.
57
+ - **`produces` on an agent, collected into the run before the sandbox dies.** An agent
58
+ declares the artifacts it writes (`produces "dashboard.html", "out/*.json"` — many per
59
+ agent, globs allowed) and `run_agent` copies them out and records them on the run the
60
+ moment the agent finishes, **including when it suspended for approval or raised**. A
61
+ workflow releases its sandbox on every terminal path and `Container#close` is `rm -f`,
62
+ so before this a durable-approval pause destroyed everything the run had produced, while
63
+ the identical code on `:local` kept it. Verified end to end through real ephemeral
64
+ containers on Docker 29.4.0 and Apple `container` 1.2.2.
65
+ - **`Workflow#artifact(name, path:)`** — a verbatim third mode. `from:` renders ERB and is
66
+ documented as trusted templates only, which agent output can never be; `path:` copies
67
+ sandbox bytes with no rendering. Non-UTF-8 bytes are Base64-wrapped so they survive a
68
+ JSON column; `Workflow.artifact_body(art)` decodes.
69
+ - **`Workflow#restore_artifacts`** — materializes recorded artifacts back into the run's
70
+ sandbox, so a later stage can read what an earlier one produced even on an ephemeral
71
+ tier. The missing counterpart to `Skills.materialize`.
72
+
73
+ ### Fixed
74
+
75
+ - **`Workflow#artifact` wrote to an absolute `/artifacts/<name>`, which every real sandbox
76
+ rejects.** `Local#absolute` and `Container#guard_path` both raise
77
+ `SecurityError: path escapes sandbox`, so the feature only ever worked on `:virtual`,
78
+ whose in-memory paths are unguarded. The copy is now workspace-relative
79
+ (`artifacts/<name>`) and resolves under the root on all four tiers.
80
+
81
+ ### Changed
82
+
83
+ - **The in-sandbox copy of an artifact moved from `/artifacts/<name>` to
84
+ `artifacts/<name>`**, relative to the sandbox root. Visible only to `:virtual` users —
85
+ the only ones for whom `#artifact` worked at all — and only if they read the copy back
86
+ by absolute path. The recorded `run.artifacts` data is unchanged.
87
+
3
88
  ## [0.8.1] - 2026-08-19
4
89
 
5
90
  Sandbox tiers made honest and skill resources made usable. A skill's `scripts/` and
data/docs/sandboxes.md CHANGED
@@ -94,6 +94,85 @@ scope; none widens authority silently.
94
94
  )
95
95
  ```
96
96
 
97
+ ## Ask a sandbox what it has — `#environment`
98
+
99
+ `#instructions` describes the execution environment *for the model*. `#environment`
100
+ answers the same question *for code*, in one `#shell` round trip:
101
+
102
+ ```ruby
103
+ sandbox.environment
104
+ # => { commands: { "ruby" => { path: "/usr/local/bin/ruby", version: "4.0.0" } },
105
+ # locale: "C.UTF-8",
106
+ # error: nil }
107
+ ```
108
+
109
+ It exists because a skill's script runs wherever the sandbox is, which is not the machine
110
+ it was written on. Measured on three real sandboxes:
111
+
112
+ | Sandbox | Probe | `locale` | Commands found |
113
+ |---|---|---|---|
114
+ | `:local` (host) | 0.14s | `en_US.UTF-8` | ruby 4.0.0, python3 3.14.5, node 26.3.0 |
115
+ | `:docker alpine:latest` | 0.25s | **none** | sh only |
116
+ | `:docker` full image (~5 GB) | 0.50s | **none** | ruby 4.0.0, python3 3.12.3, node 26.7.0 |
117
+ | `:apple` full image | 1.84s | **none** | ruby 4.0.0, python3 3.12.3, node 26.4.0 |
118
+
119
+ Note the last three rows: **a container has no locale even when it has a full toolchain.**
120
+ Under an unset locale Ruby's default external encoding is `US-ASCII`, and a bare
121
+ `File.read` on a UTF-8 file raises `Encoding::InvalidByteSequenceError`. "Has the
122
+ interpreter" and "can read a UTF-8 file" are independent, so they are reported separately.
123
+
124
+ The probe is POSIX `sh` — no interpreter needed on the far side, so it works on busybox —
125
+ memoized for the sandbox's lifetime, and extensible per call:
126
+
127
+ ```ruby
128
+ sandbox.environment(commands: %w[ruby convert])
129
+ ```
130
+
131
+ **It never raises.** A sandbox with no shell (`Virtual`) and a probe that could not run
132
+ both report empty, with the reason under `:error`:
133
+
134
+ ```ruby
135
+ Nexo::Sandboxes::Virtual.new.environment[:error]
136
+ # => "sandbox has no shell"
137
+ ```
138
+
139
+ That distinction matters: "I looked and there is no ruby" and "I never got to look" have
140
+ different fixes. An `:apple` container left on the default `readonly_rootfs: true`, for
141
+ instance, cannot start a process at all (`The volume is read only`) — reported as an
142
+ `:error`, not as an empty toolchain.
143
+
144
+ **Scope is deliberately coarse: commands on `PATH` and the locale, never packages.** Gems,
145
+ wheels and npm modules belong to whoever builds the image; modelling them here would be a
146
+ cross-language dependency resolver competing with the manifest every ecosystem already has.
147
+
148
+ ### Declaring what an agent needs
149
+
150
+ ```ruby
151
+ class Publisher < Nexo::Agent
152
+ skills :dashboard_designer
153
+ requires commands: {"ruby" => ">= 3.1"}, locale: :utf8
154
+ end
155
+ ```
156
+
157
+ Checked once, before the first turn, and it fails with every unmet requirement at once:
158
+
159
+ ```
160
+ Publisher cannot run here: no ruby on PATH; no locale set (needs a UTF-8 locale).
161
+ Provision the sandbox, or drop the `requires` declaration.
162
+ ```
163
+
164
+ - `commands:` maps a command to a `Gem::Requirement` string, or `"*"` for any version. A
165
+ command whose version cannot be read (busybox `sh` prints none) satisfies any constraint
166
+ by being present — an unreadable version is not evidence of a wrong one.
167
+ - `locale:` takes `:utf8` (any UTF-8 locale, the case that actually comes up) or an exact
168
+ `String`.
169
+ - Declaring nothing is the default and costs **no probe at all**.
170
+
171
+ The declaration lives here, in Nexo's vocabulary, rather than in the skill file: whoever
172
+ wires an agent to a sandbox is the only person who can *fix* a gap, so the declaration and
173
+ the fix sit together. A skill states its needs in prose through `compatibility:`, which is
174
+ the Agent Skills spec's field for it and is aimed at a human or a model.
175
+
97
176
  ## Remote sandbox — bring your own container
98
177
 
99
178
  `Sandboxes::Remote` contains **zero vendor code**. It wraps any object that satisfies a
data/docs/skills.md CHANGED
@@ -57,6 +57,35 @@ Without it installed, `require "nexo"` still loads; touching a skill raises a cl
57
57
  `Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-skills"`. Referencing a
58
58
  skill that does not exist raises `Nexo::Error` naming the missing `SKILL.md` path.
59
59
 
60
+ ## Say what a skill needs with `compatibility:`
61
+
62
+ A skill's script runs in whatever the agent's sandbox happens to be — which is not the
63
+ machine the script was written on. A container typically has **no locale**, so Ruby's
64
+ default external encoding is `US-ASCII` and a bare `File.read` on a UTF-8 file raises;
65
+ it may also have no interpreter at all.
66
+
67
+ `compatibility:` is the Agent Skills spec's field for saying so, and Nexo passes it to
68
+ the model alongside the skill body:
69
+
70
+ ```yaml
71
+ ---
72
+ name: dashboard-designer
73
+ description: Render the briefing dashboard.
74
+ compatibility: Requires a Ruby interpreter (>= 3.1) and a UTF-8 locale.
75
+ ---
76
+ ```
77
+
78
+ The model sees the body, then `Compatibility: Requires a Ruby interpreter …` as a labelled
79
+ line, so it can tell a requirement from a step. Skills that do not set the field contribute
80
+ exactly their body, unchanged.
81
+
82
+ It is **free text, by design** — the spec does not make it machine-checkable, and Nexo does
83
+ not try to. It is documentation aimed at a human or a model, not a dependency manifest:
84
+ provisioning the sandbox (a gem, a library, a config) is the job of whoever wires the agent
85
+ to it, not of the skill file. `license:` and `allowed-tools:` are parsed by
86
+ `ruby_llm-skills` but deliberately **not** surfaced — the first is prompt noise, and the
87
+ second would compete with `Nexo::Permissions`, which is the real gate.
88
+
60
89
  ## Skill tools stay gated
61
90
 
62
91
  A skill contributes **instructions only**. A loaded skill ships no independent tools, and
data/docs/workflows.md CHANGED
@@ -82,6 +82,34 @@ ActiveRecord when it is available and the in-memory store otherwise. The schema
82
82
  uses portable `json` columns (SQLite and PostgreSQL alike) and a UUID string
83
83
  primary key.
84
84
 
85
+ ### Durable without a database — `RunStore::Disk`
86
+
87
+ The in-memory store dies with the process, and **everything durable reads state
88
+ back from the store**: `checkpoint`, `suspend!`/`resume`, and a run's recorded
89
+ artifacts. So for a CLI or a script — no Rails, no database, but a process that
90
+ ends — point the store at a directory:
91
+
92
+ ```ruby
93
+ Nexo.config.run_store = Nexo::RunStore::Disk.new(dir: "~/.local/state/myapp/runs")
94
+ ```
95
+
96
+ One JSON document per run, rewritten whenever the run changes, written to a temp
97
+ file and renamed so a crash cannot leave a truncated document behind. The run shape
98
+ and every read helper are Memory's — `Disk::Run` subclasses it — so host code
99
+ written against one store behaves identically on the other. `#all` lists the
100
+ directory newest-first, which is what a host wants for *"what did the last run
101
+ produce?"*.
102
+
103
+ A configured store wins over the automatic choice, including under Rails.
104
+
105
+ **Scope, honestly.** Rewriting the whole document per change suits the run sizes this
106
+ is meant for (a CLI's own history) and is wrong for a busy multi-worker queue — that
107
+ is what the ActiveRecord backend is for. `claim_for_resume!` re-reads from disk, so a
108
+ stale in-memory copy cannot win a claim, but it is atomic **within** a process only.
109
+ Runs are never pruned: retention is the host's policy and `dir` is a plain directory
110
+ it can manage. `Workflow.reconcile_interrupted!` sweeps the Memory and ActiveRecord
111
+ stores only.
112
+
85
113
  ## Input staging and artifacts
86
114
 
87
115
  A run owns a **sandbox** — declared with the `sandbox` class macro (default
@@ -115,7 +143,7 @@ the count staged.
115
143
 
116
144
  `artifact(name, content:)` records a **named deliverable** on the run — a digest,
117
145
  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
146
+ sandbox at `artifacts/<name>` (so later steps can read it) and recorded on the
119
147
  run. `run.artifacts` reads it back as an **ordered array** of string-keyed hashes
120
148
  (`{"name" =>, "content" =>, "at" =>}`) in both stores:
121
149
 
@@ -150,6 +178,52 @@ artifact("digest.md", from: "app/templates/digest.md.erb",
150
178
 
151
179
  See [`examples/artifact_from_template.rb`](../examples/artifact_from_template.rb) for
152
180
  the full offline flow (`ruby -Ilib examples/artifact_from_template.rb`).
181
+ ### Agent output — `produces`
182
+
183
+ `from:` renders ERB and is **only** for templates you wrote. An agent's output is model
184
+ output, so it is copied **verbatim** instead. An agent declares what it produces, and may
185
+ produce as many artifacts as it likes:
186
+
187
+ ```ruby
188
+ class Publisher < Nexo::Agent
189
+ skills :dashboard_designer
190
+ produces "dashboard.html", "digest.json", "out/*.csv"
191
+ end
192
+ ```
193
+
194
+ `run_agent` copies those out of the sandbox and records them on the run the moment the
195
+ agent finishes — **including when it suspended for approval or raised**, which are exactly
196
+ the paths where results would otherwise be lost. Under the hood that is
197
+ `artifact(name, path:)`, the verbatim third mode, which you can also call directly.
198
+
199
+ Why this matters: a workflow builds **one** sandbox that every `run_agent` borrows, so a
200
+ hand-off between stages is just "agent B reads a path agent A wrote". On `:local` that is a
201
+ real directory and survives anything. On `:docker`/`:apple` it does not — `Container#close`
202
+ is `rm -f`, and a run's sandbox is released on **every** terminal path, `suspended`
203
+ included. So pausing for a human approval destroyed everything produced before the pause,
204
+ while the identical code on `:local` kept it. Declared artifacts survive the teardown.
205
+
206
+ Declared, never inferred: sweeping the sandbox would also collect staged skill scripts,
207
+ templates and scratch files, and naming outputs is the only honest way for an agent to say
208
+ it produced nothing. A declared artifact that was never written is skipped, not fatal.
209
+
210
+ Bytes that are not valid UTF-8 are Base64-wrapped so they survive a JSON column. Read a
211
+ body back with `Nexo::Workflow.artifact_body(art)` rather than `art["content"]`, which is
212
+ Base64 text for binary.
213
+
214
+ ### Handing output to the next stage — `restore_artifacts`
215
+
216
+ ```ruby
217
+ run_agent("extract") # Extractor produces gmail.json
218
+ restore_artifacts # put recorded artifacts back in the sandbox
219
+ run_agent("synthesize") # Synthesizer reads gmail.json
220
+ ```
221
+
222
+ `Skills.materialize` gets a skill's files *into* a sandbox; this is the other direction and
223
+ then back in again, so a pipeline behaves the same whether the tier is persistent or
224
+ ephemeral — including across a suspend and resume, where the sandbox that held the files no
225
+ longer exists. Pass `only:` to restore a subset.
226
+
153
227
 
154
228
  The `artifacts` column ships with fresh installs. Apps installed before this
155
229
  release add it with:
data/lib/nexo/agent.rb CHANGED
@@ -22,7 +22,7 @@ module Nexo
22
22
  # configuration instead of silently resetting to defaults.
23
23
  CONFIG_IVARS = %i[
24
24
  @model @assume_model_exists @provider @sandbox @permissions @instructions
25
- @skills @mcp @mcp_allow @fetch_allow @search_backend
25
+ @skills @mcp @mcp_allow @fetch_allow @search_backend @requires @produces
26
26
  ].freeze
27
27
 
28
28
  class << self
@@ -102,6 +102,59 @@ module Nexo
102
102
  names.empty? ? (@skills || []) : (@skills = ((@skills || []) + names).uniq)
103
103
  end
104
104
 
105
+ # What this agent needs from whatever sandbox it runs in — checked once,
106
+ # before the first turn, against Sandbox#environment:
107
+ #
108
+ # class Publisher < Nexo::Agent
109
+ # skills :dashboard_designer
110
+ # requires commands: {"ruby" => ">= 3.1"}, locale: :utf8
111
+ # end
112
+ #
113
+ # Declared HERE, in Nexo's own vocabulary, rather than in the skill file:
114
+ # whoever wires an agent to a sandbox is the only person who can *fix* a
115
+ # gap, so the declaration and the fix live in the same place. A skill states
116
+ # its needs in prose via +compatibility:+, which is the spec's field for it
117
+ # and is aimed at a human or a model.
118
+ #
119
+ # +commands:+ maps a command that must be on +PATH+ to a version constraint
120
+ # — a Gem::Requirement string (+">= 3.1"+) or +"*"+ for "any version". A
121
+ # command whose version cannot be read (busybox +sh+ prints none) satisfies
122
+ # any constraint by being present: an unreadable version is not evidence of
123
+ # a wrong one. +locale:+ takes +:utf8+ (any UTF-8 locale — the useful case)
124
+ # or an exact String.
125
+ #
126
+ # Deliberately coarse, and never packages: gems, wheels and npm modules
127
+ # belong to the image, not here (see Sandbox#environment).
128
+ def requires(commands: nil, locale: nil)
129
+ return @requires if commands.nil? && locale.nil?
130
+
131
+ @requires = {commands: commands || {}, locale: locale}
132
+ end
133
+
134
+ # The artifacts this agent produces — sandbox paths, or globs, that a
135
+ # workflow copies out of the sandbox and records on the run as soon as the
136
+ # agent finishes:
137
+ #
138
+ # class Publisher < Nexo::Agent
139
+ # produces "dashboard.html", "digest.json", "out/*.csv"
140
+ # end
141
+ #
142
+ # Accumulating and deduped, like +skills+: an agent may produce many
143
+ # artifacts, and several +produces+ lines add up rather than replacing.
144
+ #
145
+ # This is the agent's OUTPUT, not a template. Workflow#artifact's +from:+
146
+ # mode renders ERB and is only ever for files you wrote; what an agent
147
+ # produces is model output and is copied verbatim (Workflow#artifact +path:+).
148
+ #
149
+ # Declared rather than inferred because a sweep of the sandbox would also
150
+ # collect staged skill scripts, templates and scratch files — and because
151
+ # naming outputs is the only honest way for an agent to say it produced
152
+ # nothing. A declared artifact that is absent when the agent finishes is
153
+ # skipped, not fatal.
154
+ def produces(*names)
155
+ names.empty? ? (@produces || []) : (@produces = ((@produces || []) + names).uniq)
156
+ end
157
+
105
158
  # Declares an MCP server for this agent (Spec 6). Accumulating: multiple
106
159
  # +mcp+ lines are collected. With no args (+name+ nil and +opts+ empty) it
107
160
  # reads the list (default +[]+); otherwise it appends the friendly,
@@ -255,9 +308,87 @@ module Nexo
255
308
  # so swapping +loop:+ swaps the engine without touching this class. The
256
309
  # optional +&on_event+ block receives +(type, payload)+ progress events.
257
310
  def prompt(text, max_turns: 25, &on_event)
311
+ verify_environment!
258
312
  @loop.run(agent: self, prompt: text, max_turns: max_turns, &on_event)
259
313
  end
260
314
 
315
+ # Checks the agent's declared ::requires against its sandbox, once, before the
316
+ # first turn. A no-op — and zero cost, no probe at all — when nothing is
317
+ # declared, which is the default.
318
+ #
319
+ # Fails BEFORE the model is called, because the alternative is what this
320
+ # exists to prevent: the agent spends turns deciding to run a script, runs it,
321
+ # and gets +sh: ruby: not found+ or an Encoding::InvalidByteSequenceError three
322
+ # frames into a JSON parse. One legible sentence naming what is missing and
323
+ # where beats a stack trace after the fact.
324
+ #
325
+ # Raises Nexo::EnvironmentError listing every unmet requirement at once, so a
326
+ # misprovisioned image is fixed in one pass rather than one round trip per
327
+ # missing command.
328
+ def verify_environment!
329
+ return if @environment_verified
330
+
331
+ req = self.class.requires
332
+ @environment_verified = true
333
+ return if req.nil?
334
+
335
+ missing = unmet_requirements(req, @sandbox.environment)
336
+ return if missing.empty?
337
+
338
+ raise Nexo::EnvironmentError,
339
+ "#{self.class} cannot run here: #{missing.join("; ")}. " \
340
+ "Provision the sandbox, or drop the `requires` declaration."
341
+ end
342
+
343
+ private
344
+
345
+ # The declared requirements this environment does not meet, as human-readable
346
+ # phrases. Reports the probe's own failure first when it could not run at all:
347
+ # "no ruby" and "I never got to look" are different problems with different
348
+ # fixes, and saying the first when the second is true sends the reader to the
349
+ # wrong place.
350
+ def unmet_requirements(req, env)
351
+ return ["could not inspect the sandbox (#{env[:error]})"] if env[:error]
352
+
353
+ missing = req[:commands].filter_map do |name, constraint|
354
+ found = env[:commands][name.to_s]
355
+ next "no #{name} on PATH" if found.nil?
356
+ next unless version_short?(found[:version], constraint)
357
+
358
+ "#{name} #{found[:version]} does not satisfy #{constraint}"
359
+ end
360
+ missing << locale_complaint(req[:locale], env[:locale]) if locale_unmet?(req[:locale], env[:locale])
361
+ missing
362
+ end
363
+
364
+ # Whether a found command's version fails +constraint+. A missing version, an
365
+ # unparseable constraint, or +"*"+ all pass: presence is the requirement, and
366
+ # an unreadable version is not evidence of a wrong one.
367
+ def version_short?(version, constraint)
368
+ return false if version.nil? || constraint.nil? || constraint.to_s.strip == "*"
369
+
370
+ !Gem::Requirement.new(constraint.to_s).satisfied_by?(Gem::Version.new(version))
371
+ rescue ArgumentError
372
+ false
373
+ end
374
+
375
+ # +:utf8+ asks for any UTF-8 locale (the only case that comes up in practice);
376
+ # a String asks for that exact locale. An unset locale never satisfies either
377
+ # — that is the container default, and the bug this catches.
378
+ def locale_unmet?(required, actual)
379
+ return false if required.nil?
380
+ return true if actual.nil?
381
+
382
+ (required == :utf8) ? !actual.match?(/utf-?8/i) : actual != required.to_s
383
+ end
384
+
385
+ def locale_complaint(required, actual)
386
+ want = (required == :utf8) ? "a UTF-8 locale" : "locale #{required}"
387
+ actual.nil? ? "no locale set (needs #{want})" : "locale #{actual} is not #{want}"
388
+ end
389
+
390
+ public
391
+
261
392
  # The agent's Nexo permission mode mapped onto an opt-in backend's own
262
393
  # permission vocabulary (see PERMISSION_MODE_MAP). Consumed by
263
394
  # Loops::AgentSDK; the default Loops::RubyLLM does its gating inside the
@@ -337,7 +468,7 @@ module Nexo
337
468
  texts = []
338
469
  texts << @instructions if @instructions
339
470
  texts << @sandbox.instructions if @sandbox.instructions
340
- self.class.skills.each { |name| texts << Skills.find(name).content }
471
+ self.class.skills.each { |name| texts << skill_instructions(Skills.find(name)) }
341
472
 
342
473
  texts.each_with_index do |text, i|
343
474
  chat = chat.with_instructions(text, append: i.positive?)
@@ -345,6 +476,32 @@ module Nexo
345
476
  chat
346
477
  end
347
478
 
479
+ # One skill's contribution to the system prompt: its body, plus its
480
+ # +compatibility:+ frontmatter when set.
481
+ #
482
+ # +compatibility:+ is the Agent Skills spec's own field for what a skill needs
483
+ # in order to run ("requires a Ruby interpreter and a UTF-8 locale"), and it is
484
+ # free text by design — the spec deliberately does not make it machine-checkable.
485
+ # Nexo parsed it and then dropped it, so the one place an author can state a
486
+ # requirement never reached the model. That matters now that a skill can ship a
487
+ # script Nexo stages into a sandbox (Skills.materialize): the environment is no
488
+ # longer the author's machine, and the model is the one deciding whether to run
489
+ # the thing.
490
+ #
491
+ # Labelled rather than concatenated, so the model can tell a requirement from an
492
+ # instruction. Absent or blank +compatibility:+ contributes nothing, leaving the
493
+ # prompt byte-for-byte as before for every skill that does not set it. +license:+
494
+ # and +allowed-tools:+ are deliberately NOT surfaced: the first is prompt noise,
495
+ # and the second would be a second source of truth about what an agent may do,
496
+ # competing with Nexo::Permissions, which is the real gate.
497
+ def skill_instructions(skill)
498
+ body = skill.content.to_s
499
+ compat = skill.compatibility.to_s.strip if skill.respond_to?(:compatibility)
500
+ return body if compat.nil? || compat.empty?
501
+
502
+ "#{body}\n\nCompatibility: #{compat}".strip
503
+ end
504
+
348
505
  # Lazily connects the declared MCP servers and attaches their tools, each
349
506
  # wrapped in a MCP::GatedTool so every invocation is authorized through this
350
507
  # agent's Permissions first. Attached after the four sandbox tools and the
@@ -57,6 +57,14 @@ module Nexo
57
57
  # this only gates the opt-in Turbo mirror.
58
58
  attr_accessor :broadcast_events
59
59
 
60
+ # The run store Workflow persists runs through, default +nil+ → the automatic
61
+ # choice (ActiveRecord under Rails, in-memory otherwise). Set an instance to
62
+ # override, which is what a host outside Rails needs for anything durable:
63
+ # checkpoint/suspend/resume all read state back from the store, and the
64
+ # in-memory one dies with the process. +Nexo::RunStore::Disk.new(dir:)+ is the
65
+ # shipped answer for a CLI or a script.
66
+ attr_accessor :run_store
67
+
60
68
  # The ActiveJob queue Workflow.run_later enqueues onto (Spec 11 R1), default
61
69
  # +nil+ → ActiveJob's default queue. Set to a symbol (e.g. +:nexo+) to route
62
70
  # workflow jobs to a dedicated queue.
@@ -74,6 +82,7 @@ module Nexo
74
82
  @buffer_workflow_events = false
75
83
  @session_chat_model = "Chat" # ruby_llm's own default acts_as_chat host
76
84
  @broadcast_events = false # safe default: opt in to the Turbo mirror
85
+ @run_store = nil # nil → the automatic choice (ActiveRecord under Rails, else Memory)
77
86
  @job_queue = nil # nil → ActiveJob's default queue
78
87
  end
79
88
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "fileutils"
5
+
3
6
  module Nexo
4
7
  # Storage seam for Workflow runs. Two interchangeable backends expose an
5
8
  # identical create/find interface and return run objects responding to the
@@ -11,11 +14,19 @@ module Nexo
11
14
  # +push_event(event)+, +save_events!+, +push_artifact(artifact)+,
12
15
  # +save_artifacts!+, and +save_state!+ (Spec 13).
13
16
  module RunStore
14
- # Selects a backend: the ActiveRecord store when both ::ActiveRecord::Base
15
- # and Nexo::WorkflowRun are defined (the Rails path), otherwise the in-memory
16
- # store. With no Rails loaded the AR check short-circuits, so the plain-Ruby
17
- # path never references ActiveRecord.
17
+ # Selects a backend: +Nexo.config.run_store+ when a host set one, else the
18
+ # ActiveRecord store when both ::ActiveRecord::Base and Nexo::WorkflowRun are
19
+ # defined (the Rails path), otherwise the in-memory store. With no Rails loaded
20
+ # the AR check short-circuits, so the plain-Ruby path never references
21
+ # ActiveRecord.
22
+ #
23
+ # The configured store wins over both, which is the only way a non-Rails host
24
+ # gets durability: everything that survives a process — checkpoint, suspend,
25
+ # resume, a run's recorded artifacts — is read back from the store, and Memory
26
+ # dies with the process. See Disk.
18
27
  def self.default
28
+ return Nexo.config.run_store if Nexo.config.run_store
29
+
19
30
  if defined?(::ActiveRecord::Base) && defined?(Nexo::WorkflowRun)
20
31
  ActiveRecord.new
21
32
  else
@@ -158,5 +169,137 @@ module Nexo
158
169
  .update_all(status: "running") == 1
159
170
  end
160
171
  end
172
+
173
+ # File-backed backend for a host with no database: one JSON document per run
174
+ # under +dir+, rewritten whenever the run changes. This is what makes durability
175
+ # available outside Rails — checkpoint, suspend/resume and a run's recorded
176
+ # artifacts all read state back from the store, and Memory dies with the process.
177
+ #
178
+ # Nexo.config.run_store = Nexo::RunStore::Disk.new(dir: "~/.local/state/myapp/runs")
179
+ #
180
+ # The Run is Memory's, subclassed: the shape and every read helper are shared, so
181
+ # a host written against one store behaves identically on the other. Only the
182
+ # +save_*+ hooks differ — they write the document. +push_event+/+push_artifact+
183
+ # deliberately do NOT, because Workflow always calls the matching +save_*+ right
184
+ # after, and persisting twice would double the writes for nothing.
185
+ #
186
+ # Scope, honestly: rewriting the whole document per change is fine for the run
187
+ # sizes this is meant for (a CLI's own history) and wrong for a busy multi-worker
188
+ # queue — that is what the ActiveRecord backend is for. +claim_for_resume!+ is
189
+ # atomic within a process but NOT across processes; two machines resuming the
190
+ # same run concurrently is out of scope. Runs are never pruned here: retention is
191
+ # the host's policy, and +dir+ is a plain directory it can manage.
192
+ class Disk
193
+ # A Memory::Run that writes itself to +path+ whenever Workflow saves it.
194
+ class Run < Memory::Run
195
+ # Absolute path of this run's JSON document. Assigned by the store.
196
+ attr_accessor :path
197
+
198
+ # The three save hooks Workflow calls after mutating a run. Memory's are
199
+ # no-ops because the Struct already holds everything; here they are the
200
+ # whole point.
201
+ def save_events! = persist!
202
+
203
+ # Writes the run after Workflow appended an artifact.
204
+ def save_artifacts! = persist!
205
+
206
+ # Writes the run after Workflow stored checkpoint/suspend state.
207
+ def save_state! = persist!
208
+
209
+ # Assigns attributes (Memory's behaviour) and writes the run.
210
+ def update!(attrs)
211
+ super
212
+ persist!
213
+ end
214
+
215
+ # Writes the document atomically — a temp file in the same directory, then
216
+ # a rename — so a crash mid-write cannot leave a truncated run behind.
217
+ def persist!
218
+ return if path.nil?
219
+
220
+ tmp = "#{path}.#{Process.pid}.tmp"
221
+ ::File.write(tmp, JSON.generate(to_h.except(:path)))
222
+ ::File.rename(tmp, path)
223
+ end
224
+ end
225
+
226
+ # Serializes writes within the process, mirroring Memory's store-wide lock.
227
+ MUTEX = Mutex.new
228
+
229
+ # +dir+ is created on demand; +~+ is expanded.
230
+ def initialize(dir:)
231
+ @dir = ::File.expand_path(dir)
232
+ end
233
+
234
+ # The directory runs are written to.
235
+ attr_reader :dir
236
+
237
+ # Builds a fresh +"pending"+ Run, writes it, and returns it.
238
+ def create(workflow_class:, payload:)
239
+ run = Run.new(
240
+ id: Nexo.generate_run_id, workflow_class: workflow_class, status: "pending",
241
+ payload: payload, result: nil, error: nil, events: [], artifacts: [], state: {}
242
+ )
243
+ ::FileUtils.mkdir_p(@dir)
244
+ run.path = path_for(run.id)
245
+ run.persist!
246
+ run
247
+ end
248
+
249
+ # Reads a run back by id. A miss raises KeyError, matching Memory.
250
+ def find(id)
251
+ path = path_for(id)
252
+ raise KeyError, "run not found: #{id}" unless ::File.exist?(path)
253
+
254
+ hydrate(JSON.parse(::File.read(path)), path)
255
+ end
256
+
257
+ # Every run in the directory, newest-written first. Not part of the store
258
+ # contract Workflow calls — it is here because a host with a run directory
259
+ # inevitably wants to list it ("what did the last run produce?").
260
+ def all
261
+ return [] unless ::File.directory?(@dir)
262
+
263
+ Dir.glob(::File.join(@dir, "*.json"))
264
+ .sort_by { |p| -::File.mtime(p).to_f }
265
+ .filter_map do |p|
266
+ hydrate(JSON.parse(::File.read(p)), p)
267
+ rescue JSON::ParserError, SystemCallError
268
+ # A half-written or hand-edited document must not break the listing.
269
+ nil
270
+ end
271
+ end
272
+
273
+ # Atomically claims a +"suspended"+ run for resume, re-reading from disk so a
274
+ # stale in-memory copy cannot win. Within one process only — see the class
275
+ # note.
276
+ def claim_for_resume!(run)
277
+ MUTEX.synchronize do
278
+ current = find(run.id)
279
+ return false unless current.status == "suspended"
280
+
281
+ run.status = "running"
282
+ run.path ||= path_for(run.id)
283
+ run.persist!
284
+ true
285
+ end
286
+ end
287
+
288
+ private
289
+
290
+ def path_for(id) = ::File.join(@dir, "#{id}.json")
291
+
292
+ # Rebuilds a Run from its document. Missing members read as nil/empty rather
293
+ # than raising, so a document written by an older version still loads.
294
+ def hydrate(data, path)
295
+ run = Run.new(
296
+ id: data["id"], workflow_class: data["workflow_class"], status: data["status"],
297
+ payload: data["payload"], result: data["result"], error: data["error"],
298
+ events: data["events"] || [], artifacts: data["artifacts"] || [], state: data["state"] || {}
299
+ )
300
+ run.path = path
301
+ run
302
+ end
303
+ end
161
304
  end
162
305
  end
data/lib/nexo/sandbox.rb CHANGED
@@ -60,5 +60,111 @@ module Nexo
60
60
  def mtime(path)
61
61
  nil
62
62
  end
63
+
64
+ # Commands the default #environment probe looks for. Deliberately short —
65
+ # each entry costs a +command -v+ plus one +--version+ when found — and
66
+ # extensible per call for anything else a skill's script might need.
67
+ PROBE_COMMANDS = %w[ruby python3 node sh].freeze
68
+
69
+ # The shape #environment always answers with, built fresh on every call.
70
+ # Deliberately a method and not a frozen constant: the +:commands+ Hash is
71
+ # mutated while parsing, and +CONST.dup+ is shallow — sharing one inner Hash
72
+ # let every probe accumulate into the constant, so a sandbox with no shell
73
+ # answered with the previous sandbox's findings. +:error+ is nil on a
74
+ # successful probe and carries the reason when one could not be run.
75
+ def self.empty_environment
76
+ {commands: {}, locale: nil, error: nil}
77
+ end
78
+
79
+ # One POSIX +sh+ script, no interpreter required on the far side, so the probe
80
+ # works on a busybox image. +command -v+ locates each command and a single
81
+ # +--version+ reports it; the format placeholder is filled by #environment.
82
+ PROBE_SCRIPT = <<~SH
83
+ printf 'locale=%%s\\n' "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
84
+ for c in %<commands>s; do
85
+ p=$(command -v "$c" 2>/dev/null) || continue
86
+ v=$("$c" --version 2>/dev/null | head -1)
87
+ printf 'cmd=%%s\\t%%s\\t%%s\\n' "$c" "$p" "$v"
88
+ done
89
+ SH
90
+
91
+ # What this execution environment actually provides, as data:
92
+ #
93
+ # sandbox.environment
94
+ # # => { commands: { "ruby" => { path: "/usr/local/bin/ruby", version: "4.0.0" } },
95
+ # # locale: "C.UTF-8" }
96
+ #
97
+ # #instructions describes the environment *for the model*; this is the same
98
+ # question answered *for code*, so a caller can check before staging a skill's
99
+ # script rather than discovering the answer as a stack trace several turns in.
100
+ # A container typically has no locale at all, under which Ruby's default
101
+ # external encoding is US-ASCII and a bare File.read on a UTF-8 file raises —
102
+ # and an image can carry a full Ruby toolchain and still report no locale, so
103
+ # the two are reported as independent axes.
104
+ #
105
+ # Deliberately coarse: commands on +PATH+ and the locale, never packages. Gems,
106
+ # wheels and npm modules belong to whoever builds the image, and modelling them
107
+ # here would be a cross-language dependency resolver competing with the manifest
108
+ # every ecosystem already has.
109
+ #
110
+ # Costs one #shell round trip and is memoized for the sandbox's lifetime (0.14s
111
+ # on +:local+, 0.25–0.48s on a container, measured). A sandbox with no shell
112
+ # A sandbox with no shell (Virtual) reports empty. A probe that fails for any
113
+ # other reason — the container would not start, the client is unreachable —
114
+ # also reports empty, but carries the reason under +:error+: this is
115
+ # diagnostics and must never be the reason a run dies, yet "I probed and found
116
+ # nothing" and "I could not probe" are different answers and a caller building
117
+ # an error message needs to tell them apart.
118
+ def environment(commands: PROBE_COMMANDS)
119
+ @environment ||= {}
120
+ @environment[commands] ||= probe_environment(commands)
121
+ end
122
+
123
+ private
124
+
125
+ # Runs the probe and parses its output. Rescues everything: see #environment.
126
+ def probe_environment(commands)
127
+ return Sandbox.empty_environment.merge(error: "sandbox has no shell") unless supports?(:shell)
128
+
129
+ out = shell(format(PROBE_SCRIPT, commands: Array(commands).join(" ")))
130
+ parse_environment(out[:stdout].to_s)
131
+ rescue StandardError, NotImplementedError => e
132
+ Sandbox.empty_environment.merge(error: "#{e.class}: #{salient_line(e.message)}")
133
+ end
134
+
135
+ # The most useful single line of a multi-line runtime failure. Neither the
136
+ # first line nor a blind truncation works: a container runtime prints a
137
+ # progress banner first ("[1/6] Fetching image", "container start failed")
138
+ # and puts the actual cause last ("The volume is read only"). Prefer the last
139
+ # line that announces an error, else the last non-empty line.
140
+ def salient_line(message)
141
+ lines = message.to_s.lines.map(&:strip).reject(&:empty?)
142
+ # Written as a plain scan on purpose. The idiomatic spellings deadlock the
143
+ # linter — Style/ReverseFind rewrites `reverse.find` to Enumerable#rfind,
144
+ # which does not exist on the Ruby 3.3 floor this gem supports, and
145
+ # Performance/Detect rewrites `select.last` straight back to `reverse.find`.
146
+ line = nil
147
+ lines.each { |l| line = l if l.match?(/error|fail|cause/i) }
148
+ line ||= lines.last
149
+ line.to_s[0, 300]
150
+ end
151
+
152
+ # Turns the probe's line protocol into the #environment Hash. An empty locale
153
+ # line means "unset", which is the interesting case, so it maps to +nil+ rather
154
+ # than an empty String.
155
+ def parse_environment(stdout)
156
+ env = Sandbox.empty_environment
157
+ stdout.each_line do |line|
158
+ case line.chomp
159
+ when /\Alocale=(.*)\z/
160
+ env[:locale] = $1.empty? ? nil : $1
161
+ when /\Acmd=([^\t]*)\t([^\t]*)\t(.*)\z/
162
+ # A command that prints no version (busybox sh) still counts as present;
163
+ # an unreadable version is not evidence of a wrong one.
164
+ env[:commands][$1] = {path: $2, version: $3[/(\d+(?:\.\d+)+)/]}
165
+ end
166
+ end
167
+ env
168
+ end
63
169
  end
64
170
  end
data/lib/nexo/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Nexo
4
4
  # The gem version (also used as +spec.version+ in the gemspec).
5
- VERSION = "0.8.1"
5
+ VERSION = "0.10.0"
6
6
  end
data/lib/nexo/workflow.rb CHANGED
@@ -628,6 +628,9 @@ module Nexo
628
628
  pairs.size
629
629
  end
630
630
 
631
+ # Where #artifact drops its in-sandbox copy, relative to the sandbox root.
632
+ ARTIFACTS_DIR = "artifacts"
633
+
631
634
  # Records a named deliverable on the run (Spec 7 R3). The body comes from
632
635
  # either +content:+ (used verbatim) or +from:+ (a **trusted, developer-authored**
633
636
  # ERB template — a real disk file when +File.exist?(from)+, else a staged
@@ -637,27 +640,131 @@ module Nexo
637
640
  # file you control — NEVER model output or user-uploaded content. Templates are
638
641
  # code, not data (see README).
639
642
  #
640
- # The body is written to the sandbox at +/artifacts/<name>+ (so scripts/agents
641
- # can read it during the run) and recorded on the run as a string-keyed hash
643
+ # +path:+ is the third mode and the one for **agent output**: it copies a
644
+ # sandbox path VERBATIM, with no ERB and no rendering of any kind.
645
+ # +from:+ cannot be used for this — it evaluates the file as ERB, which is
646
+ # exactly what the security note above forbids for model-written content, and
647
+ # would corrupt any file containing +<%+ regardless. Use +path:+ for anything
648
+ # an agent produced; +from:+ only for templates you wrote.
649
+ #
650
+ # The body is written to the sandbox at the workspace-relative
651
+ # +artifacts/<name>+ (so scripts/agents can read it during the run) and
652
+ # recorded on the run as a string-keyed hash
642
653
  # +{"name" =>, "content" =>, "at" =>}+, matching how #emit string-keys events
643
- # so Memory and the AR json column round-trip identically. Artifacts persist
644
- # immediately (never buffered). Raises Nexo::Error when neither +content:+ nor
645
- # +from:+ produces a body. Returns the artifact hash.
646
- def artifact(name, content: nil, from: nil, locals: {})
654
+ # so Memory and the AR json column round-trip identically. Bytes that are not
655
+ # valid UTF-8 are Base64-encoded and marked +"encoding" => "base64"+, because
656
+ # an AR json column cannot hold arbitrary binary — see .artifact_body for the
657
+ # decode side. Artifacts persist immediately (never buffered). Raises
658
+ # Nexo::Error when no mode produces a body. Returns the artifact hash.
659
+ def artifact(name, content: nil, from: nil, path: nil, locals: {})
647
660
  body = content
648
661
  if from
649
662
  template = File.exist?(from) ? File.read(from) : sandbox.read(from)
650
663
  body = ERB.new(template, trim_mode: "-").result_with_hash(locals)
651
664
  end
652
- raise Nexo::Error, "artifact #{name} needs content: or from:" if body.nil?
653
-
654
- sandbox.write("/artifacts/#{name}", body)
655
- art = {"name" => name.to_s, "content" => body, "at" => Time.now.utc.iso8601}
665
+ body = sandbox.read(path) if path
666
+ raise Nexo::Error, "artifact #{name} needs content:, from: or path:" if body.nil?
667
+
668
+ # Workspace-relative on purpose. This was an ABSOLUTE "/artifacts/#{name}",
669
+ # which every real sandbox rejects — Local#absolute and Container#guard_path
670
+ # both raise SecurityError ("path escapes sandbox") for a path outside the
671
+ # single root. #artifact therefore only ever worked on :virtual, whose
672
+ # in-memory paths are unguarded; on :local and :container it raised. Relative
673
+ # resolves under the sandbox root on all four tiers.
674
+ sandbox.write(File.join(ARTIFACTS_DIR, name.to_s), body)
675
+ art = {"name" => name.to_s, "at" => Time.now.utc.iso8601}.merge(encoded_body(body))
656
676
  @run.push_artifact(art)
657
677
  @run.save_artifacts! if @run.respond_to?(:save_artifacts!)
658
678
  art
659
679
  end
660
680
 
681
+ # The bytes of a recorded artifact, decoding the Base64 form when present.
682
+ # The counterpart to the encoding #artifact applies; use it rather than
683
+ # reading +art["content"]+ directly, which is Base64 text for binary.
684
+ def self.artifact_body(art)
685
+ body = art["content"].to_s
686
+ (art["encoding"] == "base64") ? body.unpack1("m") : body
687
+ end
688
+
689
+ # Copies every artifact an agent DECLARED into the run, immediately after that
690
+ # agent runs and before anything can tear its sandbox down.
691
+ #
692
+ # This is the missing half of Skills.materialize. That gets a skill's files
693
+ # INTO a sandbox; nothing got results back OUT, and on an ephemeral tier there
694
+ # is nowhere else for them to live: Container#close is +rm -f+, and
695
+ # Workflow.execute releases the run's sandbox on EVERY terminal path — done,
696
+ # failed, and **suspended**. So a durable-approval pause (the whole point of
697
+ # Spec 16) destroyed everything the run had produced up to the approval, while
698
+ # the identical code on +:local+ kept it, because there the sandbox is just a
699
+ # directory. Same workflow, durable on one tier and lossy on another, with no
700
+ # error either way.
701
+ #
702
+ # Declared, never inferred: a sweep of the sandbox would collect staged skill
703
+ # scripts, templates and scratch files, and an agent naming its outputs is also
704
+ # the only honest way to say "this run produced nothing". A name may be a glob
705
+ # (+"out/*.json"+), and an agent may declare as many as it likes. A declared
706
+ # artifact that does not exist is skipped rather than fatal — a run can
707
+ # legitimately not produce one, and this must never be what fails a run.
708
+ # Returns the artifacts recorded.
709
+ def collect_artifacts(agent)
710
+ # Guarded like #run_agent's +agent.close+: an injected test double or a
711
+ # non-Nexo::Agent duck simply declares nothing.
712
+ return [] unless agent.class.respond_to?(:produces)
713
+
714
+ names = agent.class.produces
715
+ return [] if names.empty?
716
+
717
+ names.flat_map { |name| artifact_paths(name) }.uniq.filter_map do |path|
718
+ artifact(File.basename(path), path: path)
719
+ rescue => e
720
+ emit(:artifact_skipped, {"path" => path.to_s, "error" => e.message})
721
+ nil
722
+ end
723
+ end
724
+
725
+ # Materializes previously collected artifacts back into the run's sandbox, so
726
+ # a later stage can read what an earlier one produced. The hand-off half: on a
727
+ # persistent tier stage N+1 just reads the path stage N wrote, and this makes
728
+ # that true on an ephemeral tier too — including across a suspend/resume, where
729
+ # the sandbox that held them no longer exists. Names nothing by default (every
730
+ # recorded artifact); pass +only:+ to restore a subset. Returns the paths written.
731
+ def restore_artifacts(only: nil, into: ".")
732
+ wanted = only && Array(only).map(&:to_s)
733
+ @run.artifacts.filter_map do |art|
734
+ name = art["name"].to_s
735
+ next if wanted && !wanted.include?(name)
736
+
737
+ path = File.join(into, name)
738
+ sandbox.write(path, self.class.artifact_body(art))
739
+ path
740
+ end
741
+ end
742
+
743
+ private
744
+
745
+ # Expands one declared name against the sandbox, so +produces "out/*.json"+
746
+ # works. A name with no glob character is taken literally and checked for
747
+ # existence, which keeps the common case off the glob path entirely.
748
+ def artifact_paths(name)
749
+ return sandbox.glob(name.to_s) if name.to_s.match?(/[*?\[]/)
750
+
751
+ sandbox.read(name.to_s)
752
+ [name.to_s]
753
+ rescue
754
+ []
755
+ end
756
+
757
+ # An artifact body plus, when the bytes are not valid UTF-8, the Base64
758
+ # envelope that lets them survive a JSON column round trip.
759
+ def encoded_body(body)
760
+ text = body.to_s
761
+ return {"content" => text} if text.dup.force_encoding(Encoding::UTF_8).valid_encoding?
762
+
763
+ {"content" => [text].pack("m0"), "encoding" => "base64"}
764
+ end
765
+
766
+ public
767
+
661
768
  # Drives the workflow's declared ::agent (Spec 8), bound to *this* run's
662
769
  # sandbox (Spec 7), forwarding every +(type, payload)+ event the agent's loop
663
770
  # yields into the run log as an +agent_*+ event, and ensuring the agent is
@@ -708,6 +815,14 @@ module Nexo
708
815
  @run.save_state! if @run.respond_to?(:save_state!)
709
816
  suspend!(reason: "approval: #{a.detail}", resume_key: a.detail.to_s)
710
817
  ensure
818
+ # Collect BEFORE teardown, and in the ensure so it also runs when the agent
819
+ # suspended for approval or raised — those are the paths where the sandbox
820
+ # dies with results still in it. Never allowed to mask the original outcome.
821
+ begin
822
+ collect_artifacts(agent) if agent
823
+ rescue => e
824
+ emit(:artifact_skipped, {"error" => e.message})
825
+ end
711
826
  # Guarded so it's safe if Spec 6 (Agent#close) isn't present in the agent,
712
827
  # or if agent construction itself raised (agent is nil).
713
828
  agent.close if agent.respond_to?(:close)
data/lib/nexo.rb CHANGED
@@ -30,6 +30,12 @@ module Nexo
30
30
  # no model and no configured default).
31
31
  class ConfigurationError < Error; end
32
32
 
33
+ # Raised when an agent's declared +requires+ are not met by the sandbox it is
34
+ # about to run in — no interpreter on +PATH+, or no UTF-8 locale. A
35
+ # configuration error in spirit, but named separately because the fix is in the
36
+ # image or the sandbox wiring rather than in the Ruby.
37
+ class EnvironmentError < ConfigurationError; end
38
+
33
39
  # Control-flow signal raised by the +:approve+ permission gate (Spec 16) when a
34
40
  # capability needs a human decision and none has been threaded in yet. Unlike
35
41
  # Permissions::Denied ("no, adapt" — tools rescue it into +{error:}+),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexo_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez