nexo_ai 0.1.0 → 0.8.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +255 -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 +100 -0
  8. data/docs/durable-workflows.md +230 -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 +162 -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 +839 -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/workflow.rbs +14 -0
  92. data/sig/nexo_ai.rbs +22 -1
  93. metadata +186 -2
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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # Bounded fan-out driver for running many agent/workflow calls concurrently on
5
+ # Ruby's fiber scheduler (Spec 5). It is the value Nexo adds over a raw
6
+ # +Async {}+ block: *rate-bounded* concurrency (so you stay under provider rate
7
+ # limits) with proper error propagation (the first task failure is re-raised,
8
+ # never swallowed) and results returned in **submission order**.
9
+ #
10
+ # results = Nexo.concurrent(max_in_flight: 8) do |c|
11
+ # docs.each { |d| c.add { SummarizeDocument.run(text: d.body).result } }
12
+ # end
13
+ # # => one result per doc, in doc order, never more than 8 calls in flight
14
+ #
15
+ # +async+ is a SOFT (optional) dependency: it is required lazily the moment a
16
+ # fan-out actually runs. With the gem absent, +require "nexo"+ still loads
17
+ # cleanly; only calling Nexo.concurrent raises MissingDependencyError.
18
+ class Concurrent
19
+ # A submitted unit of work. Wrapping the block in a Struct keeps submission
20
+ # order explicit (the index into +@tasks+ is the index into the results).
21
+ Task = Struct.new(:block)
22
+
23
+ # +max_in_flight+ bounds how many submitted blocks run concurrently once the
24
+ # collector is run inside an +async+ reactor.
25
+ def initialize(max_in_flight:)
26
+ @max_in_flight = max_in_flight
27
+ @tasks = []
28
+ end
29
+
30
+ # Registers a block to run. Called via the yielded collector inside
31
+ # Nexo.concurrent. Order of +add+ calls is the order of the results array.
32
+ def add(&block)
33
+ @tasks << Task.new(block)
34
+ end
35
+
36
+ # Runs every added block inside ONE reactor, bounding in-flight work with an
37
+ # +Async::Semaphore+ and coordinating with an +Async::Barrier+. Results are
38
+ # index-assigned, so the returned Array is in submission order regardless of
39
+ # completion order. On the first task that raises, the error propagates out
40
+ # of +barrier.wait+ and the +ensure+ stops the remaining in-flight tasks —
41
+ # errors are never swallowed.
42
+ def run
43
+ require_async!
44
+ results = Array.new(@tasks.size)
45
+
46
+ Async do |task|
47
+ semaphore = Async::Semaphore.new(@max_in_flight, parent: task)
48
+ barrier = Async::Barrier.new(parent: semaphore)
49
+
50
+ @tasks.each_with_index do |t, i|
51
+ barrier.async { results[i] = t.block.call }
52
+ end
53
+
54
+ # barrier.wait re-raises the first task failure (no silent swallowing).
55
+ barrier.wait
56
+ ensure
57
+ barrier&.stop
58
+ end.wait
59
+
60
+ results
61
+ end
62
+
63
+ private
64
+
65
+ # Lazily loads +async+ and its coordination primitives, mirroring the
66
+ # soft-dependency precedent in Skills.load! / Loops::AgentSDK: a bare
67
+ # +require+ (so tests can stub it on the instance) rescuing stdlib +LoadError+
68
+ # into a MissingDependencyError that names the gem and the exact remedy.
69
+ def require_async!
70
+ require "async"
71
+ require "async/barrier"
72
+ require "async/semaphore"
73
+ rescue LoadError
74
+ raise Nexo::MissingDependencyError,
75
+ 'Concurrency requires the `async` gem. Add `gem "async", "~> 2.0"` to your Gemfile.'
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # Holds the harness-wide defaults. Accessed through Nexo.config and
5
+ # mutated through Nexo.configure. Every default is safe-by-default and
6
+ # provider-neutral — there is intentionally no hardcoded model.
7
+ class Configuration
8
+ # The default ruby_llm model id. +nil+ means none chosen — provider-neutral.
9
+ attr_accessor :default_model
10
+
11
+ # The default sandbox: +:virtual+ (in-memory, zero host access).
12
+ attr_accessor :default_sandbox
13
+
14
+ # The default permission mode: +:read_only+.
15
+ attr_accessor :default_permissions
16
+
17
+ # Where SKILL.md packages live. Rails-aware: +app/skills+ under the app root.
18
+ attr_accessor :skills_path
19
+
20
+ # Concurrency mode (Spec 5): +:threaded+ (default) | +:async+. This is the
21
+ # single switch that gates the Sandboxes::Local offload path — under
22
+ # +:async+ its blocking file/shell I/O runs on a worker thread so it does not
23
+ # stall a fiber reactor; under +:threaded+ it runs inline (zero overhead,
24
+ # byte-for-byte the Spec 1 behavior). It does NOT gate Nexo.concurrent,
25
+ # which always runs its own reactor when called.
26
+ attr_accessor :concurrency
27
+
28
+ # Max in-flight tasks for Nexo.concurrent fan-out (Spec 5), default +8+.
29
+ # The single most important knob for staying under provider rate limits.
30
+ attr_accessor :max_in_flight
31
+
32
+ # Whether Workflow buffers events in memory and flushes once at the end of
33
+ # a run instead of persisting per +emit+ (Spec 5), default +false+. Buffering
34
+ # avoids a blocking per-event DB write under a fiber reactor.
35
+ attr_accessor :buffer_workflow_events
36
+
37
+ # The host +acts_as_chat+ model that stores Nexo::Session threads (Spec 10),
38
+ # given as a **String** class name (default +"Chat"+ — ruby_llm's own default).
39
+ # It is constantized lazily at resume time so plain Ruby / no-ActiveRecord
40
+ # paths never reference an AR constant, mirroring how RunStore only touches
41
+ # Nexo::WorkflowRun when it is defined. The host app owns this model (columns
42
+ # + a unique +(agent_name, instance_id)+ index); Nexo ships no migration for it.
43
+ attr_accessor :session_chat_model
44
+
45
+ # Whether to mirror run event notifications over Turbo Streams (Spec 11 R2),
46
+ # default +false+ (safe-by-default). When true and turbo-rails is present, the
47
+ # engine subscribes Nexo::TurboBroadcaster at boot. The plain
48
+ # +nexo.workflow.event+/+nexo.workflow.status+ notifications fire regardless —
49
+ # this only gates the opt-in Turbo mirror.
50
+ attr_accessor :broadcast_events
51
+
52
+ # The ActiveJob queue Workflow.run_later enqueues onto (Spec 11 R1), default
53
+ # +nil+ → ActiveJob's default queue. Set to a symbol (e.g. +:nexo+) to route
54
+ # workflow jobs to a dedicated queue.
55
+ attr_accessor :job_queue
56
+
57
+ # Seeds the safe, provider-neutral defaults (no model, +:virtual+ sandbox,
58
+ # +:read_only+ permissions, +:threaded+ concurrency).
59
+ def initialize
60
+ @default_model = nil # provider-neutral: NO hardcoded default
61
+ @default_sandbox = :virtual # safe default
62
+ @default_permissions = :read_only # safe default
63
+ @skills_path = default_skills_path
64
+ @concurrency = :threaded # async is opt-in; :threaded stays the default
65
+ @max_in_flight = 8
66
+ @buffer_workflow_events = false
67
+ @session_chat_model = "Chat" # ruby_llm's own default acts_as_chat host
68
+ @broadcast_events = false # safe default: opt in to the Turbo mirror
69
+ @job_queue = nil # nil → ActiveJob's default queue
70
+ end
71
+
72
+ private
73
+
74
+ def default_skills_path
75
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
76
+ ::Rails.root.join("app/skills").to_s
77
+ else
78
+ "skills"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails-optional: this file is required from lib/nexo.rb only when Rails is
4
+ # present, and is additionally guarded here so requiring it directly in plain
5
+ # Ruby never raises. The engine wires Nexo's generator and rake task into a host
6
+ # Rails app and loads the WorkflowRun model once ActiveRecord is available.
7
+ if defined?(::Rails::Engine)
8
+ module Nexo
9
+ # The Rails engine that wires Nexo into a host app: it loads the WorkflowRun
10
+ # model and WorkflowJob during boot, opt-in-subscribes the Turbo broadcaster,
11
+ # and (via Rails::Engine) exposes Nexo's generators and rake tasks. Defined
12
+ # only when +::Rails::Engine+ is present, so the plain-Ruby core stays
13
+ # Rails-free.
14
+ class Engine < ::Rails::Engine
15
+ isolate_namespace Nexo
16
+
17
+ # Load the WorkflowRun model during boot so RunStore.default reliably
18
+ # selects the ActiveRecord backend (it checks defined?(Nexo::WorkflowRun)).
19
+ # A plain on_load(:active_record) hook is not enough: it only fires once
20
+ # AR::Base is touched, which may not happen before a command runs — e.g.
21
+ # `rake nexo:logs` would otherwise boot, never load AR, and fall back to
22
+ # the Memory store. Requiring the file directly is safe: its body is
23
+ # guarded, so in a Rails app with no ActiveRecord it defines nothing and
24
+ # RunStore.default falls back to Memory.
25
+ initializer "nexo.workflow_run_model" do
26
+ require "nexo/workflow_run"
27
+ end
28
+
29
+ # Load the WorkflowJob once ActiveJob is available (Spec 11 R1), mirroring the
30
+ # workflow_run model initializer above. The require is safe with no ActiveJob:
31
+ # the file body is guarded, so it defines nothing and Workflow.run_later raises
32
+ # a MissingDependencyError instead.
33
+ initializer "nexo.workflow_job" do
34
+ require "nexo/workflow_job"
35
+ end
36
+
37
+ # Wire the opt-in Turbo mirror (Spec 11 R2). The require is always safe (the
38
+ # broadcaster's body is guarded on ActiveSupport::Notifications); subscribe! is
39
+ # a no-op without turbo-rails. Only subscribe when the host opted in via
40
+ # config.broadcast_events — safe-by-default (broadcast_events defaults false).
41
+ initializer "nexo.turbo_broadcaster" do
42
+ require "nexo/turbo_broadcaster"
43
+ Nexo::TurboBroadcaster.subscribe! if Nexo.config.broadcast_events
44
+ end
45
+
46
+ # The nexo:logs rake task ships in lib/tasks/nexo.rake, which Rails::Engine
47
+ # loads into the host app automatically — no explicit rake_tasks wiring
48
+ # (loading it a second time would run the task body twice).
49
+ end
50
+ end
51
+ end
data/lib/nexo/loop.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # The loop seam: the engine that drives one prompt to completion. Swapping the
5
+ # loop swaps *how* the agent's turns are run — the plain provider-neutral
6
+ # +ruby_llm+ tool loop, or an opt-in vendor-tuned backend — by constructor
7
+ # injection, with no change to the agent class.
8
+ #
9
+ # A loop implements the contract below. The base class raises so an incomplete
10
+ # subclass fails loudly. The optional +&on_event+ block, when given, is called
11
+ # with +(type, payload)+ as the run progresses (e.g. +:tool_call+,
12
+ # +:tool_result+, +:done+) — observability only; it never steers the run.
13
+ #
14
+ # See Loops::RubyLLM (default, provider-neutral) and Loops::AgentSDK
15
+ # (opt-in, Anthropic-oriented).
16
+ class Loop
17
+ # Runs +prompt+ through +agent+ and returns the final response. +max_turns+
18
+ # is a hint a backend may enforce as a hard cap (AgentSDK) or expose only as
19
+ # observability (RubyLLM — see the turn-cap caveat in the README).
20
+ #
21
+ # +chat:+ (default +nil+) lets a Nexo::Session inject a hydrated, continuing
22
+ # chat so the loop runs over the persisted thread. It is part of the base
23
+ # contract so every backend accepts it: Loops::RubyLLM uses it; a backend
24
+ # that runs its own in-process loop (Loops::AgentSDK) rejects a non-nil chat
25
+ # rather than silently dropping the session's memory.
26
+ def run(agent:, prompt:, max_turns: 25, chat: nil, &on_event)
27
+ raise NotImplementedError
28
+ end
29
+ end
30
+ end