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



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/nexo/agent.rb', line 187

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



174
175
176
# File 'lib/nexo/agent.rb', line 174

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.



139
140
141
# File 'lib/nexo/agent.rb', line 139

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


115
116
117
118
119
# File 'lib/nexo/agent.rb', line 115

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 []).



125
126
127
# File 'lib/nexo/agent.rb', line 125

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

.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

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



152
153
154
# File 'lib/nexo/agent.rb', line 152

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.



269
270
271
# File 'lib/nexo/agent.rb', line 269

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



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/nexo/agent.rb', line 228

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
  tools = [
    Tools::ReadFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
    Tools::WriteFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
    Tools::Glob.new(sandbox: @sandbox, permissions: @permissions)
  ]
  # Attach Shell only when the sandbox can actually run one (R2), so a
  # :virtual agent stops advertising a tool it can never run.
  if @sandbox.supports?(:shell)
    tools << Tools::Shell.new(sandbox: @sandbox, permissions: @permissions)
  end
  c.with_tools(*tools)
  apply_mcp(c)
  apply_fetch(c)
  apply_search(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).



280
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
# File 'lib/nexo/agent.rb', line 280

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.



264
265
266
# File 'lib/nexo/agent.rb', line 264

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.



256
257
258
# File 'lib/nexo/agent.rb', line 256

def prompt(text, max_turns: 25, &on_event)
  @loop.run(agent: self, prompt: text, max_turns: max_turns, &on_event)
end