Class: Nexo::Agent
- Inherits:
-
Object
- Object
- Nexo::Agent
- Defined in:
- lib/nexo/agent.rb
Overview
The DSL that composes a model, a sandbox, permissions, and instructions into
a working tool-using agent. Subclass it and declare the pieces with class
macros, then call #prompt:
class CodeReviewer < Nexo::Agent
model ENV.fetch("NEXO_MODEL")
sandbox :local
:read_only
instructions "You are a careful code reviewer."
end
CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")
No sandbox, permission, or tool object is instantiated by hand. Defaults are
safe: :virtual sandbox + :read_only permissions unless overridden.
Constant Summary collapse
- CONFIG_IVARS =
The class-level ivars every macro reads/writes. Copied to a subclass in .inherited so
class Child < ConfiguredAgent; endkeeps the parent's configuration instead of silently resetting to defaults. %i[ @model @assume_model_exists @provider @sandbox @permissions @instructions @skills @mcp @mcp_allow @fetch_allow @search_backend @requires @produces ].freeze
- ALLOWED_TOOLS =
The set of tool names handed to an opt-in backend that ships its own tools (e.g. Loops::AgentSDK). The default Loops::RubyLLM ignores this — it uses the agent's own sandbox-backed tools instead.
%w[Read Write Edit Bash Glob Grep].freeze
- PERMISSION_MODE_MAP =
Maps Nexo's permission modes onto AgentSDK's own permission vocabulary, consumed by Loops::AgentSDK.
:askmaps to:defaulton purpose: human gating stays in Nexo's ownon_askpath and is not delegated to the SDK. { read_only: :default, auto: :bypass_permissions, ask: :default }.freeze
Instance Attribute Summary collapse
-
#assume_model_exists ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#cwd ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#instructions ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#loop ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#model ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#permissions ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#provider ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop. -
#sandbox ⇒ Object
readonly
The resolved per-instance configuration (arg → class macro → config): the working directory, model, provider,
assume_model_existsflag, the resolved Sandbox and Permissions, the system instructions, and the injected Loop.
Class Method Summary collapse
-
.assume_model_exists(value = nil) ⇒ Object
Opt out of ruby_llm's models.json registry validation for this agent so it can run unregistered models (Ollama tags, self-hosted, brand-new releases).
-
.fetch_allow(*hosts) ⇒ Object
The host allow-list scoping this agent's Nexo::Tools::Fetch (Spec 9).
-
.inherited(subclass) ⇒ Object
Carries the parent's macro configuration onto a subclass.
-
.instructions(value = nil) ⇒ Object
The instructions macro.
-
.mcp(name = nil, **opts) ⇒ Object
Declares an MCP server for this agent (Spec 6).
-
.mcp_allow(*names) ⇒ Object
The MCP tool-name allow-list threaded into this agent's Permissions (see
mcp_allow:in #resolve_permissions). -
.model(value = nil) ⇒ Object
Each macro is a reader with no argument and a writer with one.
-
.permissions(value = nil) ⇒ Object
The permissions macro.
-
.produces(*names) ⇒ Object
The artifacts this agent produces — sandbox paths, or globs, that a workflow copies out of the sandbox and records on the run as soon as the agent finishes:.
-
.provider(value = nil) ⇒ Object
The provider symbol/string (e.g.
:ollama) passed straight through toRubyLLM.chat. -
.requires(commands: nil, locale: nil) ⇒ Object
What this agent needs from whatever sandbox it runs in — checked once, before the first turn, against Sandbox#environment:.
-
.sandbox(value = nil, **opts) ⇒ Object
The sandbox macro.
-
.search_backend(obj = nil) ⇒ Object
The host-injected search backend for this agent's Nexo::Tools::WebSearch (Spec 19).
-
.skills(*names) ⇒ Object
Declares the skills attached to this agent.
Instance Method Summary collapse
-
#allowed_tools ⇒ Object
The tool names handed to an opt-in backend that ships its own tools.
-
#chat(base: nil) ⇒ Object
Builds a configured chat with the four sandbox-backed tools attached as instances bound to this agent's sandbox and permissions, then layers on the instructions of every declared skill.
-
#close ⇒ Object
Releases any MCP server connections held by this agent instance.
-
#initialize(cwd: Dir.pwd, model: nil, sandbox: nil, permissions: nil, decision: nil, loop: Loops::RubyLLM.new) ⇒ Agent
constructor
Every argument is optional; each resolves arg -> class macro -> config.
-
#permission_mode ⇒ Object
The agent's Nexo permission mode mapped onto an opt-in backend's own permission vocabulary (see PERMISSION_MODE_MAP).
-
#prompt(text, max_turns: 25, &on_event) ⇒ Object
Runs one prompt through the agent by delegating to the injected loop.
-
#verify_environment! ⇒ Object
Checks the agent's declared ::requires against its sandbox, once, before the first turn.
Constructor Details
#initialize(cwd: Dir.pwd, model: nil, sandbox: nil, permissions: nil, decision: nil, loop: Loops::RubyLLM.new) ⇒ Agent
Every argument is optional; each resolves arg -> class macro -> config.
Symbol shorthands (:virtual/:local, :read_only/:auto/:ask/:approve) and
pre-built Sandbox/Permissions instances are both accepted. loop: injects
the engine that drives a prompt — the provider-neutral Loops::RubyLLM by
default, or an opt-in backend like Loops::AgentSDK.
decision: (Spec 16, default nil) is a per-run approval answer
(+true|false+) threaded into the resolved Permissions so an
:approve gate allows/denies instead of raising Nexo::ApprovalRequired.
It only supplies the answer to an already-+:approve+ gate — it never
widens capability. Workflow#run_agent passes it on the resume pass.
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/nexo/agent.rb', line 240 def initialize(cwd: Dir.pwd, model: nil, sandbox: nil, permissions: nil, decision: nil, loop: Loops::RubyLLM.new) @cwd = cwd @model = model || self.class.model || Nexo.config.default_model if @model.nil? raise ConfigurationError, "no model set — use the `model` macro, pass model:, or set Nexo.config.default_model" end @assume_model_exists = self.class.assume_model_exists @provider = self.class.provider if @assume_model_exists && @provider.nil? raise ConfigurationError, "assume_model_exists is set but no provider given — add the `provider` macro (e.g. `provider :ollama`)" end # The agent owns (and so closes) its sandbox only when it resolved one from # its own config. A sandbox injected via +sandbox:+ (e.g. Workflow#run_agent # sharing the run's sandbox) is BORROWED — closing it would strand the owner. @owns_sandbox = sandbox.nil? @sandbox = resolve_sandbox(sandbox || self.class.sandbox) @permissions = ( || self.class., decision: decision) @instructions = self.class.instructions @loop = loop end |
Instance Attribute Details
#assume_model_exists ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def assume_model_exists @assume_model_exists end |
#cwd ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def cwd @cwd end |
#instructions ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def instructions @instructions end |
#loop ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def loop @loop end |
#model ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def model @model end |
#permissions ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def @permissions end |
#provider ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def provider @provider end |
#sandbox ⇒ Object (readonly)
The resolved per-instance configuration (arg → class macro → config): the
working directory, model, provider, assume_model_exists flag, the resolved
Sandbox and Permissions, the system instructions, and the injected Loop.
227 228 229 |
# File 'lib/nexo/agent.rb', line 227 def sandbox @sandbox end |
Class Method Details
.assume_model_exists(value = nil) ⇒ Object
Opt out of ruby_llm's models.json registry validation for this agent so
it can run unregistered models (Ollama tags, self-hosted, brand-new
releases). Boolean opt-in: because nil? still distinguishes read from
write, assume_model_exists false is an explicit write (sets false),
not a read. Unset reads as false, keeping registry validation on.
55 56 57 |
# File 'lib/nexo/agent.rb', line 55 def assume_model_exists(value = nil) value.nil? ? (@assume_model_exists || false) : (@assume_model_exists = value) end |
.fetch_allow(*hosts) ⇒ Object
The host allow-list scoping this agent's Nexo::Tools::Fetch (Spec 9).
Subdomain-aware, exact-host-suffix matching only — no globs. Like
+skills+/+mcp_allow+, with args it ACCUMULATES the flattened hosts as
strings (deduped); with none it reads the list (default []).
Declaring fetch_allow only SCOPES hosts — it does not grant the :fetch
capability, which is default-denied like :shell. An agent that wants
egress must also run under :auto or carry an explicit
Permissions.new(mode: :read_only, allow: %i[read glob fetch]). Both locks
must open before a fetch happens.
192 193 194 |
# File 'lib/nexo/agent.rb', line 192 def fetch_allow(*hosts) hosts.empty? ? (@fetch_allow || []) : (@fetch_allow = ((@fetch_allow || []) + hosts.flatten.map(&:to_s)).uniq) end |
.inherited(subclass) ⇒ Object
Carries the parent's macro configuration onto a subclass. Arrays/Hashes are
duped so a subclass extending an accumulating macro (e.g. a second mcp
line) never mutates the parent's collection; scalars and shared config
instances (a class-level Permissions) are copied by reference.
33 34 35 36 37 38 39 40 41 42 |
# File 'lib/nexo/agent.rb', line 33 def inherited(subclass) super CONFIG_IVARS.each do |ivar| next unless instance_variable_defined?(ivar) value = instance_variable_get(ivar) value = value.dup if value.is_a?(Array) || value.is_a?(Hash) subclass.instance_variable_set(ivar, value) end end |
.instructions(value = nil) ⇒ Object
The instructions macro. With no argument it reads the stored system
prompt (default nil); with a value it records it.
87 88 89 |
# File 'lib/nexo/agent.rb', line 87 def instructions(value = nil) value.nil? ? @instructions : (@instructions = value) end |
.mcp(name = nil, **opts) ⇒ Object
Declares an MCP server for this agent (Spec 6). Accumulating: multiple
mcp lines are collected. With no args (+name+ nil and opts empty) it
reads the list (default []); otherwise it appends the friendly,
transport-shaped config consumed by Nexo::MCP.build.
class InboxDigest < Nexo::Agent
model ENV.fetch("NEXO_MODEL")
mcp :gmail, transport: :stdio, command: "npx", args: %w[-y srv-gmail]
mcp :fetch, transport: :sse, url: "http://localhost:8080/sse"
end
168 169 170 171 172 |
# File 'lib/nexo/agent.rb', line 168 def mcp(name = nil, **opts) return @mcp || [] if name.nil? && opts.empty? (@mcp ||= []) << opts.merge(name: name) end |
.mcp_allow(*names) ⇒ Object
The MCP tool-name allow-list threaded into this agent's Permissions (see
mcp_allow: in #resolve_permissions). Exact tool-name match only — no
globs. Like skills, with args it ACCUMULATES the flattened names as
strings (deduped); with none it reads the list (default []).
178 179 180 |
# File 'lib/nexo/agent.rb', line 178 def mcp_allow(*names) names.empty? ? (@mcp_allow || []) : (@mcp_allow = ((@mcp_allow || []) + names.flatten.map(&:to_s)).uniq) end |
.model(value = nil) ⇒ Object
Each macro is a reader with no argument and a writer with one. Unset +sandbox+/+permissions+ fall back to the harness-wide config defaults.
46 47 48 |
# File 'lib/nexo/agent.rb', line 46 def model(value = nil) value.nil? ? @model : (@model = value) end |
.permissions(value = nil) ⇒ Object
The permissions macro. With no argument it reads the configured value
(falling back to the harness-wide default, :read_only); with a value it
records the mode symbol or a pre-built Permissions instance.
81 82 83 |
# File 'lib/nexo/agent.rb', line 81 def (value = nil) value.nil? ? (@permissions || Nexo.config.) : (@permissions = value) end |
.produces(*names) ⇒ Object
The artifacts this agent produces — sandbox paths, or globs, that a workflow copies out of the sandbox and records on the run as soon as the agent finishes:
class Publisher < Nexo::Agent
produces "dashboard.html", "digest.json", "out/*.csv"
end
Accumulating and deduped, like skills: an agent may produce many
artifacts, and several produces lines add up rather than replacing.
This is the agent's OUTPUT, not a template. Workflow#artifact's from:
mode renders ERB and is only ever for files you wrote; what an agent
produces is model output and is copied verbatim (Workflow#artifact path:).
Declared rather than inferred because a sweep of the sandbox would also collect staged skill scripts, templates and scratch files — and because naming outputs is the only honest way for an agent to say it produced nothing. A declared artifact that is absent when the agent finishes is skipped, not fatal.
154 155 156 |
# File 'lib/nexo/agent.rb', line 154 def produces(*names) names.empty? ? (@produces || []) : (@produces = ((@produces || []) + names).uniq) end |
.provider(value = nil) ⇒ Object
The provider symbol/string (e.g. :ollama) passed straight through to
RubyLLM.chat. Required whenever assume_model_exists is set, since
ruby_llm cannot infer a provider once the registry lookup is skipped.
Unset resolves to nil.
63 64 65 |
# File 'lib/nexo/agent.rb', line 63 def provider(value = nil) value.nil? ? @provider : (@provider = value) end |
.requires(commands: nil, locale: nil) ⇒ Object
What this agent needs from whatever sandbox it runs in — checked once, before the first turn, against Sandbox#environment:
class Publisher < Nexo::Agent
skills :dashboard_designer
requires commands: {"ruby" => ">= 3.1"}, locale: :utf8
end
Declared HERE, in Nexo's own vocabulary, rather than in the skill file:
whoever wires an agent to a sandbox is the only person who can fix a
gap, so the declaration and the fix live in the same place. A skill states
its needs in prose via compatibility:, which is the spec's field for it
and is aimed at a human or a model.
commands: maps a command that must be on PATH to a version constraint
— a Gem::Requirement string (+">= 3.1"+) or "*" for "any version". A
command whose version cannot be read (busybox sh prints none) satisfies
any constraint by being present: an unreadable version is not evidence of
a wrong one. locale: takes :utf8 (any UTF-8 locale — the useful case)
or an exact String.
Deliberately coarse, and never packages: gems, wheels and npm modules belong to the image, not here (see Sandbox#environment).
128 129 130 131 132 |
# File 'lib/nexo/agent.rb', line 128 def requires(commands: nil, locale: nil) return @requires if commands.nil? && locale.nil? @requires = {commands: commands || {}, locale: locale} end |
.sandbox(value = nil, **opts) ⇒ Object
The sandbox macro. With no argument it reads the configured value
(falling back to the harness-wide default). With a bare value it stores a
symbol/instance as before; with keywords it stores an options Hash
(+{ type: value, **opts }+) resolved by Nexo::Sandboxes.resolve — e.g.
sandbox :docker, image: "node:22-slim", binds: {...}.
72 73 74 75 76 |
# File 'lib/nexo/agent.rb', line 72 def sandbox(value = nil, **opts) return @sandbox || Nexo.config.default_sandbox if value.nil? && opts.empty? @sandbox = opts.empty? ? value : {type: value, **opts} end |
.search_backend(obj = nil) ⇒ Object
The host-injected search backend for this agent's Nexo::Tools::WebSearch
(Spec 19). Reader/writer by nil-check, matching the model macro
convention. Any object responding to search(query, **opts) that
returns an Enumerable of {title:, url:, snippet:} rows works — Nexo ships
no backend. Unset reads as nil, in which case no search tool is attached.
Declaring search_backend does not grant the :search capability, which is
default-denied like +:fetch+/+:shell+. An agent that wants web discovery must
also run under :auto or carry an explicit allow: [..., :search].
205 206 207 |
# File 'lib/nexo/agent.rb', line 205 def search_backend(obj = nil) obj.nil? ? @search_backend : (@search_backend = obj) end |
.skills(*names) ⇒ Object
Declares the skills attached to this agent. With no args it returns the
configured list (default []); with args it ACCUMULATES the names
(deduped), so multiple skills lines add up instead of the last one
silently replacing the earlier ones — consistent with mcp.
class TriageAgent < Nexo::Agent
model ENV.fetch("NEXO_MODEL")
skills :triage # one macro, no loader setup
skills :formatting # adds to :triage, does not replace it
end
101 102 103 |
# File 'lib/nexo/agent.rb', line 101 def skills(*names) names.empty? ? (@skills || []) : (@skills = ((@skills || []) + names).uniq) end |
Instance Method Details
#allowed_tools ⇒ Object
The tool names handed to an opt-in backend that ships its own tools.
406 407 408 |
# File 'lib/nexo/agent.rb', line 406 def allowed_tools ALLOWED_TOOLS end |
#chat(base: nil) ⇒ Object
Builds a configured chat with the four sandbox-backed tools attached as instances bound to this agent's sandbox and permissions, then layers on the instructions of every declared skill.
base: lets a Nexo::Session pass a persisted acts_as_chat record
(hydrated via ruby_llm's #to_llm delegation) so the very same wiring —
instructions, the four sandbox tools, skills, MCP, fetch — is applied onto
the continuing thread instead of a fresh chat. When base is nil the path
is byte-for-byte the standalone-agent build: a fresh RubyLLM.chat. A
session therefore changes only memory/persistence, never authority or
execution — the record supplies the thread, the agent supplies the wiring.
Re-applying @instructions on every resume stays idempotent because the
persisted-chat #with_instructions (default append: false) replaces the
stored role: :system messages rather than appending, so the thread keeps
exactly one copy across resumes (VERIFIED, ruby_llm 1.16.0).
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'lib/nexo/agent.rb', line 281 def chat(base: nil) c = base || RubyLLM.chat(**) c = apply_instructions(c) # One ReadTracker per chat, shared by ReadFile (records) and WriteFile # (enforces the read-before-write + stale guard) — R4. tracker = ReadTracker.new # A sandbox tool is attached only when the agent could actually use it, on # BOTH axes: the sandbox has to support the capability (R2 — a :virtual # sandbox has no shell) and the gate must not deny it statically (a # :read_only agent can never be authorized for :write or :shell). Otherwise # a guaranteed failure sits in the tool schema on every turn and models do # try it. This is the same attach-time gating apply_fetch/apply_search # already apply; #authorize! stays the actual boundary either way. tools = [Tools::ReadFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker)] unless @permissions.never_allows?(:write) tools << Tools::WriteFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker) end tools << Tools::Glob.new(sandbox: @sandbox, permissions: @permissions) if @sandbox.supports?(:shell) && !@permissions.never_allows?(:shell) tools << Tools::Shell.new(sandbox: @sandbox, permissions: @permissions) end c.with_tools(*tools) apply_mcp(c) apply_fetch(c) apply_search(c) apply_tool_concurrency(c) c end |
#close ⇒ Object
Releases any MCP server connections held by this agent instance. Clients are
memoized on the instance and reused across prompts (Spec 6 lifecycle default),
so a long-lived agent holding stdio/SSE servers should call #close when
done. Idempotent: safe to call with no MCP servers attached or more than once.
VERIFY (Group 0, ruby_llm-mcp 1.0.0): the client teardown method is #stop
(guarded by respond_to?, falling back to #close for other client shapes).
417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 |
# File 'lib/nexo/agent.rb', line 417 def close @mcp_clients&.each do |client| if client.respond_to?(:stop) client.stop elsif client.respond_to?(:close) client.close end rescue # Best-effort teardown: a failing stop on one client must not strand the # remaining clients or leave @mcp_clients set (breaking idempotency). # Swallow and continue to the next. end @mcp_clients = nil # Release the sandbox too, so a container/remote-backed agent doesn't leak # its container/connection when the caller only remembers +close+. Only close # a sandbox this agent OWNS (resolved from its own config) — a borrowed one # (injected via +sandbox:+, e.g. Workflow#run_agent's shared run sandbox) is # the injector's to close. The base Sandbox#close is a no-op, so this is safe # and idempotent for :virtual/:local. Best-effort: never raise out of close. if @owns_sandbox begin @sandbox&.close rescue # Swallow: close must stay idempotent and non-raising. end end end |
#permission_mode ⇒ Object
The agent's Nexo permission mode mapped onto an opt-in backend's own permission vocabulary (see PERMISSION_MODE_MAP). Consumed by Loops::AgentSDK; the default Loops::RubyLLM does its gating inside the sandbox-backed tools and ignores this.
401 402 403 |
# File 'lib/nexo/agent.rb', line 401 def PERMISSION_MODE_MAP.fetch(@permissions.mode, :default) end |
#prompt(text, max_turns: 25, &on_event) ⇒ Object
Runs one prompt through the agent by delegating to the injected loop. The
loop body that used to live here is now in Loops::RubyLLM (the default),
so swapping loop: swaps the engine without touching this class. The
optional &on_event block receives (type, payload) progress events.
315 316 317 318 |
# File 'lib/nexo/agent.rb', line 315 def prompt(text, max_turns: 25, &on_event) verify_environment! @loop.run(agent: self, prompt: text, max_turns: max_turns, &on_event) end |
#verify_environment! ⇒ Object
Checks the agent's declared ::requires against its sandbox, once, before the first turn. A no-op — and zero cost, no probe at all — when nothing is declared, which is the default.
Fails BEFORE the model is called, because the alternative is what this
exists to prevent: the agent spends turns deciding to run a script, runs it,
and gets sh: ruby: not found or an Encoding::InvalidByteSequenceError three
frames into a JSON parse. One legible sentence naming what is missing and
where beats a stack trace after the fact.
Raises Nexo::EnvironmentError listing every unmet requirement at once, so a misprovisioned image is fixed in one pass rather than one round trip per missing command.
333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/nexo/agent.rb', line 333 def verify_environment! return if @environment_verified req = self.class.requires @environment_verified = true return if req.nil? missing = unmet_requirements(req, @sandbox.environment) return if missing.empty? raise Nexo::EnvironmentError, "#{self.class} cannot run here: #{missing.join("; ")}. " \ "Provision the sandbox, or drop the `requires` declaration." end |