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 +4 -4
- data/CHANGELOG.md +68 -0
- data/docs/sandboxes.md +79 -0
- data/docs/skills.md +29 -0
- data/docs/workflows.md +47 -1
- data/lib/nexo/agent.rb +159 -2
- data/lib/nexo/sandbox.rb +106 -0
- data/lib/nexo/version.rb +1 -1
- data/lib/nexo/workflow.rb +125 -10
- data/lib/nexo.rb +6 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c12fe51de0643c44b5ede5f140d45a2c2c50b15b8f357ad0e40132bcd40034c
|
|
4
|
+
data.tar.gz: 6c98b8a0adab754ba7ea290ca2227df8f464f9a71407ad4956b6a985b5a40346
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
|
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
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
|
-
#
|
|
641
|
-
#
|
|
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.
|
|
644
|
-
#
|
|
645
|
-
#
|
|
646
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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:}+),
|