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 ].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.
-
.provider(value = nil) ⇒ Object
The provider symbol/string (e.g.
:ollama) passed straight through toRubyLLM.chat. -
.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.
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 = ( || 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
174 175 176 |
# File 'lib/nexo/agent.rb', line 174 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.
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 (value = nil) value.nil? ? (@permissions || Nexo.config.) : (@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_tools ⇒ Object
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(**) 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 |
#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).
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_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.
264 265 266 |
# File 'lib/nexo/agent.rb', line 264 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.
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 |