Class: Nexo::Agent

Inherits:
Object
  • Object
show all
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
permissions :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; end keeps 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. :ask maps to :default on purpose: human gating stays in Nexo's own on_ask path and is not delegated to the SDK.

{
  read_only: :default,
  auto: :bypass_permissions,
  ask: :default
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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 = resolve_permissions(permissions || self.class.permissions, decision: decision)
  @instructions = self.class.instructions
  @loop = loop
end

Instance Attribute Details

#assume_model_existsObject (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

#cwdObject (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

#instructionsObject (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

#loopObject (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

#modelObject (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

#permissionsObject (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
  @permissions
end

#providerObject (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

#sandboxObject (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 permissions(value = nil)
  value.nil? ? (@permissions || Nexo.config.default_permissions) : (@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_toolsObject

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(**chat_model_options)
  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

#closeObject

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_modeObject

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
  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