nexo_ai 0.1.0 → 0.7.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/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- metadata +185 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Nexo
|
|
6
|
+
# Namespace for Nexo's Rails generators (+rails g nexo:install+,
|
|
7
|
+
# +nexo:workflows+, +nexo:artifacts+, +nexo:state+, +nexo:skill+). Each is
|
|
8
|
+
# Rails-coupled and never autoloaded by the plain-Ruby core.
|
|
9
|
+
module Generators
|
|
10
|
+
# Sets up the conventional Nexo layout in a host Rails app:
|
|
11
|
+
#
|
|
12
|
+
# rails g nexo:install
|
|
13
|
+
#
|
|
14
|
+
# creates the app/agents, app/workflows and app/skills directories (each
|
|
15
|
+
# with a committable .keep) and a provider-neutral config/initializers/nexo.rb.
|
|
16
|
+
class InstallGenerator < Rails::Generators::Base
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
# Generation step: create the app/agents, app/workflows, and app/skills
|
|
20
|
+
# directories, each with a committable +.keep+.
|
|
21
|
+
def create_directories
|
|
22
|
+
%w[app/agents app/workflows app/skills].each do |dir|
|
|
23
|
+
empty_directory dir
|
|
24
|
+
create_file "#{dir}/.keep" unless File.exist?("#{dir}/.keep")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generation step: copy the provider-neutral
|
|
29
|
+
# +config/initializers/nexo.rb+ into the host app.
|
|
30
|
+
def copy_initializer
|
|
31
|
+
copy_file "nexo.rb", "config/initializers/nexo.rb"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Nexo.configure do |config|
|
|
2
|
+
# Provider-neutral: any ruby_llm-supported model. There is intentionally no default.
|
|
3
|
+
config.default_model = ENV["NEXO_MODEL"]
|
|
4
|
+
config.default_sandbox = :virtual # :virtual | :local (:remote in Spec 4)
|
|
5
|
+
config.default_permissions = :read_only # :read_only | :auto | :ask
|
|
6
|
+
config.skills_path = Rails.root.join("app/skills")
|
|
7
|
+
|
|
8
|
+
# Background execution (Spec 11): MyWorkflow.run_later(payload) enqueues on your
|
|
9
|
+
# existing ActiveJob adapter. Route workflow jobs to a dedicated queue (nil uses
|
|
10
|
+
# ActiveJob's default queue):
|
|
11
|
+
# config.job_queue = :nexo
|
|
12
|
+
|
|
13
|
+
# Live progress over Turbo Streams (opt-in; requires turbo-rails). Runs broadcast
|
|
14
|
+
# their events as `nexo.workflow.event` notifications regardless; set this to true
|
|
15
|
+
# to also mirror them over Turbo (override app/views/nexo/_event.html.erb to style
|
|
16
|
+
# them). Without Turbo this is a no-op — subscribe to the notifications yourself
|
|
17
|
+
# for logging/metrics.
|
|
18
|
+
# config.broadcast_events = true
|
|
19
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/named_base"
|
|
5
|
+
|
|
6
|
+
module Nexo
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds an Agent Skills package in a host Rails app:
|
|
9
|
+
#
|
|
10
|
+
# rails g nexo:skill triage
|
|
11
|
+
#
|
|
12
|
+
# creates +app/skills/triage/SKILL.md+ (valid frontmatter + placeholder
|
|
13
|
+
# process steps, per agentskills.io/specification) and a kept
|
|
14
|
+
# +app/skills/triage/references/+ directory for supporting docs the skill can
|
|
15
|
+
# cite. Reference it from an agent with the +skills :triage+ macro.
|
|
16
|
+
#
|
|
17
|
+
# Rails-coupled, like Nexo's other generators: it requires +rails/generators+
|
|
18
|
+
# at load time and is never autoloaded by the plain-Ruby core, so
|
|
19
|
+
# +require "nexo"+ with no Rails present neither defines nor fails on it.
|
|
20
|
+
class SkillGenerator < Rails::Generators::NamedBase
|
|
21
|
+
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
|
|
23
|
+
# Generation step: scaffold +app/skills/<name>/+ with a kept +references/+
|
|
24
|
+
# directory and a valid +SKILL.md+ rendered from the template.
|
|
25
|
+
def create_skill_package
|
|
26
|
+
empty_directory File.join(skill_root, "references")
|
|
27
|
+
# An empty references/ would not survive git; .keep keeps it tracked.
|
|
28
|
+
create_file File.join(skill_root, "references", ".keep")
|
|
29
|
+
template "SKILL.md.tt", File.join(skill_root, "SKILL.md")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def skill_root
|
|
35
|
+
File.join("app", "skills", file_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A human-readable heading from the skill name ("issue_triage" / "issue-triage"
|
|
39
|
+
# -> "Issue Triage").
|
|
40
|
+
def skill_title
|
|
41
|
+
file_name.split(/[_-]/).reject(&:empty?).map(&:capitalize).join(" ")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Nexo
|
|
7
|
+
module Generators
|
|
8
|
+
# Adds the +state+ column to an already-installed +nexo_workflow_runs+ table
|
|
9
|
+
# (Spec 13):
|
|
10
|
+
#
|
|
11
|
+
# rails g nexo:state
|
|
12
|
+
#
|
|
13
|
+
# copies a timestamped, additive migration adding a +json+ +state+ column
|
|
14
|
+
# (default +{}+), after which +rails db:migrate+ lets Nexo::Workflow runs
|
|
15
|
+
# store checkpoint results and suspend metadata (durable suspend/resume).
|
|
16
|
+
# Fresh installs get the column from +nexo:workflows+ directly; this generator
|
|
17
|
+
# is for apps installed before Spec 13. Modeled on ArtifactsGenerator.
|
|
18
|
+
class StateGenerator < Rails::Generators::Base
|
|
19
|
+
include Rails::Generators::Migration
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
|
|
23
|
+
# Required by Rails::Generators::Migration to produce the migration's
|
|
24
|
+
# timestamp prefix.
|
|
25
|
+
def self.next_migration_number(dirname)
|
|
26
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
27
|
+
::ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generation step: copy the timestamped +state+-column migration into
|
|
31
|
+
# +db/migrate+.
|
|
32
|
+
def create_migration_file
|
|
33
|
+
migration_template "add_state_to_nexo_workflow_runs.rb", "db/migrate/add_state_to_nexo_workflow_runs.rb"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Additive migration for apps installed before Spec 13 (fresh installs get the
|
|
4
|
+
# column from create_nexo_workflow_runs directly). Portable json object column,
|
|
5
|
+
# default {} — it holds one entry per completed checkpoint plus the reserved
|
|
6
|
+
# "__suspend__" suspend metadata. The migration version is resolved from the host
|
|
7
|
+
# app's ActiveRecord rather than hardcoded, so it tracks whatever Rails is installed.
|
|
8
|
+
class AddStateToNexoWorkflowRuns < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
9
|
+
# Adds the +state+ json object column (default +{}+) to +nexo_workflow_runs+.
|
|
10
|
+
def change
|
|
11
|
+
add_column :nexo_workflow_runs, :state, :json, null: false, default: {}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# json columns are used everywhere (not jsonb) so the schema is portable across
|
|
4
|
+
# SQLite and PostgreSQL without an adapter-aware path. The primary key is the
|
|
5
|
+
# UUID string id assigned by Nexo::WorkflowRun before_create.
|
|
6
|
+
class CreateNexoWorkflowRuns < ActiveRecord::Migration[8.0]
|
|
7
|
+
# Creates the +nexo_workflow_runs+ table (UUID string primary key, portable
|
|
8
|
+
# +json+ columns for payload/result/events/artifacts/state) plus indexes on
|
|
9
|
+
# +workflow_class+ and +status+.
|
|
10
|
+
def change
|
|
11
|
+
create_table :nexo_workflow_runs, id: false do |t|
|
|
12
|
+
t.string :id, null: false, primary_key: true
|
|
13
|
+
t.string :workflow_class, null: false
|
|
14
|
+
t.string :status, null: false, default: "pending" # pending|running|done|failed|suspended
|
|
15
|
+
t.json :payload, null: false, default: {}
|
|
16
|
+
t.json :result
|
|
17
|
+
t.text :error
|
|
18
|
+
t.json :events, null: false, default: []
|
|
19
|
+
t.json :artifacts, null: false, default: []
|
|
20
|
+
t.json :state, null: false, default: {} # checkpoints + suspend metadata (Spec 13)
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
add_index :nexo_workflow_runs, :workflow_class
|
|
24
|
+
add_index :nexo_workflow_runs, :status
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
|
|
6
|
+
module Nexo
|
|
7
|
+
module Generators
|
|
8
|
+
# Installs the WorkflowRun persistence schema into a host Rails app:
|
|
9
|
+
#
|
|
10
|
+
# rails g nexo:workflows
|
|
11
|
+
#
|
|
12
|
+
# copies a timestamped migration that creates the +nexo_workflow_runs+
|
|
13
|
+
# table, after which +rails db:migrate+ makes Nexo::Workflow runs persist
|
|
14
|
+
# to ActiveRecord instead of the in-memory store.
|
|
15
|
+
class WorkflowsGenerator < Rails::Generators::Base
|
|
16
|
+
include Rails::Generators::Migration
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
# Required by Rails::Generators::Migration to produce the migration's
|
|
21
|
+
# timestamp prefix.
|
|
22
|
+
def self.next_migration_number(dirname)
|
|
23
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
24
|
+
::ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Generation step: copy the timestamped +create_nexo_workflow_runs+
|
|
28
|
+
# migration into +db/migrate+.
|
|
29
|
+
def create_migration_file
|
|
30
|
+
migration_template "create_nexo_workflow_runs.rb", "db/migrate/create_nexo_workflow_runs.rb"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/nexo/agent.rb
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# The DSL that composes a model, a sandbox, permissions, and instructions into
|
|
5
|
+
# a working tool-using agent. Subclass it and declare the pieces with class
|
|
6
|
+
# macros, then call +#prompt+:
|
|
7
|
+
#
|
|
8
|
+
# class CodeReviewer < Nexo::Agent
|
|
9
|
+
# model ENV.fetch("NEXO_MODEL")
|
|
10
|
+
# sandbox :local
|
|
11
|
+
# permissions :read_only
|
|
12
|
+
# instructions "You are a careful code reviewer."
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")
|
|
16
|
+
#
|
|
17
|
+
# No sandbox, permission, or tool object is instantiated by hand. Defaults are
|
|
18
|
+
# safe: +:virtual+ sandbox + +:read_only+ permissions unless overridden.
|
|
19
|
+
class Agent
|
|
20
|
+
# The class-level ivars every macro reads/writes. Copied to a subclass in
|
|
21
|
+
# .inherited so `class Child < ConfiguredAgent; end` keeps the parent's
|
|
22
|
+
# configuration instead of silently resetting to defaults.
|
|
23
|
+
CONFIG_IVARS = %i[
|
|
24
|
+
@model @assume_model_exists @provider @sandbox @permissions @instructions
|
|
25
|
+
@skills @mcp @mcp_allow @fetch_allow @search_backend
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Carries the parent's macro configuration onto a subclass. Arrays/Hashes are
|
|
30
|
+
# duped so a subclass extending an accumulating macro (e.g. a second +mcp+
|
|
31
|
+
# line) never mutates the parent's collection; scalars and shared config
|
|
32
|
+
# instances (a class-level Permissions) are copied by reference.
|
|
33
|
+
def inherited(subclass)
|
|
34
|
+
super
|
|
35
|
+
CONFIG_IVARS.each do |ivar|
|
|
36
|
+
next unless instance_variable_defined?(ivar)
|
|
37
|
+
|
|
38
|
+
value = instance_variable_get(ivar)
|
|
39
|
+
value = value.dup if value.is_a?(Array) || value.is_a?(Hash)
|
|
40
|
+
subclass.instance_variable_set(ivar, value)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Each macro is a reader with no argument and a writer with one. Unset
|
|
45
|
+
# +sandbox+/+permissions+ fall back to the harness-wide config defaults.
|
|
46
|
+
def model(value = nil)
|
|
47
|
+
value.nil? ? @model : (@model = value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Opt out of ruby_llm's models.json registry validation for this agent so
|
|
51
|
+
# it can run unregistered models (Ollama tags, self-hosted, brand-new
|
|
52
|
+
# releases). Boolean opt-in: because +nil?+ still distinguishes read from
|
|
53
|
+
# write, +assume_model_exists false+ is an explicit write (sets +false+),
|
|
54
|
+
# not a read. Unset reads as +false+, keeping registry validation on.
|
|
55
|
+
def assume_model_exists(value = nil)
|
|
56
|
+
value.nil? ? (@assume_model_exists || false) : (@assume_model_exists = value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The provider symbol/string (e.g. +:ollama+) passed straight through to
|
|
60
|
+
# +RubyLLM.chat+. Required whenever +assume_model_exists+ is set, since
|
|
61
|
+
# ruby_llm cannot infer a provider once the registry lookup is skipped.
|
|
62
|
+
# Unset resolves to +nil+.
|
|
63
|
+
def provider(value = nil)
|
|
64
|
+
value.nil? ? @provider : (@provider = value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The sandbox macro. With no argument it reads the configured value
|
|
68
|
+
# (falling back to the harness-wide default). With a bare value it stores a
|
|
69
|
+
# symbol/instance as before; with keywords it stores an options Hash
|
|
70
|
+
# (+{ type: value, **opts }+) resolved by Nexo::Sandboxes.resolve — e.g.
|
|
71
|
+
# +sandbox :docker, image: "node:22-slim", binds: {...}+.
|
|
72
|
+
def sandbox(value = nil, **opts)
|
|
73
|
+
return @sandbox || Nexo.config.default_sandbox if value.nil? && opts.empty?
|
|
74
|
+
|
|
75
|
+
@sandbox = opts.empty? ? value : {type: value, **opts}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The permissions macro. With no argument it reads the configured value
|
|
79
|
+
# (falling back to the harness-wide default, +:read_only+); with a value it
|
|
80
|
+
# records the mode symbol or a pre-built Permissions instance.
|
|
81
|
+
def permissions(value = nil)
|
|
82
|
+
value.nil? ? (@permissions || Nexo.config.default_permissions) : (@permissions = value)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The instructions macro. With no argument it reads the stored system
|
|
86
|
+
# prompt (default +nil+); with a value it records it.
|
|
87
|
+
def instructions(value = nil)
|
|
88
|
+
value.nil? ? @instructions : (@instructions = value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Declares the skills attached to this agent. With no args it returns the
|
|
92
|
+
# configured list (default +[]+); with args it ACCUMULATES the names
|
|
93
|
+
# (deduped), so multiple +skills+ lines add up instead of the last one
|
|
94
|
+
# silently replacing the earlier ones — consistent with +mcp+.
|
|
95
|
+
#
|
|
96
|
+
# class TriageAgent < Nexo::Agent
|
|
97
|
+
# model ENV.fetch("NEXO_MODEL")
|
|
98
|
+
# skills :triage # one macro, no loader setup
|
|
99
|
+
# skills :formatting # adds to :triage, does not replace it
|
|
100
|
+
# end
|
|
101
|
+
def skills(*names)
|
|
102
|
+
names.empty? ? (@skills || []) : (@skills = ((@skills || []) + names).uniq)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Declares an MCP server for this agent (Spec 6). Accumulating: multiple
|
|
106
|
+
# +mcp+ lines are collected. With no args (+name+ nil and +opts+ empty) it
|
|
107
|
+
# reads the list (default +[]+); otherwise it appends the friendly,
|
|
108
|
+
# transport-shaped config consumed by Nexo::MCP.build.
|
|
109
|
+
#
|
|
110
|
+
# class InboxDigest < Nexo::Agent
|
|
111
|
+
# model ENV.fetch("NEXO_MODEL")
|
|
112
|
+
# mcp :gmail, transport: :stdio, command: "npx", args: %w[-y srv-gmail]
|
|
113
|
+
# mcp :fetch, transport: :sse, url: "http://localhost:8080/sse"
|
|
114
|
+
# end
|
|
115
|
+
def mcp(name = nil, **opts)
|
|
116
|
+
return @mcp || [] if name.nil? && opts.empty?
|
|
117
|
+
|
|
118
|
+
(@mcp ||= []) << opts.merge(name: name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The MCP tool-name allow-list threaded into this agent's Permissions (see
|
|
122
|
+
# +mcp_allow:+ in #resolve_permissions). Exact tool-name match only — no
|
|
123
|
+
# globs. Like +skills+, with args it ACCUMULATES the flattened names as
|
|
124
|
+
# strings (deduped); with none it reads the list (default +[]+).
|
|
125
|
+
def mcp_allow(*names)
|
|
126
|
+
names.empty? ? (@mcp_allow || []) : (@mcp_allow = ((@mcp_allow || []) + names.flatten.map(&:to_s)).uniq)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The host allow-list scoping this agent's Nexo::Tools::Fetch (Spec 9).
|
|
130
|
+
# Subdomain-aware, exact-host-suffix matching only — no globs. Like
|
|
131
|
+
# +skills+/+mcp_allow+, with args it ACCUMULATES the flattened hosts as
|
|
132
|
+
# strings (deduped); with none it reads the list (default +[]+).
|
|
133
|
+
#
|
|
134
|
+
# Declaring +fetch_allow+ only SCOPES hosts — it does not grant the +:fetch+
|
|
135
|
+
# capability, which is default-denied like +:shell+. An agent that wants
|
|
136
|
+
# egress must also run under +:auto+ or carry an explicit
|
|
137
|
+
# +Permissions.new(mode: :read_only, allow: %i[read glob fetch])+. Both locks
|
|
138
|
+
# must open before a fetch happens.
|
|
139
|
+
def fetch_allow(*hosts)
|
|
140
|
+
hosts.empty? ? (@fetch_allow || []) : (@fetch_allow = ((@fetch_allow || []) + hosts.flatten.map(&:to_s)).uniq)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The host-injected search backend for this agent's Nexo::Tools::WebSearch
|
|
144
|
+
# (Spec 19). Reader/writer by nil-check, matching the +model+ macro
|
|
145
|
+
# convention. Any object responding to +search(query, **opts)+ that
|
|
146
|
+
# returns an Enumerable of +{title:, url:, snippet:}+ rows works — Nexo ships
|
|
147
|
+
# no backend. Unset reads as +nil+, in which case no search tool is attached.
|
|
148
|
+
#
|
|
149
|
+
# Declaring +search_backend+ does not grant the +:search+ capability, which is
|
|
150
|
+
# default-denied like +:fetch+/+:shell+. An agent that wants web discovery must
|
|
151
|
+
# also run under +:auto+ or carry an explicit +allow: [..., :search]+.
|
|
152
|
+
def search_backend(obj = nil)
|
|
153
|
+
obj.nil? ? @search_backend : (@search_backend = obj)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The set of tool names handed to an opt-in backend that ships its own tools
|
|
158
|
+
# (e.g. Loops::AgentSDK). The default Loops::RubyLLM ignores this — it
|
|
159
|
+
# uses the agent's own sandbox-backed tools instead.
|
|
160
|
+
ALLOWED_TOOLS = %w[Read Write Edit Bash Glob Grep].freeze
|
|
161
|
+
|
|
162
|
+
# Maps Nexo's permission modes onto AgentSDK's own permission vocabulary,
|
|
163
|
+
# consumed by Loops::AgentSDK. +:ask+ maps to +:default+ on purpose: human
|
|
164
|
+
# gating stays in Nexo's own +on_ask+ path and is not delegated to the SDK.
|
|
165
|
+
PERMISSION_MODE_MAP = {
|
|
166
|
+
read_only: :default,
|
|
167
|
+
auto: :bypass_permissions,
|
|
168
|
+
ask: :default
|
|
169
|
+
}.freeze
|
|
170
|
+
|
|
171
|
+
# The resolved per-instance configuration (arg → class macro → config): the
|
|
172
|
+
# working directory, model, provider, +assume_model_exists+ flag, the resolved
|
|
173
|
+
# Sandbox and Permissions, the system instructions, and the injected Loop.
|
|
174
|
+
attr_reader :cwd, :model, :provider, :assume_model_exists, :sandbox, :permissions, :instructions, :loop
|
|
175
|
+
|
|
176
|
+
# Every argument is optional; each resolves arg -> class macro -> config.
|
|
177
|
+
# Symbol shorthands (:virtual/:local, :read_only/:auto/:ask/:approve) and
|
|
178
|
+
# pre-built Sandbox/Permissions instances are both accepted. +loop:+ injects
|
|
179
|
+
# the engine that drives a prompt — the provider-neutral Loops::RubyLLM by
|
|
180
|
+
# default, or an opt-in backend like Loops::AgentSDK.
|
|
181
|
+
#
|
|
182
|
+
# +decision:+ (Spec 16, default +nil+) is a per-run approval answer
|
|
183
|
+
# (+{approved: true|false}+) threaded into the resolved Permissions so an
|
|
184
|
+
# +:approve+ gate allows/denies instead of raising Nexo::ApprovalRequired.
|
|
185
|
+
# It only supplies the *answer* to an already-+:approve+ gate — it never
|
|
186
|
+
# widens capability. Workflow#run_agent passes it on the resume pass.
|
|
187
|
+
def initialize(cwd: Dir.pwd, model: nil, sandbox: nil, permissions: nil, decision: nil, loop: Loops::RubyLLM.new)
|
|
188
|
+
@cwd = cwd
|
|
189
|
+
@model = model || self.class.model || Nexo.config.default_model
|
|
190
|
+
if @model.nil?
|
|
191
|
+
raise ConfigurationError,
|
|
192
|
+
"no model set — use the `model` macro, pass model:, or set Nexo.config.default_model"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@assume_model_exists = self.class.assume_model_exists
|
|
196
|
+
@provider = self.class.provider
|
|
197
|
+
if @assume_model_exists && @provider.nil?
|
|
198
|
+
raise ConfigurationError,
|
|
199
|
+
"assume_model_exists is set but no provider given — add the `provider` macro (e.g. `provider :ollama`)"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The agent owns (and so closes) its sandbox only when it resolved one from
|
|
203
|
+
# its own config. A sandbox injected via +sandbox:+ (e.g. Workflow#run_agent
|
|
204
|
+
# sharing the run's sandbox) is BORROWED — closing it would strand the owner.
|
|
205
|
+
@owns_sandbox = sandbox.nil?
|
|
206
|
+
@sandbox = resolve_sandbox(sandbox || self.class.sandbox)
|
|
207
|
+
@permissions = resolve_permissions(permissions || self.class.permissions, decision: decision)
|
|
208
|
+
@instructions = self.class.instructions
|
|
209
|
+
@loop = loop
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Builds a configured chat with the four sandbox-backed tools attached as
|
|
213
|
+
# instances bound to this agent's sandbox and permissions, then layers on the
|
|
214
|
+
# instructions of every declared skill.
|
|
215
|
+
#
|
|
216
|
+
# +base:+ lets a Nexo::Session pass a persisted +acts_as_chat+ record
|
|
217
|
+
# (hydrated via ruby_llm's +#to_llm+ delegation) so the very same wiring —
|
|
218
|
+
# instructions, the four sandbox tools, skills, MCP, fetch — is applied onto
|
|
219
|
+
# the continuing thread instead of a fresh chat. When +base+ is nil the path
|
|
220
|
+
# is byte-for-byte the standalone-agent build: a fresh +RubyLLM.chat+. A
|
|
221
|
+
# session therefore changes only *memory/persistence*, never authority or
|
|
222
|
+
# execution — the record supplies the thread, the agent supplies the wiring.
|
|
223
|
+
#
|
|
224
|
+
# Re-applying +@instructions+ on every resume stays idempotent because the
|
|
225
|
+
# persisted-chat +#with_instructions+ (default +append: false+) *replaces* the
|
|
226
|
+
# stored +role: :system+ messages rather than appending, so the thread keeps
|
|
227
|
+
# exactly one copy across resumes (VERIFIED, ruby_llm 1.16.0).
|
|
228
|
+
def chat(base: nil)
|
|
229
|
+
c = base || RubyLLM.chat(**chat_model_options)
|
|
230
|
+
c = apply_instructions(c)
|
|
231
|
+
|
|
232
|
+
# One ReadTracker per chat, shared by ReadFile (records) and WriteFile
|
|
233
|
+
# (enforces the read-before-write + stale guard) — R4.
|
|
234
|
+
tracker = ReadTracker.new
|
|
235
|
+
tools = [
|
|
236
|
+
Tools::ReadFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
|
|
237
|
+
Tools::WriteFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
|
|
238
|
+
Tools::Glob.new(sandbox: @sandbox, permissions: @permissions)
|
|
239
|
+
]
|
|
240
|
+
# Attach Shell only when the sandbox can actually run one (R2), so a
|
|
241
|
+
# :virtual agent stops advertising a tool it can never run.
|
|
242
|
+
if @sandbox.supports?(:shell)
|
|
243
|
+
tools << Tools::Shell.new(sandbox: @sandbox, permissions: @permissions)
|
|
244
|
+
end
|
|
245
|
+
c.with_tools(*tools)
|
|
246
|
+
apply_mcp(c)
|
|
247
|
+
apply_fetch(c)
|
|
248
|
+
apply_search(c)
|
|
249
|
+
c
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Runs one prompt through the agent by delegating to the injected loop. The
|
|
253
|
+
# loop body that used to live here is now in Loops::RubyLLM (the default),
|
|
254
|
+
# so swapping +loop:+ swaps the engine without touching this class. The
|
|
255
|
+
# optional +&on_event+ block receives +(type, payload)+ progress events.
|
|
256
|
+
def prompt(text, max_turns: 25, &on_event)
|
|
257
|
+
@loop.run(agent: self, prompt: text, max_turns: max_turns, &on_event)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The agent's Nexo permission mode mapped onto an opt-in backend's own
|
|
261
|
+
# permission vocabulary (see PERMISSION_MODE_MAP). Consumed by
|
|
262
|
+
# Loops::AgentSDK; the default Loops::RubyLLM does its gating inside the
|
|
263
|
+
# sandbox-backed tools and ignores this.
|
|
264
|
+
def permission_mode
|
|
265
|
+
PERMISSION_MODE_MAP.fetch(@permissions.mode, :default)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The tool names handed to an opt-in backend that ships its own tools.
|
|
269
|
+
def allowed_tools
|
|
270
|
+
ALLOWED_TOOLS
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Releases any MCP server connections held by this agent instance. Clients are
|
|
274
|
+
# memoized on the instance and reused across prompts (Spec 6 lifecycle default),
|
|
275
|
+
# so a long-lived agent holding stdio/SSE servers should call +#close+ when
|
|
276
|
+
# done. Idempotent: safe to call with no MCP servers attached or more than once.
|
|
277
|
+
#
|
|
278
|
+
# VERIFY (Group 0, ruby_llm-mcp 1.0.0): the client teardown method is +#stop+
|
|
279
|
+
# (guarded by +respond_to?+, falling back to +#close+ for other client shapes).
|
|
280
|
+
def close
|
|
281
|
+
@mcp_clients&.each do |client|
|
|
282
|
+
if client.respond_to?(:stop)
|
|
283
|
+
client.stop
|
|
284
|
+
elsif client.respond_to?(:close)
|
|
285
|
+
client.close
|
|
286
|
+
end
|
|
287
|
+
rescue
|
|
288
|
+
# Best-effort teardown: a failing stop on one client must not strand the
|
|
289
|
+
# remaining clients or leave @mcp_clients set (breaking idempotency).
|
|
290
|
+
# Swallow and continue to the next.
|
|
291
|
+
end
|
|
292
|
+
@mcp_clients = nil
|
|
293
|
+
|
|
294
|
+
# Release the sandbox too, so a container/remote-backed agent doesn't leak
|
|
295
|
+
# its container/connection when the caller only remembers +close+. Only close
|
|
296
|
+
# a sandbox this agent OWNS (resolved from its own config) — a borrowed one
|
|
297
|
+
# (injected via +sandbox:+, e.g. Workflow#run_agent's shared run sandbox) is
|
|
298
|
+
# the injector's to close. The base Sandbox#close is a no-op, so this is safe
|
|
299
|
+
# and idempotent for :virtual/:local. Best-effort: never raise out of close.
|
|
300
|
+
if @owns_sandbox
|
|
301
|
+
begin
|
|
302
|
+
@sandbox&.close
|
|
303
|
+
rescue
|
|
304
|
+
# Swallow: close must stay idempotent and non-raising.
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
# Builds the +RubyLLM.chat+ options conditionally so the default agent's
|
|
312
|
+
# call is byte-for-byte what it was before this feature: +{model: @model}+.
|
|
313
|
+
# +provider+ is added only when resolved; +assume_model_exists: true+ only
|
|
314
|
+
# when opted in (never passed as +false+).
|
|
315
|
+
def chat_model_options
|
|
316
|
+
opts = {model: @model}
|
|
317
|
+
opts[:provider] = @provider if @provider
|
|
318
|
+
opts[:assume_model_exists] = true if @assume_model_exists
|
|
319
|
+
opts
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Applies the full system-prompt stack to +chat+ in deterministic order: the
|
|
323
|
+
# agent's own instructions, then the self-describing sandbox instructions (R1),
|
|
324
|
+
# then each declared skill's body. A skill contributes instructions only — it
|
|
325
|
+
# ships no independent tools (its scripts/references are reached through the
|
|
326
|
+
# already-gated sandbox tools), so attaching one never widens the agent.
|
|
327
|
+
#
|
|
328
|
+
# The FIRST contribution is applied with +append: false+ and the rest with
|
|
329
|
+
# +append: true+. On a fresh chat that is byte-for-byte the prior behavior; on
|
|
330
|
+
# a Nexo::Session's persisted, re-hydrated chat the leading +append: false+
|
|
331
|
+
# collapses all prior +role: :system+ messages to one before the rest
|
|
332
|
+
# re-append, so N resumes keep exactly ONE copy of the stack — even when the
|
|
333
|
+
# agent declares no +instructions+ (the case that previously let skills/sandbox
|
|
334
|
+
# instructions accumulate a fresh copy per resume).
|
|
335
|
+
def apply_instructions(chat)
|
|
336
|
+
texts = []
|
|
337
|
+
texts << @instructions if @instructions
|
|
338
|
+
texts << @sandbox.instructions if @sandbox.instructions
|
|
339
|
+
self.class.skills.each { |name| texts << Skills.find(name).content }
|
|
340
|
+
|
|
341
|
+
texts.each_with_index do |text, i|
|
|
342
|
+
chat = chat.with_instructions(text, append: i.positive?)
|
|
343
|
+
end
|
|
344
|
+
chat
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Lazily connects the declared MCP servers and attaches their tools, each
|
|
348
|
+
# wrapped in a MCP::GatedTool so every invocation is authorized through this
|
|
349
|
+
# agent's Permissions first. Attached after the four sandbox tools and the
|
|
350
|
+
# skills, so MCP tools fire the chat's +before_tool_call+/+after_tool_result+
|
|
351
|
+
# callbacks (wired in Loops::RubyLLM) and appear in the run's event log with
|
|
352
|
+
# no extra wiring. Returns early when no server is declared.
|
|
353
|
+
#
|
|
354
|
+
# Clients are built once and memoized on the instance (Spec 6 lifecycle
|
|
355
|
+
# default): the +ruby_llm-mcp+ client connects on construction and is reusable
|
|
356
|
+
# across prompts, so subsequent +#chat+ calls reuse the live connections until
|
|
357
|
+
# #close. VERIFY (Group 0): tools accessor is +client.tools+ (an Array).
|
|
358
|
+
def apply_mcp(chat)
|
|
359
|
+
return if self.class.mcp.empty?
|
|
360
|
+
|
|
361
|
+
@mcp_clients ||= self.class.mcp.map { |cfg| Nexo::MCP.build(**cfg) }
|
|
362
|
+
gated = @mcp_clients.flat_map(&:tools).map do |tool|
|
|
363
|
+
Nexo::MCP::GatedTool.new(tool: tool, permissions: @permissions)
|
|
364
|
+
end
|
|
365
|
+
chat.with_tools(*gated) unless gated.empty?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Attaches a single Nexo::Tools::Fetch scoped to the agent's +fetch_allow+
|
|
369
|
+
# hosts (Spec 9). Returns early when no host is declared, so an agent that never
|
|
370
|
+
# calls +fetch_allow+ gets no fetch tool. Attached right after +apply_mcp+ so
|
|
371
|
+
# the tool participates in the chat's +before_tool_call+/+after_tool_result+
|
|
372
|
+
# event stream (wired in Loops::RubyLLM) with no extra wiring. The +:fetch+
|
|
373
|
+
# capability itself is gated through Permissions#authorize! at call time — the
|
|
374
|
+
# allow-list only scopes hosts, it is not the capability grant.
|
|
375
|
+
def apply_fetch(chat)
|
|
376
|
+
return if self.class.fetch_allow.empty?
|
|
377
|
+
|
|
378
|
+
chat.with_tools(
|
|
379
|
+
Tools::Fetch.new(sandbox: @sandbox, permissions: @permissions, allow_hosts: self.class.fetch_allow)
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Attaches a single Nexo::Tools::WebSearch bound to the agent's injected
|
|
384
|
+
# +search_backend+ (Spec 19). Returns early when no backend is declared, so an
|
|
385
|
+
# agent that never calls +search_backend+ gets no search tool — existing agents
|
|
386
|
+
# are byte-for-byte unchanged. Attached right after +apply_fetch+ so the tool
|
|
387
|
+
# rides the same +before_tool_call+/+after_tool_result+ event stream (wired in
|
|
388
|
+
# Loops::RubyLLM) with no extra wiring. The +:search+ capability itself is gated
|
|
389
|
+
# through Permissions#authorize! at call time.
|
|
390
|
+
def apply_search(chat)
|
|
391
|
+
backend = self.class.search_backend or return
|
|
392
|
+
|
|
393
|
+
chat.with_tools(
|
|
394
|
+
Tools::WebSearch.new(sandbox: @sandbox, permissions: @permissions, backend: backend)
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Resolves this agent's sandbox declaration via the shared resolver (Spec 15),
|
|
399
|
+
# passing the agent's instance +@cwd+ as the host working directory (used only
|
|
400
|
+
# by +:local+; container tiers keep their own +/workspace+ default).
|
|
401
|
+
def resolve_sandbox(value) = Sandboxes.resolve(value, cwd: @cwd)
|
|
402
|
+
|
|
403
|
+
def resolve_permissions(value, decision: nil)
|
|
404
|
+
# A user-supplied Permissions sets its own mcp_allow: — leave it untouched.
|
|
405
|
+
# Thread a per-run approval decision through a non-mutating copy (Spec 16)
|
|
406
|
+
# so a shared, class-level :approve instance is never clobbered.
|
|
407
|
+
if value.is_a?(Permissions)
|
|
408
|
+
return decision ? value.with_decision(decision) : value
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Thread the class-level mcp_allow into each symbol branch (Spec 6) so the
|
|
412
|
+
# MCP capability axis is populated alongside the sandbox axis.
|
|
413
|
+
allow = self.class.mcp_allow
|
|
414
|
+
case value
|
|
415
|
+
when :read_only then Permissions.new(mode: :read_only, mcp_allow: allow)
|
|
416
|
+
when :auto then Permissions.new(mode: :auto, allow: %i[read glob write shell fetch search], mcp_allow: allow)
|
|
417
|
+
when :ask then Permissions.new(mode: :ask, mcp_allow: allow) # pass a Permissions with on_ask for a real gate
|
|
418
|
+
when :approve then Permissions.new(mode: :approve, mcp_allow: allow, decision: decision)
|
|
419
|
+
else raise ConfigurationError, "unknown permissions: #{value.inspect}"
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|