nexo_ai 0.8.1 → 0.9.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: 6c12fe51de0643c44b5ede5f140d45a2c2c50b15b8f357ad0e40132bcd40034c
4
+ data.tar.gz: 6c98b8a0adab754ba7ea290ca2227df8f464f9a71407ad4956b6a985b5a40346
5
5
  SHA512:
6
- metadata.gz: 985b074e6c59e6e5aa2353beef2fd14890efceb5f8cd20d7c3611ced5861f3fdf7a086a12b67a90ba2fbd8358c5288ac3c1e774bb3c2bb512a0868f2cc053b75
7
- data.tar.gz: '091eff209c74596c4acb2b767182c21fd7f717416fc8a8ef9b3150e245b65d11ab185d8eb546d63dd508d36f7e81faced7c182fcd26fcbe3ad2998561e06965f'
6
+ metadata.gz: 221c754bcd1d4d46bf8550c5411c95e194840b5c9de42dbf66a4035a7b5d95bdccb2fb8c5a5ade8eccf88e238e763cb59b9e97d2b40d7d92fc9aee273dfe8376
7
+ data.tar.gz: 337b96d65dcbfcb2d879ed2e6dc62867d1a27a768f0693e007db8a0f562de0a6e5d3d129f0092d804eb531cbac0b375b7b97731016e1551cd9d1c164bae94e05
data/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.9.0] - 2026-08-20
4
+
5
+ Skills and sandboxes learn to talk about the environment, and an agent's output finally
6
+ survives its sandbox. A skill can state what it needs (`compatibility:`, which was parsed
7
+ and then dropped), a sandbox can report what it has (`Sandbox#environment`), and an agent
8
+ can require the two to match before the first turn. Separately, an agent's declared output
9
+ is now collected before teardown — a workflow releases its sandbox on **every** terminal
10
+ path, `suspended` included, so pausing for a human approval used to destroy everything the
11
+ run had produced on a container tier while the identical code on `:local` kept it. Along
12
+ the way `Workflow#artifact` turned out never to have worked outside `:virtual`. Verified
13
+ end to end against Docker 29.4.0 and Apple `container` 1.2.2.
14
+
15
+ ### Added
16
+
17
+ - **A skill's `compatibility:` frontmatter now reaches the model.** `apply_instructions`
18
+ passed only `skill.content`, so `compatibility:` — the Agent Skills spec's own field for
19
+ stating what a skill needs in order to run — was parsed and then dropped. It is now
20
+ appended to the skill's body as a labelled `Compatibility: …` line. Skills that do not
21
+ set it contribute exactly their body, byte for byte, as before. `license:` and
22
+ `allowed-tools:` stay unsurfaced on purpose: the first is prompt noise, and the second
23
+ would be a second source of truth about what an agent may do, competing with
24
+ `Nexo::Permissions`.
25
+ - **`Sandbox#environment` — a sandbox can report what it actually provides.** One POSIX
26
+ `sh` round trip returns the commands on `PATH` (with versions) and the locale, memoized
27
+ for the sandbox's lifetime and extensible per call. It never raises: a shell-less
28
+ sandbox and a probe that could not run both answer empty, carrying the reason under
29
+ `:error`, because "there is no ruby" and "I never got to look" have different fixes.
30
+ Deliberately coarse — commands and locale, never packages.
31
+ - **`requires` on an agent, checked before the first turn.**
32
+ `requires commands: {"ruby" => ">= 3.1"}, locale: :utf8` raises `Nexo::EnvironmentError`
33
+ listing every unmet requirement at once, instead of letting the run reach
34
+ `sh: ruby: not found` several turns in. Declaring nothing is the default and costs no
35
+ probe. Motivating case: a container has **no locale even when it has a full toolchain**,
36
+ under which Ruby's default external encoding is `US-ASCII` and a bare `File.read` on a
37
+ UTF-8 file raises — measured on Docker and Apple `container` alike.
38
+ - **`Nexo::EnvironmentError`** (a `ConfigurationError`) for the above: the fix is in the
39
+ image or the sandbox wiring rather than in the Ruby.
40
+ - **`produces` on an agent, collected into the run before the sandbox dies.** An agent
41
+ declares the artifacts it writes (`produces "dashboard.html", "out/*.json"` — many per
42
+ agent, globs allowed) and `run_agent` copies them out and records them on the run the
43
+ moment the agent finishes, **including when it suspended for approval or raised**. A
44
+ workflow releases its sandbox on every terminal path and `Container#close` is `rm -f`,
45
+ so before this a durable-approval pause destroyed everything the run had produced, while
46
+ the identical code on `:local` kept it. Verified end to end through real ephemeral
47
+ containers on Docker 29.4.0 and Apple `container` 1.2.2.
48
+ - **`Workflow#artifact(name, path:)`** — a verbatim third mode. `from:` renders ERB and is
49
+ documented as trusted templates only, which agent output can never be; `path:` copies
50
+ sandbox bytes with no rendering. Non-UTF-8 bytes are Base64-wrapped so they survive a
51
+ JSON column; `Workflow.artifact_body(art)` decodes.
52
+ - **`Workflow#restore_artifacts`** — materializes recorded artifacts back into the run's
53
+ sandbox, so a later stage can read what an earlier one produced even on an ephemeral
54
+ tier. The missing counterpart to `Skills.materialize`.
55
+
56
+ ### Fixed
57
+
58
+ - **`Workflow#artifact` wrote to an absolute `/artifacts/<name>`, which every real sandbox
59
+ rejects.** `Local#absolute` and `Container#guard_path` both raise
60
+ `SecurityError: path escapes sandbox`, so the feature only ever worked on `:virtual`,
61
+ whose in-memory paths are unguarded. The copy is now workspace-relative
62
+ (`artifacts/<name>`) and resolves under the root on all four tiers.
63
+
64
+ ### Changed
65
+
66
+ - **The in-sandbox copy of an artifact moved from `/artifacts/<name>` to
67
+ `artifacts/<name>`**, relative to the sandbox root. Visible only to `:virtual` users —
68
+ the only ones for whom `#artifact` worked at all — and only if they read the copy back
69
+ by absolute path. The recorded `run.artifacts` data is unchanged.
70
+
3
71
  ## [0.8.1] - 2026-08-19
4
72
 
5
73
  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
@@ -115,7 +115,7 @@ the count staged.
115
115
 
116
116
  `artifact(name, content:)` records a **named deliverable** on the run — a digest,
117
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
118
+ sandbox at `artifacts/<name>` (so later steps can read it) and recorded on the
119
119
  run. `run.artifacts` reads it back as an **ordered array** of string-keyed hashes
120
120
  (`{"name" =>, "content" =>, "at" =>}`) in both stores:
121
121
 
@@ -150,6 +150,52 @@ artifact("digest.md", from: "app/templates/digest.md.erb",
150
150
 
151
151
  See [`examples/artifact_from_template.rb`](../examples/artifact_from_template.rb) for
152
152
  the full offline flow (`ruby -Ilib examples/artifact_from_template.rb`).
153
+ ### Agent output — `produces`
154
+
155
+ `from:` renders ERB and is **only** for templates you wrote. An agent's output is model
156
+ output, so it is copied **verbatim** instead. An agent declares what it produces, and may
157
+ produce as many artifacts as it likes:
158
+
159
+ ```ruby
160
+ class Publisher < Nexo::Agent
161
+ skills :dashboard_designer
162
+ produces "dashboard.html", "digest.json", "out/*.csv"
163
+ end
164
+ ```
165
+
166
+ `run_agent` copies those out of the sandbox and records them on the run the moment the
167
+ agent finishes — **including when it suspended for approval or raised**, which are exactly
168
+ the paths where results would otherwise be lost. Under the hood that is
169
+ `artifact(name, path:)`, the verbatim third mode, which you can also call directly.
170
+
171
+ Why this matters: a workflow builds **one** sandbox that every `run_agent` borrows, so a
172
+ hand-off between stages is just "agent B reads a path agent A wrote". On `:local` that is a
173
+ real directory and survives anything. On `:docker`/`:apple` it does not — `Container#close`
174
+ is `rm -f`, and a run's sandbox is released on **every** terminal path, `suspended`
175
+ included. So pausing for a human approval destroyed everything produced before the pause,
176
+ while the identical code on `:local` kept it. Declared artifacts survive the teardown.
177
+
178
+ Declared, never inferred: sweeping the sandbox would also collect staged skill scripts,
179
+ templates and scratch files, and naming outputs is the only honest way for an agent to say
180
+ it produced nothing. A declared artifact that was never written is skipped, not fatal.
181
+
182
+ Bytes that are not valid UTF-8 are Base64-wrapped so they survive a JSON column. Read a
183
+ body back with `Nexo::Workflow.artifact_body(art)` rather than `art["content"]`, which is
184
+ Base64 text for binary.
185
+
186
+ ### Handing output to the next stage — `restore_artifacts`
187
+
188
+ ```ruby
189
+ run_agent("extract") # Extractor produces gmail.json
190
+ restore_artifacts # put recorded artifacts back in the sandbox
191
+ run_agent("synthesize") # Synthesizer reads gmail.json
192
+ ```
193
+
194
+ `Skills.materialize` gets a skill's files *into* a sandbox; this is the other direction and
195
+ then back in again, so a pipeline behaves the same whether the tier is persistent or
196
+ ephemeral — including across a suspend and resume, where the sandbox that held the files no
197
+ longer exists. Pass `only:` to restore a subset.
198
+
153
199
 
154
200
  The `artifacts` column ships with fresh installs. Apps installed before this
155
201
  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
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.9.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.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez