nexo_ai 0.1.0 → 0.7.0

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