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.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +255 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +100 -0
- data/docs/durable-workflows.md +230 -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 +162 -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 +839 -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/workflow.rbs +14 -0
- data/sig/nexo_ai.rbs +22 -1
- 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
|
data/lib/nexo/engine.rb
ADDED
|
@@ -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
|