Skip to content

Commit ae4de56

Browse files
committed
Add experimental agent mode for binding.agent
1 parent 6073bb0 commit ae4de56

5 files changed

Lines changed: 519 additions & 1 deletion

File tree

doc/Index.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,37 @@ You can use IRB as a debugging console with `debug.gem` with these options:
9393

9494
To learn more about debugging with IRB, see [Debugging with IRB](#label-Debugging+with+IRB).
9595

96+
### Agent Mode (Experimental)
97+
98+
`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. It is available on platforms where Ruby supports Unix sockets.
99+
100+
The behavior depends on the `IRB_SOCK_PATH` environment variable:
101+
102+
- **Not set**: prints instructions explaining the workflow, then exits. This lets the agent discover the breakpoint and learn the protocol.
103+
- **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.
104+
105+
```console
106+
# app.rb
107+
require "irb"
108+
binding.agent
109+
```
110+
111+
```console
112+
# 1. First run: discover the breakpoint.
113+
$ ruby app.rb
114+
IRB agent breakpoint hit at app.rb:14 in `cook!`
115+
...
116+
117+
# 2. Re-run in background with a socket path.
118+
$ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb &
119+
120+
# 3. Send commands.
121+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close'
122+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close'
123+
```
124+
125+
See IRB::RemoteServer for more details.
126+
96127
## Startup
97128

98129
At startup, IRB:
@@ -681,7 +712,7 @@ This integration offers several benefits over `debug.gem`'s native console:
681712
However, there are some limitations to be aware of:
682713

683714
1. `binding.irb` doesn't support `pre` and `do` arguments like [binding.break](https://github.com/ruby/debug#bindingbreak-method).
684-
2. As IRB [doesn't currently support remote-connection](https://github.com/ruby/irb/issues/672), it can't be used with `debug.gem`'s remote debugging feature.
715+
2. The regular `binding.irb` console does not support `debug.gem`'s remote debugging feature. For agent-oriented remote evaluation, use the separate `binding.agent` Unix-socket workflow.
685716
3. Access to the previous return value via the underscore `_` is not supported.
686717

687718
## Encodings

lib/irb.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,9 +745,11 @@ class Binding
745745
# Cooked potato: true
746746
#
747747
# See IRB for more information.
748+
#
748749
def irb(show_code: true)
749750
# Setup IRB with the current file's path and no command line arguments
750751
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
752+
751753
# Create a new workspace using the current binding
752754
workspace = IRB::WorkSpace.new(self)
753755
# Print the code around the binding if show_code is true
@@ -775,4 +777,38 @@ def irb(show_code: true)
775777
binding_irb.debug_break
776778
end
777779
end
780+
781+
# Opens a non-interactive IRB session designed for AI agents and scripts
782+
# (experimental). Instead of opening a REPL, it exposes an IRB session over a
783+
# Unix socket using a simple request/response protocol.
784+
#
785+
# The behavior depends on the +IRB_SOCK_PATH+ environment variable:
786+
#
787+
# - *Not set* (Phase 1 - discovery): prints instructions explaining the
788+
# workflow, then exits. This lets the agent discover the breakpoint and
789+
# learn the protocol.
790+
# - *Set* (Phase 2 - debug session): starts a Unix socket server at the
791+
# given path. Each connection accepts one command, evaluates it, returns
792+
# the result, and closes. The IRB session state persists across
793+
# connections. Send +exit+ to end the session and resume app execution.
794+
#
795+
# See IRB::RemoteServer for the full protocol and workflow.
796+
def agent
797+
require_relative "irb/remote_server"
798+
sock_path = ENV["IRB_SOCK_PATH"]
799+
800+
# Phase 1 (discovery): no socket path set, so print instructions
801+
# teaching the agent how to connect, then exit immediately.
802+
# No IRB.setup needed since we're not starting a session.
803+
if sock_path.nil? || sock_path.empty?
804+
IRB::RemoteServer.print_instructions(self)
805+
Kernel.exit(0)
806+
end
807+
808+
# Phase 2 (debug session): socket path set, start a request/response
809+
# server that the agent can send commands to.
810+
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
811+
server = IRB::RemoteServer.new(self, sock_path: sock_path)
812+
server.run
813+
end
778814
end

lib/irb/remote_server.rb

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# frozen_string_literal: true
2+
3+
require "socket"
4+
require "stringio"
5+
6+
module IRB
7+
# A request/response server for agent-driven IRB sessions over a Unix socket
8+
# (experimental).
9+
#
10+
# When <tt>binding.agent</tt> is called, the behavior depends on
11+
# the +IRB_SOCK_PATH+ environment variable:
12+
#
13+
# - *Not set* (Phase 1 - discovery): prints instructions explaining how to
14+
# start a debug session, then calls <tt>exit(0)</tt>. This lets the agent
15+
# see the instructions before the process terminates.
16+
#
17+
# - *Set* (Phase 2 - debug session): starts a Unix socket server at the
18+
# given path. The server accepts one connection at a time in a loop. Each
19+
# connection is a single request: the client sends Ruby code (or an IRB
20+
# command), closes its write end, and reads back the result. The IRB session
21+
# state persists across requests. Sending +exit+ ends the loop and resumes
22+
# execution of the host program.
23+
#
24+
# == Example agent workflow
25+
#
26+
# # 1. Run the app - hits breakpoint, prints instructions, exits:
27+
# $ ruby app.rb
28+
#
29+
# # 2. Re-run in background with a socket path:
30+
# $ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb &
31+
#
32+
# # 3. Send commands (one per connection):
33+
# $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close'
34+
# $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close'
35+
#
36+
class RemoteServer
37+
def initialize(binding_context, sock_path:)
38+
@binding_context = binding_context
39+
@sock_path = sock_path
40+
end
41+
42+
class StringInput < InputMethod
43+
def initialize(str)
44+
super()
45+
@io = StringIO.new(str)
46+
end
47+
48+
def gets
49+
@io.gets
50+
end
51+
52+
def eof?
53+
@io.eof?
54+
end
55+
56+
def encoding
57+
Encoding::UTF_8
58+
end
59+
end
60+
61+
def run
62+
server = nil
63+
original_stdout = $stdout
64+
original_main_context = IRB.conf[:MAIN_CONTEXT]
65+
original_use_pager = IRB.conf[:USE_PAGER]
66+
67+
begin
68+
remove_socket_file
69+
70+
server = UNIXServer.new(@sock_path)
71+
File.chmod(0600, @sock_path)
72+
73+
IRB.conf[:USE_PAGER] = false
74+
75+
binding_irb = create_irb
76+
IRB.conf[:MAIN_CONTEXT] = binding_irb.context
77+
78+
loop do
79+
client = server.accept
80+
begin
81+
input = client.read
82+
next if input.nil? || input.empty?
83+
84+
binding_irb.context.io = StringInput.new(input)
85+
86+
not_exited = catch(:IRB_EXIT) do
87+
begin
88+
$stdout = client
89+
binding_irb.eval_input
90+
ensure
91+
$stdout = original_stdout
92+
end
93+
:not_exited
94+
end
95+
96+
break unless not_exited == :not_exited
97+
ensure
98+
client&.close
99+
end
100+
101+
end
102+
ensure
103+
$stdout = original_stdout
104+
IRB.conf[:USE_PAGER] = original_use_pager
105+
# Match Irb#run: restoring MAIN_CONTEXT to nil can crash threaded sessions.
106+
IRB.conf[:MAIN_CONTEXT] = original_main_context if original_main_context
107+
server&.close
108+
remove_socket_file
109+
end
110+
end
111+
112+
private
113+
114+
def remove_socket_file
115+
File.unlink(@sock_path) if File.lstat(@sock_path).socket?
116+
rescue Errno::ENOENT
117+
end
118+
119+
def create_irb
120+
workspace = IRB::WorkSpace.new(@binding_context)
121+
irb = IRB::Irb.new(workspace, StringInput.new(""), from_binding: true)
122+
irb.context.irb_path = File.expand_path(@binding_context.source_location[0])
123+
irb.context.verbose = false
124+
irb.context.newline_before_multiline_output = false
125+
irb
126+
end
127+
128+
class << self
129+
def print_instructions(binding_context)
130+
file, line = binding_context.source_location
131+
method_name = binding_context.eval("__method__") rescue nil # rubocop:disable Style/RescueModifier
132+
location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}"
133+
134+
$stdout.puts <<~MSG
135+
==========================================================
136+
IRB agent breakpoint hit at #{location}
137+
138+
No IRB_SOCK_PATH set - exiting without starting a debug session.
139+
140+
Add breakpoints with: require "irb"; binding.agent
141+
142+
To debug this breakpoint:
143+
144+
1. Run the app in the BACKGROUND with a socket path:
145+
146+
IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock <your command> &
147+
148+
The process will block waiting for a connection.
149+
150+
2. Wait for the socket file to appear:
151+
152+
ls /tmp/irb-UNIQUE_ID.sock
153+
154+
3. Send commands to the socket with FOREGROUND commands:
155+
156+
ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close'
157+
158+
Each invocation sends one command and prints the result.
159+
The IRB session persists between invocations.
160+
161+
Examples:
162+
... s.puts "ls"; s.close_write # list methods and variables
163+
... s.puts "show_source foo"; s.close_write # see source of a method
164+
... s.puts "@name"; s.close_write # inspect a variable
165+
... s.puts "exit"; s.close_write # end session, resume app
166+
167+
==========================================================
168+
MSG
169+
$stdout.flush
170+
end
171+
end
172+
end
173+
end

test/irb/test_irb.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,11 @@ class ContextModeTest < TestIRB::IntegrationTestCase
994994
def test_context_mode_ruby_box
995995
omit if RUBY_VERSION < "4.0.0"
996996
@envs['RUBY_BOX'] = '1'
997+
ENV.each_key.grep(/\ABUNDLE/).each do |key|
998+
@envs[key] = nil
999+
end
1000+
@envs["RUBYOPT"] = nil
1001+
@envs["RUBYLIB"] = nil
9971002

9981003
write_rc <<~'RUBY'
9991004
IRB.conf[:CONTEXT_MODE] = 5

0 commit comments

Comments
 (0)