From 1eec9d2eb0068d779cbab89cd191b58720738af0 Mon Sep 17 00:00:00 2001 From: st0012 Date: Wed, 18 Mar 2026 00:32:35 +0000 Subject: [PATCH 1/3] Add experimental agent mode for binding.irb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `binding.irb(agent: true)` for non-interactive use by AI agents. Uses a two-phase workflow controlled by `IRB_SOCK_PATH` env var: - Phase 1 (no socket path): prints instructions and exits, so the agent learns the protocol from the command output. - Phase 2 (socket path set): starts a Unix socket server with a request/response model — one command per connection, session state persists across requests, `exit` resumes app execution. This replaces the previous persistent-connection design with a simpler HTTP-like model that works with agents that can only run non-interactive shell commands. --- doc/Index.md | 25 +++++ lib/irb.rb | 28 ++++- lib/irb/remote_server.rb | 157 ++++++++++++++++++++++++++++ test/irb/test_remote.rb | 218 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 lib/irb/remote_server.rb create mode 100644 test/irb/test_remote.rb diff --git a/doc/Index.md b/doc/Index.md index a50432d59..7893ec16e 100644 --- a/doc/Index.md +++ b/doc/Index.md @@ -93,6 +93,31 @@ You can use IRB as a debugging console with `debug.gem` with these options: To learn more about debugging with IRB, see [Debugging with IRB](#label-Debugging+with+IRB). +### Agent Mode (Experimental) + +`binding.irb(agent: true)` starts a non-interactive IRB session designed for AI agents and scripts. Instead of opening a REPL, it exposes an IRB session over a Unix socket using a simple request/response protocol. + +The behavior depends on the `IRB_SOCK_PATH` environment variable: + +- **Not set**: prints instructions explaining the workflow, then exits. This lets the agent discover the breakpoint and learn the protocol. +- **Set**: starts a Unix socket server at the given path. Each connection accepts one command, evaluates it, returns the result, and closes. The IRB session state persists across connections. Send `exit` to end the session and resume app execution. + +```console +# 1. First run — discover the breakpoint: +$ ruby app.rb +IRB agent breakpoint hit at app.rb:14 in `cook!` +... + +# 2. Re-run in background with a socket path: +$ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb & + +# 3. Send commands: +$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close' +$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close' +``` + +See IRB::RemoteServer for more details. + ## Startup At startup, IRB: diff --git a/lib/irb.rb b/lib/irb.rb index af65c8a13..99797a066 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -745,9 +745,35 @@ class Binding # Cooked potato: true # # See IRB for more information. - def irb(show_code: true) + # + # When +agent+ is true, the session is designed for non-interactive use by + # AI agents or scripts (experimental). Instead of opening an interactive REPL, + # it starts a Unix socket server that accepts one command per connection. See + # IRB::RemoteServer for the full protocol and workflow. + def irb(show_code: true, agent: false) + if agent + require_relative "irb/remote_server" + sock_path = ENV['IRB_SOCK_PATH'] + + # Phase 1 (discovery): no socket path set, so print instructions + # teaching the agent how to connect, then exit immediately. + # No IRB.setup needed since we're not starting a session. + unless sock_path + IRB::RemoteServer.print_instructions(self) + exit(0) + end + + # Phase 2 (debug session): socket path set, start a request/response + # server that the agent can send commands to. + IRB.setup(source_location[0], argv: []) unless IRB.initialized? + server = IRB::RemoteServer.new(self, sock_path: sock_path) + server.run + return + end + # Setup IRB with the current file's path and no command line arguments IRB.setup(source_location[0], argv: []) unless IRB.initialized? + # Create a new workspace using the current binding workspace = IRB::WorkSpace.new(self) # Print the code around the binding if show_code is true diff --git a/lib/irb/remote_server.rb b/lib/irb/remote_server.rb new file mode 100644 index 000000000..cb546f216 --- /dev/null +++ b/lib/irb/remote_server.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'socket' +require 'stringio' + +module IRB + # A request/response server for agent-driven IRB sessions over a Unix socket + # (experimental). + # + # When binding.irb(agent: true) is called, the behavior depends on + # the +IRB_SOCK_PATH+ environment variable: + # + # - *Not set* (Phase 1 — discovery): prints instructions explaining how to + # start a debug session, then calls exit(0). This lets the agent + # see the instructions before the process terminates. + # + # - *Set* (Phase 2 — debug session): starts a Unix socket server at the + # given path. The server accepts one connection at a time in a loop. Each + # connection is a single request: the client sends Ruby code (or an IRB + # command), closes its write end, and reads back the result. The IRB session + # state persists across requests. Sending +exit+ ends the loop and resumes + # execution of the host program. + # + # == Example agent workflow + # + # # 1. Run the app — hits breakpoint, prints instructions, exits: + # $ ruby app.rb + # + # # 2. Re-run in background with a socket path: + # $ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb & + # + # # 3. Send commands (one per connection): + # $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close' + # $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close' + # + class RemoteServer + def initialize(binding_context, sock_path:) + @binding_context = binding_context + @sock_path = sock_path + end + + class StringInput < InputMethod + def initialize(str) + super() + @io = StringIO.new(str) + end + + def gets + @io.gets + end + + def eof? + @io.eof? + end + + def encoding + Encoding::UTF_8 + end + end + + def run + File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier + + server = UNIXServer.new(@sock_path) + File.chmod(0600, @sock_path) + + original_stdout = $stdout + + IRB.conf[:USE_PAGER] = false + + binding_irb = create_irb + IRB.conf[:MAIN_CONTEXT] = binding_irb.context + + begin + loop do + client = server.accept + input = client.read + break if input.nil? || input.empty? + + binding_irb.context.io = StringInput.new(input) + + not_exited = catch(:IRB_EXIT) do + begin + $stdout = client + binding_irb.eval_input + ensure + $stdout = original_stdout + end + :not_exited + end + + client.close rescue nil # rubocop:disable Style/RescueModifier + + break unless not_exited == :not_exited + end + ensure + $stdout = original_stdout + server&.close + File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier + end + end + + private + + def create_irb + workspace = IRB::WorkSpace.new(@binding_context) + irb = IRB::Irb.new(workspace, StringInput.new(""), from_binding: true) + irb.context.irb_path = File.expand_path(@binding_context.source_location[0]) + irb.context.verbose = false + irb.context.newline_before_multiline_output = false + irb + end + + class << self + def print_instructions(binding_context) + file, line = binding_context.source_location + method_name = binding_context.eval("__method__") rescue nil # rubocop:disable Style/RescueModifier + location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}" + + $stdout.puts <<~MSG + ══════════════════════════════════════════════════════════ + IRB agent breakpoint hit at #{location} + + No IRB_SOCK_PATH set — exiting without starting a debug session. + + To debug this breakpoint: + + 1. Run the app in the BACKGROUND with a socket path: + + IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock + + The process will block waiting for a connection. + + 2. Wait for the socket file to appear: + + ls /tmp/irb-UNIQUE_ID.sock + + 3. Send commands to the socket with FOREGROUND commands: + + ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close' + + Each invocation sends one command and prints the result. + The IRB session persists between invocations. + + Examples: + ... s.puts "ls"; s.close_write # list methods and variables + ... s.puts "show_source foo"; s.close_write # see source of a method + ... s.puts "@name"; s.close_write # inspect a variable + ... s.puts "exit"; s.close_write # end session, resume app + + ══════════════════════════════════════════════════════════ + MSG + $stdout.flush + end + end + end +end diff --git a/test/irb/test_remote.rb b/test/irb/test_remote.rb new file mode 100644 index 000000000..137887232 --- /dev/null +++ b/test/irb/test_remote.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "socket" +require "tempfile" +require_relative "helper" + +module TestIRB + class RemoteTest < IntegrationTestCase + def test_phase1_prints_instructions_and_exits + write_ruby <<~'RUBY' + puts "BEFORE" + binding.irb(agent: true) + puts "AFTER" + RUBY + + output = run_ruby_file do + type("should not get here") + end + + assert_include output, "IRB agent breakpoint hit at" + assert_include output, "IRB_SOCK_PATH" + assert_include output, "BEFORE" + # exit(0) means AFTER should not print + assert_not_include output, "AFTER" + end + + def test_phase2_basic_eval + write_ruby <<~'RUBY' + binding.irb(agent: true) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "1 + 1") + send_command(sock_path, "exit") + end + + assert_include output, "=> 2" + end + + def test_phase2_ls_command + write_ruby <<~'RUBY' + class Potato + attr_accessor :name + def initialize(name) = @name = name + end + Potato.new("Russet").instance_eval { binding.irb(agent: true) } + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "ls") + send_command(sock_path, "exit") + end + + assert_include output, "name" + assert_include output, "@name" + end + + def test_phase2_show_source_command + write_ruby <<~'RUBY' + class Potato + def cook! = "done" + end + Potato.new.instance_eval { binding.irb(agent: true) } + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "show_source cook!") + send_command(sock_path, "exit") + end + + assert_include output, "def cook!" + end + + def test_phase2_error_handling + write_ruby <<~'RUBY' + binding.irb(agent: true) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "undefined_var") + send_command(sock_path, "exit") + end + + assert_include output, "NameError" + end + + def test_phase2_multiline_expression + write_ruby <<~'RUBY' + binding.irb(agent: true) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "def double(x)\n x * 2\nend") + send_command(sock_path, "double(21)") + send_command(sock_path, "exit") + end + + assert_include output, "=> :double" + assert_include output, "=> 42" + end + + def test_phase2_session_state_persists + write_ruby <<~'RUBY' + binding.irb(agent: true) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "x = 42") + send_command(sock_path, "x * 2") + send_command(sock_path, "exit") + end + + assert_include output, "=> 42" + assert_include output, "=> 84" + end + + def test_phase2_resumes_execution_after_exit + write_ruby <<~'RUBY' + puts "BEFORE" + binding.irb(agent: true) + puts "AFTER" + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "exit") + end + + assert_include output, "BEFORE" + assert_include output, "AFTER" + end + + def test_phase2_help_command + write_ruby <<~'RUBY' + binding.irb(agent: true) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "help") + send_command(sock_path, "exit") + end + + assert_include output, "show_source" + assert_include output, "ls" + end + + private + + def run_agent_session(timeout: TIMEOUT_SEC) + cmd = [EnvUtil.rubybin, "-I", LIB, @ruby_file.to_path] + tmp_dir = Dir.mktmpdir + sock_path = File.join(tmp_dir, "irb-test.sock") + pty_lines = [] + @command_output = +"" + + @envs["HOME"] ||= tmp_dir + @envs["XDG_CONFIG_HOME"] ||= tmp_dir + @envs["IRBRC"] = nil unless @envs.key?("IRBRC") + + envs_for_spawn = { 'TERM' => 'dumb', 'IRB_SOCK_PATH' => sock_path }.merge(@envs) + + PTY.spawn(envs_for_spawn, *cmd) do |read, write, pid| + Timeout.timeout(timeout) do + # Collect PTY output in background — the process produces no stdout + # until after the IRB session ends (e.g. puts after the breakpoint). + reader = Thread.new do + while line = safe_gets(read) + pty_lines << line + end + end + + poll_until { File.exist?(sock_path) } + + yield sock_path + + reader.join(timeout) + end + ensure + read.close + write.close + kill_safely(pid) + end + + pty_lines.join + @command_output + rescue Timeout::Error + message = <<~MSG + Test timed out. + + #{'=' * 30} PTY OUTPUT #{'=' * 30} + #{pty_lines.map { |l| " #{l}" }.join} + #{'=' * 27} COMMAND OUTPUT #{'=' * 27} + #{@command_output} + #{'=' * 27} END #{'=' * 27} + MSG + assert_block(message) { false } + ensure + FileUtils.remove_entry tmp_dir + end + + def send_command(sock_path, cmd) + sock = UNIXSocket.new(sock_path) + sock.puts cmd + sock.close_write + result = sock.read + @command_output << result + result + ensure + sock&.close + end + + def poll_until(timeout: TIMEOUT_SEC, interval: 0.05) + deadline = Time.now + timeout + until yield + raise Timeout::Error, "poll_until timed out" if Time.now > deadline + sleep interval + end + end + end +end From 25796f81400359b86886b6a7a4c8513dfc0fe920 Mon Sep 17 00:00:00 2001 From: st0012 Date: Wed, 18 Mar 2026 00:45:09 +0000 Subject: [PATCH 2/3] Fix test compatibility with Ruby < 4.0 - Add `require "irb"` to test scripts so our gem's Binding#irb (which accepts agent:) is loaded instead of the prelude stub - Replace endless method syntax with standard syntax for Ruby 2.7 --- test/irb/test_remote.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/irb/test_remote.rb b/test/irb/test_remote.rb index 137887232..c081033db 100644 --- a/test/irb/test_remote.rb +++ b/test/irb/test_remote.rb @@ -8,6 +8,7 @@ module TestIRB class RemoteTest < IntegrationTestCase def test_phase1_prints_instructions_and_exits write_ruby <<~'RUBY' + require "irb" puts "BEFORE" binding.irb(agent: true) puts "AFTER" @@ -26,6 +27,7 @@ def test_phase1_prints_instructions_and_exits def test_phase2_basic_eval write_ruby <<~'RUBY' + require "irb" binding.irb(agent: true) RUBY @@ -39,9 +41,10 @@ def test_phase2_basic_eval def test_phase2_ls_command write_ruby <<~'RUBY' + require "irb" class Potato attr_accessor :name - def initialize(name) = @name = name + def initialize(name); @name = name; end end Potato.new("Russet").instance_eval { binding.irb(agent: true) } RUBY @@ -57,8 +60,9 @@ def initialize(name) = @name = name def test_phase2_show_source_command write_ruby <<~'RUBY' + require "irb" class Potato - def cook! = "done" + def cook!; "done"; end end Potato.new.instance_eval { binding.irb(agent: true) } RUBY @@ -73,6 +77,7 @@ def cook! = "done" def test_phase2_error_handling write_ruby <<~'RUBY' + require "irb" binding.irb(agent: true) RUBY @@ -86,6 +91,7 @@ def test_phase2_error_handling def test_phase2_multiline_expression write_ruby <<~'RUBY' + require "irb" binding.irb(agent: true) RUBY @@ -101,6 +107,7 @@ def test_phase2_multiline_expression def test_phase2_session_state_persists write_ruby <<~'RUBY' + require "irb" binding.irb(agent: true) RUBY @@ -116,6 +123,7 @@ def test_phase2_session_state_persists def test_phase2_resumes_execution_after_exit write_ruby <<~'RUBY' + require "irb" puts "BEFORE" binding.irb(agent: true) puts "AFTER" @@ -131,6 +139,7 @@ def test_phase2_resumes_execution_after_exit def test_phase2_help_command write_ruby <<~'RUBY' + require "irb" binding.irb(agent: true) RUBY From fcfcea92cb2fcd279e4e95dffaec2f000c0bfd46 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 2 Apr 2026 22:24:14 +0100 Subject: [PATCH 3/3] WIP --- doc/Index.md | 2 +- lib/irb.rb | 60 +++++++++++++++++++++++----------------- lib/irb/remote_server.rb | 4 ++- test/irb/test_remote.rb | 19 +++++++------ 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/doc/Index.md b/doc/Index.md index 7893ec16e..aa1561f23 100644 --- a/doc/Index.md +++ b/doc/Index.md @@ -95,7 +95,7 @@ To learn more about debugging with IRB, see [Debugging with IRB](#label-Debuggin ### Agent Mode (Experimental) -`binding.irb(agent: true)` starts a non-interactive IRB session designed for AI agents and scripts. Instead of opening a REPL, it exposes an IRB session over a Unix socket using a simple request/response protocol. +`binding.agent` starts a non-interactive IRB session designed for AI agents and scripts. Instead of opening a REPL, it exposes an IRB session over a Unix socket using a simple request/response protocol. The behavior depends on the `IRB_SOCK_PATH` environment variable: diff --git a/lib/irb.rb b/lib/irb.rb index 99797a066..ec9b8a30f 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -746,31 +746,7 @@ class Binding # # See IRB for more information. # - # When +agent+ is true, the session is designed for non-interactive use by - # AI agents or scripts (experimental). Instead of opening an interactive REPL, - # it starts a Unix socket server that accepts one command per connection. See - # IRB::RemoteServer for the full protocol and workflow. - def irb(show_code: true, agent: false) - if agent - require_relative "irb/remote_server" - sock_path = ENV['IRB_SOCK_PATH'] - - # Phase 1 (discovery): no socket path set, so print instructions - # teaching the agent how to connect, then exit immediately. - # No IRB.setup needed since we're not starting a session. - unless sock_path - IRB::RemoteServer.print_instructions(self) - exit(0) - end - - # Phase 2 (debug session): socket path set, start a request/response - # server that the agent can send commands to. - IRB.setup(source_location[0], argv: []) unless IRB.initialized? - server = IRB::RemoteServer.new(self, sock_path: sock_path) - server.run - return - end - + def irb(show_code: true) # Setup IRB with the current file's path and no command line arguments IRB.setup(source_location[0], argv: []) unless IRB.initialized? @@ -801,4 +777,38 @@ def irb(show_code: true, agent: false) binding_irb.debug_break end end + + # Opens a non-interactive IRB session designed for AI agents and scripts + # (experimental). Instead of opening a REPL, it exposes an IRB session over a + # Unix socket using a simple request/response protocol. + # + # The behavior depends on the +IRB_SOCK_PATH+ environment variable: + # + # - *Not set* (Phase 1 — discovery): prints instructions explaining the + # workflow, then exits. This lets the agent discover the breakpoint and + # learn the protocol. + # - *Set* (Phase 2 — debug session): starts a Unix socket server at the + # given path. Each connection accepts one command, evaluates it, returns + # the result, and closes. The IRB session state persists across + # connections. Send +exit+ to end the session and resume app execution. + # + # See IRB::RemoteServer for the full protocol and workflow. + def agent + require_relative "irb/remote_server" + sock_path = ENV['IRB_SOCK_PATH'] + + # Phase 1 (discovery): no socket path set, so print instructions + # teaching the agent how to connect, then exit immediately. + # No IRB.setup needed since we're not starting a session. + unless sock_path + IRB::RemoteServer.print_instructions(self) + exit(0) + end + + # Phase 2 (debug session): socket path set, start a request/response + # server that the agent can send commands to. + IRB.setup(source_location[0], argv: []) unless IRB.initialized? + server = IRB::RemoteServer.new(self, sock_path: sock_path) + server.run + end end diff --git a/lib/irb/remote_server.rb b/lib/irb/remote_server.rb index cb546f216..15439c1ad 100644 --- a/lib/irb/remote_server.rb +++ b/lib/irb/remote_server.rb @@ -7,7 +7,7 @@ module IRB # A request/response server for agent-driven IRB sessions over a Unix socket # (experimental). # - # When binding.irb(agent: true) is called, the behavior depends on + # When binding.agent is called, the behavior depends on # the +IRB_SOCK_PATH+ environment variable: # # - *Not set* (Phase 1 — discovery): prints instructions explaining how to @@ -123,6 +123,8 @@ def print_instructions(binding_context) No IRB_SOCK_PATH set — exiting without starting a debug session. + Add breakpoints with: require "irb"; binding.agent + To debug this breakpoint: 1. Run the app in the BACKGROUND with a socket path: diff --git a/test/irb/test_remote.rb b/test/irb/test_remote.rb index c081033db..994c0564e 100644 --- a/test/irb/test_remote.rb +++ b/test/irb/test_remote.rb @@ -10,7 +10,7 @@ def test_phase1_prints_instructions_and_exits write_ruby <<~'RUBY' require "irb" puts "BEFORE" - binding.irb(agent: true) + binding.agent puts "AFTER" RUBY @@ -20,6 +20,7 @@ def test_phase1_prints_instructions_and_exits assert_include output, "IRB agent breakpoint hit at" assert_include output, "IRB_SOCK_PATH" + assert_include output, 'require "irb"; binding.agent' assert_include output, "BEFORE" # exit(0) means AFTER should not print assert_not_include output, "AFTER" @@ -28,7 +29,7 @@ def test_phase1_prints_instructions_and_exits def test_phase2_basic_eval write_ruby <<~'RUBY' require "irb" - binding.irb(agent: true) + binding.agent RUBY output = run_agent_session do |sock_path| @@ -46,7 +47,7 @@ class Potato attr_accessor :name def initialize(name); @name = name; end end - Potato.new("Russet").instance_eval { binding.irb(agent: true) } + Potato.new("Russet").instance_eval { binding.agent } RUBY output = run_agent_session do |sock_path| @@ -64,7 +65,7 @@ def test_phase2_show_source_command class Potato def cook!; "done"; end end - Potato.new.instance_eval { binding.irb(agent: true) } + Potato.new.instance_eval { binding.agent } RUBY output = run_agent_session do |sock_path| @@ -78,7 +79,7 @@ def cook!; "done"; end def test_phase2_error_handling write_ruby <<~'RUBY' require "irb" - binding.irb(agent: true) + binding.agent RUBY output = run_agent_session do |sock_path| @@ -92,7 +93,7 @@ def test_phase2_error_handling def test_phase2_multiline_expression write_ruby <<~'RUBY' require "irb" - binding.irb(agent: true) + binding.agent RUBY output = run_agent_session do |sock_path| @@ -108,7 +109,7 @@ def test_phase2_multiline_expression def test_phase2_session_state_persists write_ruby <<~'RUBY' require "irb" - binding.irb(agent: true) + binding.agent RUBY output = run_agent_session do |sock_path| @@ -125,7 +126,7 @@ def test_phase2_resumes_execution_after_exit write_ruby <<~'RUBY' require "irb" puts "BEFORE" - binding.irb(agent: true) + binding.agent puts "AFTER" RUBY @@ -140,7 +141,7 @@ def test_phase2_resumes_execution_after_exit def test_phase2_help_command write_ruby <<~'RUBY' require "irb" - binding.irb(agent: true) + binding.agent RUBY output = run_agent_session do |sock_path|