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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +94 -0
  8. data/docs/durable-workflows.md +184 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +129 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +705 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo_ai.rbs +22 -1
  92. 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,10 @@
1
+ ---
2
+ name: <%= file_name %>
3
+ description: One-line summary of when the model should use this skill.
4
+ ---
5
+
6
+ # <%= skill_title %>
7
+
8
+ ## Process
9
+ 1. <first step>
10
+ 2. <second step>
@@ -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