nexo_ai 0.1.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- metadata +185 -2
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
module Loops
|
|
5
|
+
# An opt-in, Anthropic-oriented loop that delegates to +ruby_llm-agent_sdk+'s
|
|
6
|
+
# own engine: native +max_turns+, permission modes, and the SDK's built-in
|
|
7
|
+
# tools. It is NOT the default and is not provider-neutral — choose it
|
|
8
|
+
# explicitly with +loop: Nexo::Loops::AgentSDK.new+ when you are on Anthropic
|
|
9
|
+
# and want the Claude fast path.
|
|
10
|
+
#
|
|
11
|
+
# The trade vs. Loops::RubyLLM: this leans on the SDK's own built-in tools
|
|
12
|
+
# and runs in the host process, so it does NOT execute through Nexo's
|
|
13
|
+
# pluggable sandbox. That is the documented cost of the native +max_turns+
|
|
14
|
+
# hard cap (see the turn-cap caveat in the README).
|
|
15
|
+
#
|
|
16
|
+
# +ruby_llm-agent_sdk+ is a SOFT dependency: it is required lazily inside
|
|
17
|
+
# +#run+ and, when absent, surfaces as a Nexo::MissingDependencyError with
|
|
18
|
+
# install guidance — +require "nexo"+ without the gem never raises.
|
|
19
|
+
#
|
|
20
|
+
# VERIFY-on-install: the gem is not installed in this environment, so
|
|
21
|
+
# +RubyLLM::AgentSDK.query+'s exact signature and the +:result+ terminal
|
|
22
|
+
# message shape below remain provisional (per references.md). Confirm them
|
|
23
|
+
# against the +ruby_llm-agent_sdk+ README the moment the gem is added and
|
|
24
|
+
# record the result under "Verified APIs".
|
|
25
|
+
class AgentSDK < Nexo::Loop
|
|
26
|
+
def run(agent:, prompt:, max_turns: 25, chat: nil, &on_event)
|
|
27
|
+
unless chat.nil?
|
|
28
|
+
raise Nexo::ConfigurationError,
|
|
29
|
+
"Loops::AgentSDK runs its own in-process loop and cannot continue a persisted Nexo::Session chat — use the default Loops::RubyLLM for sessions."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
load_sdk!
|
|
33
|
+
|
|
34
|
+
result = nil
|
|
35
|
+
::RubyLLM::AgentSDK.query(
|
|
36
|
+
prompt,
|
|
37
|
+
model: agent.model,
|
|
38
|
+
max_turns: max_turns,
|
|
39
|
+
permission_mode: agent.permission_mode, # Nexo mode mapped by the agent
|
|
40
|
+
allowed_tools: agent.allowed_tools # %w[Read Write Edit Bash Glob Grep]
|
|
41
|
+
) do |message|
|
|
42
|
+
on_event&.call(message.type, message)
|
|
43
|
+
result = message if message.type == :result
|
|
44
|
+
end
|
|
45
|
+
# Emit the contract's terminal :done event (the base Loop advertises
|
|
46
|
+
# :tool_call/:tool_result/:done) so a Workflow#run_agent driving THIS
|
|
47
|
+
# backend records an `agent_done` just like the default loop does. The
|
|
48
|
+
# SDK's INTERMEDIATE event types stay backend-native (:assistant/:result/…)
|
|
49
|
+
# — this loop is opt-in and its per-message vocabulary is the SDK's.
|
|
50
|
+
on_event&.call(:done, result)
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Lazily loads the opt-in +ruby_llm-agent_sdk+ gem. The rescue wraps ONLY the
|
|
57
|
+
# require (mirroring Skills.load!/MCP.load!), so a LoadError raised from
|
|
58
|
+
# inside the SDK's own +query+ or the caller's block is NOT mislabeled as a
|
|
59
|
+
# missing-gem error.
|
|
60
|
+
def load_sdk!
|
|
61
|
+
require "ruby_llm/agent_sdk" # VERIFY exact require path on install
|
|
62
|
+
rescue LoadError
|
|
63
|
+
raise Nexo::MissingDependencyError,
|
|
64
|
+
"Loops::AgentSDK requires the `ruby_llm-agent_sdk` gem. Add `gem \"ruby_llm-agent_sdk\"` to your Gemfile and run `bundle install`."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# Namespace for the pluggable loop backends that drive one prompt to
|
|
5
|
+
# completion: Loops::RubyLLM (the default, provider-neutral) and
|
|
6
|
+
# Loops::AgentSDK (opt-in, Anthropic-oriented). Selected per agent via the
|
|
7
|
+
# +loop:+ constructor injection; the base contract is Nexo::Loop.
|
|
8
|
+
module Loops
|
|
9
|
+
# The default, provider-neutral loop. It is the Spec 1 +Agent#prompt+ body
|
|
10
|
+
# extracted verbatim: build the agent's chat (with its four sandbox-backed
|
|
11
|
+
# tools) and let +ruby_llm+ run the whole tool loop inside +#ask+. It works
|
|
12
|
+
# identically on any +ruby_llm+-supported model — Anthropic, OpenAI, Gemini,
|
|
13
|
+
# Ollama/Gemma — because file/shell capability comes entirely from the
|
|
14
|
+
# agent's own sandbox-backed tools, not from anything vendor-specific here.
|
|
15
|
+
#
|
|
16
|
+
# Tool-call *observability* (not a hard cap — see the turn-cap caveat in the
|
|
17
|
+
# README) is wired through +ruby_llm+'s +before_tool_call+/+after_tool_result+
|
|
18
|
+
# callbacks when the installed version exposes them, and is silently omitted
|
|
19
|
+
# otherwise so an older/newer +ruby_llm+ degrades to no observability rather
|
|
20
|
+
# than crashing.
|
|
21
|
+
class RubyLLM < Nexo::Loop
|
|
22
|
+
# +chat:+ lets a Nexo::Session inject the hydrated, continuing chat so the
|
|
23
|
+
# loop runs over the persisted thread; left nil it builds the agent's own
|
|
24
|
+
# fresh chat exactly as before — the default (no-session) path is unchanged.
|
|
25
|
+
def run(agent:, prompt:, max_turns: 25, chat: nil, &on_event)
|
|
26
|
+
chat ||= agent.chat
|
|
27
|
+
|
|
28
|
+
wire_observability(chat, &on_event)
|
|
29
|
+
|
|
30
|
+
response = chat.ask(prompt)
|
|
31
|
+
on_event&.call(:done, response)
|
|
32
|
+
response
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Tool-call OBSERVABILITY only: ruby_llm runs the whole tool loop inside
|
|
38
|
+
# #ask, so these callbacks report tool activity but cannot halt the loop.
|
|
39
|
+
# BOTH callbacks must be present to wire — confirmed on ruby_llm 1.16.0
|
|
40
|
+
# (legacy aliases of #on_tool_call/#on_tool_result); a chat missing either
|
|
41
|
+
# degrades to no observability rather than crashing on the one it has.
|
|
42
|
+
#
|
|
43
|
+
# A continuing Nexo::Session runs the loop repeatedly over the SAME chat, so
|
|
44
|
+
# the wiring is done once per chat (flagged with an ivar) — otherwise ruby_llm
|
|
45
|
+
# (which *appends* callbacks) would stack a fresh pair every prompt and fire
|
|
46
|
+
# each event N times. The current +on_event+ is stashed on the chat so a later
|
|
47
|
+
# prompt observes with its own block rather than the first one. A fresh chat
|
|
48
|
+
# (the default per-prompt path) simply wires once — byte-for-byte the prior
|
|
49
|
+
# behavior.
|
|
50
|
+
def wire_observability(chat, &on_event)
|
|
51
|
+
return unless chat.respond_to?(:before_tool_call) && chat.respond_to?(:after_tool_result)
|
|
52
|
+
|
|
53
|
+
chat.instance_variable_set(:@nexo_on_event, on_event)
|
|
54
|
+
return if chat.instance_variable_get(:@nexo_observed)
|
|
55
|
+
|
|
56
|
+
chat.instance_variable_set(:@nexo_observed, true)
|
|
57
|
+
chat.before_tool_call do |tc|
|
|
58
|
+
chat.instance_variable_get(:@nexo_on_event)&.call(:tool_call, tc)
|
|
59
|
+
end
|
|
60
|
+
chat.after_tool_result do |r|
|
|
61
|
+
chat.instance_variable_get(:@nexo_on_event)&.call(:tool_result, r)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
module MCP
|
|
5
|
+
# Wraps a +ruby_llm-mcp+ tool so the chat sees an identical tool but every
|
|
6
|
+
# invocation is authorized through Nexo::Permissions#authorize_mcp! first. A
|
|
7
|
+
# denied call returns +{ error: ... }+ (recoverable) and never raises into the
|
|
8
|
+
# loop — matching the sandbox-backed tools' authorize→act→rescue-+Denied+→
|
|
9
|
+
# +{error:}+ shape (see +lib/nexo/tools/write_file.rb+).
|
|
10
|
+
#
|
|
11
|
+
# Everything except the execution method is delegated to the wrapped tool, so
|
|
12
|
+
# the chat cannot tell the wrapper apart from the raw MCP tool: it reports the
|
|
13
|
+
# same +name+/+description+/+params_schema+ and serializes identically.
|
|
14
|
+
#
|
|
15
|
+
# VERIFY (Group 0, ruby_llm-mcp 1.0.0 + ruby_llm 1.16.0): +RubyLLM::Chat#with_tool+
|
|
16
|
+
# does not type-check — it accepts any object responding to +#name+ — and the tool
|
|
17
|
+
# loop invokes +tool.call(args)+ with a positional Hash. So a duck-typed wrapper
|
|
18
|
+
# is accepted; no +RubyLLM::Tool+ subclass is required. +#call+ here mirrors that
|
|
19
|
+
# entry point (a +RubyLLM::MCP::Tool+'s own +#call+ normalizes and dispatches to
|
|
20
|
+
# +#execute+), and +method_missing+ forwards +description+/+params_schema+/+to_h+/
|
|
21
|
+
# etc. to the wrapped tool.
|
|
22
|
+
class GatedTool
|
|
23
|
+
# Wraps +tool+ (a +ruby_llm-mcp+ tool) so every call is authorized through
|
|
24
|
+
# +permissions+ (the MCP axis) before delegating.
|
|
25
|
+
def initialize(tool:, permissions:)
|
|
26
|
+
@tool = tool
|
|
27
|
+
@permissions = permissions
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Authorizes the call by tool name, then delegates to the wrapped tool. A
|
|
31
|
+
# denial is returned as +{ error: ... }+ so the model can recover — never
|
|
32
|
+
# raised into the loop.
|
|
33
|
+
def call(args = {})
|
|
34
|
+
@permissions.authorize_mcp!(name, args)
|
|
35
|
+
@tool.call(args)
|
|
36
|
+
rescue Nexo::Permissions::Denied => e
|
|
37
|
+
{error: e.message}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The wrapped tool's name — what the chat and the gate key on.
|
|
41
|
+
def name = @tool.name
|
|
42
|
+
|
|
43
|
+
# The gate lives on #call: #execute (the UNGATED tool body) must never be
|
|
44
|
+
# reachable through the wrapper, or a caller invoking #execute directly would
|
|
45
|
+
# skip authorization entirely. Route it back to the gated #call.
|
|
46
|
+
def execute(*args, **kwargs)
|
|
47
|
+
call(kwargs.empty? ? (args.first || {}) : kwargs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Forward description/params_schema/to_h/etc. to the wrapped tool so the chat
|
|
51
|
+
# serializes it exactly like the raw MCP tool — but never +execute+ (see above).
|
|
52
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
53
|
+
return true if method_name == :execute
|
|
54
|
+
|
|
55
|
+
@tool.respond_to?(method_name, include_private) || super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delegates any unknown method to the wrapped tool (description,
|
|
59
|
+
# params_schema, to_h, ...), so the wrapper serializes identically. +execute+
|
|
60
|
+
# is handled explicitly above and never falls through here.
|
|
61
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
62
|
+
if @tool.respond_to?(method_name)
|
|
63
|
+
@tool.public_send(method_name, *args, **kwargs, &block)
|
|
64
|
+
else
|
|
65
|
+
super
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/nexo/mcp.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# Composes the +ruby_llm-mcp+ gem so an agent can attach one or more MCP servers
|
|
5
|
+
# with a single +mcp+ class macro — no MCP client wiring by hand. A loaded server
|
|
6
|
+
# contributes tools that are gated by Nexo::Permissions#authorize_mcp! before
|
|
7
|
+
# the model can invoke them (see MCP::GatedTool).
|
|
8
|
+
#
|
|
9
|
+
# +ruby_llm-mcp+ is a SOFT (optional) dependency: it is required lazily by load!
|
|
10
|
+
# the first time an MCP server is built. With the gem absent, +require "nexo"+
|
|
11
|
+
# still loads cleanly; only building a client raises MissingDependencyError
|
|
12
|
+
# with install guidance.
|
|
13
|
+
#
|
|
14
|
+
# Provider-neutral by construction: MCP servers are reached through a server (not
|
|
15
|
+
# a vendor SDK), so behavior is identical on Anthropic, a local model, or anything
|
|
16
|
+
# else +ruby_llm+ supports.
|
|
17
|
+
module MCP
|
|
18
|
+
class << self
|
|
19
|
+
# Lazily loads the +ruby_llm-mcp+ gem. Idempotent (a second call is a cheap
|
|
20
|
+
# no-op once the gem is loaded). Raises MissingDependencyError — naming the
|
|
21
|
+
# gem and the exact remedy — when the gem is not installed. Mirrors
|
|
22
|
+
# Nexo::Skills.load!.
|
|
23
|
+
def load!
|
|
24
|
+
require "ruby_llm/mcp"
|
|
25
|
+
rescue LoadError
|
|
26
|
+
raise MissingDependencyError,
|
|
27
|
+
'MCP requires the `ruby_llm-mcp` gem. Add `gem "ruby_llm-mcp"` to your Gemfile.'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds a +ruby_llm-mcp+ client from Nexo's friendly, transport-shaped macro
|
|
31
|
+
# opts. +name+ and +transport+ map onto the client's +name:+/+transport_type:+;
|
|
32
|
+
# **every other kwarg** is collected into the client's +config:+ hash verbatim
|
|
33
|
+
# (no key renaming) — e.g. +command:+/+args:+ for +:stdio+, +url:+ for +:sse+.
|
|
34
|
+
#
|
|
35
|
+
# +token:+ (Spec 18) is Nexo's own option — a static bearer String or a
|
|
36
|
+
# callable (+#call+) that yields one — for an OAuth-authenticated HTTP-family
|
|
37
|
+
# server (+:http+/+:sse+/+:streamable+). When present, Nexo resolves it and
|
|
38
|
+
# injects an +Authorization: Bearer+ header (see #inject_auth). +token:+ is a
|
|
39
|
+
# dedicated keyword, so it never enters +config:+ (nothing to strip — the
|
|
40
|
+
# handoff config carries no +token+ key by construction). When absent,
|
|
41
|
+
# +config:+ passes through byte-for-byte (no +headers+ key added) — +:stdio+ is
|
|
42
|
+
# untouched. Nexo runs no OAuth flow, refresh, or token store: it injects a
|
|
43
|
+
# header, the host owns the credential (see docs/mcp.md).
|
|
44
|
+
#
|
|
45
|
+
# VERIFY (Group 0, ruby_llm-mcp 1.0.0): the constructor is
|
|
46
|
+
# +RubyLLM::MCP.client(name:, transport_type:, config: {})+ and the client
|
|
47
|
+
# connects on construction (+start: true+ default), so it is reusable across
|
|
48
|
+
# prompts until torn down with +#stop+. The HTTP-family transports read auth
|
|
49
|
+
# headers from +config[:headers]+ (a Hash) and SNAPSHOT them at construction
|
|
50
|
+
# (+@headers = headers || {}+, dup'd per request) — there is no per-request
|
|
51
|
+
# header callback for a plain +headers+ Hash. So a callable +token:+ is
|
|
52
|
+
# resolved ONCE per client build; a rotated token needs the documented
|
|
53
|
+
# +Agent#close+ + fresh-prompt reconnect (Spec 6 memoizes the client).
|
|
54
|
+
def build(name:, transport:, token: nil, **config)
|
|
55
|
+
load!
|
|
56
|
+
config = inject_auth(config, token) unless token.nil?
|
|
57
|
+
client_factory.client(name: name.to_s, transport_type: transport, config: config)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolves a static-or-callable token and returns +config+ with an
|
|
61
|
+
# +Authorization: Bearer+ header merged under +:headers+ (the Group-0-verified
|
|
62
|
+
# key). A caller's other headers are preserved; Nexo's +Authorization+ wins.
|
|
63
|
+
#
|
|
64
|
+
# SECURITY: the token is a live credential. It must stay out of logs, events,
|
|
65
|
+
# persisted +WorkflowRun+ records, and URLs/query strings — it lives only in
|
|
66
|
+
# this in-memory header Hash handed to +ruby_llm-mcp+. The event path
|
|
67
|
+
# (Workflow#serializable) carries only tool +name+/+args+ and return values,
|
|
68
|
+
# never connection config, so the token never reaches +emit+ (see the no-leak
|
|
69
|
+
# test). Nexo does not police +ruby_llm-mcp+'s own internal logging.
|
|
70
|
+
private def inject_auth(config, token)
|
|
71
|
+
value = token.respond_to?(:call) ? token.call : token
|
|
72
|
+
headers = (config[:headers] || {}).merge("Authorization" => "Bearer #{value}")
|
|
73
|
+
config.merge(headers: headers)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The object whose +#client+ builds the MCP client — +::RubyLLM::MCP+ in
|
|
77
|
+
# production. Overridable via #stub_client_factory so an offline test can
|
|
78
|
+
# inspect the exact +config:+ Nexo hands off without a live server or stubbing
|
|
79
|
+
# a constant.
|
|
80
|
+
def client_factory
|
|
81
|
+
@client_factory || ::RubyLLM::MCP
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Swaps #client_factory to +factory+ for the duration of the block, then
|
|
85
|
+
# restores it. Test-only seam (Spec 18) — keeps the +token:+ transform
|
|
86
|
+
# unit-testable at the +ruby_llm-mcp+ boundary.
|
|
87
|
+
def stub_client_factory(factory)
|
|
88
|
+
previous = @client_factory
|
|
89
|
+
@client_factory = factory
|
|
90
|
+
yield
|
|
91
|
+
ensure
|
|
92
|
+
@client_factory = previous
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# Bounds unbounded command output before it reaches the model, so a single
|
|
5
|
+
# +npm install+ or +git log+ can't blow a small context window. Pure and
|
|
6
|
+
# dependency-free — line/char based, no tokenizer (a token budget would be
|
|
7
|
+
# expensive to reimplement and is out of scope for v1).
|
|
8
|
+
#
|
|
9
|
+
# The transform, in order: strip ANSI SGR escapes, keep the *last*
|
|
10
|
+
# +max_lines+ lines (the tail is where errors and exit summaries live), append
|
|
11
|
+
# a +…[truncated N lines]+ marker when lines were dropped, then hard-cap the
|
|
12
|
+
# result at +max_chars+ characters. Configurable via kwargs only — there is no
|
|
13
|
+
# +Nexo.config+ global and no per-agent macro in v1.
|
|
14
|
+
module OutputTruncator
|
|
15
|
+
# Matches ANSI SGR color/style escapes (e.g. +\e[31m+, +\e[1;32m+, +\e[0m+),
|
|
16
|
+
# the escapes real command output emits. VERIFIED against real output in
|
|
17
|
+
# Group 0.
|
|
18
|
+
ANSI = /\e\[[0-9;]*m/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Strips ANSI escapes from +text+, keeps the last +max_lines+ lines (with a
|
|
23
|
+
# +…[truncated N lines]+ marker when it trims), then caps the result at
|
|
24
|
+
# +max_chars+. Pure line/char truncation — no tokenizer.
|
|
25
|
+
def call(text, max_lines: 200, max_chars: 16_000)
|
|
26
|
+
return text if text.nil? || text.empty?
|
|
27
|
+
|
|
28
|
+
stripped = text.gsub(ANSI, "")
|
|
29
|
+
lines = stripped.split("\n", -1)
|
|
30
|
+
dropped = lines.size - max_lines
|
|
31
|
+
|
|
32
|
+
result = if dropped.positive?
|
|
33
|
+
(lines.last(max_lines) + ["…[truncated #{dropped} lines]"]).join("\n")
|
|
34
|
+
else
|
|
35
|
+
stripped
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
(result.length > max_chars) ? result[-max_chars..] : result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|