Skip to content

Commit 1eec9d2

Browse files
committed
Add experimental agent mode for binding.irb
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.
1 parent cba8ee1 commit 1eec9d2

4 files changed

Lines changed: 427 additions & 1 deletion

File tree

doc/Index.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ 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.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.
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+
# 1. First run — discover the breakpoint:
107+
$ ruby app.rb
108+
IRB agent breakpoint hit at app.rb:14 in `cook!`
109+
...
110+
111+
# 2. Re-run in background with a socket path:
112+
$ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb &
113+
114+
# 3. Send commands:
115+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close'
116+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close'
117+
```
118+
119+
See IRB::RemoteServer for more details.
120+
96121
## Startup
97122

98123
At startup, IRB:

lib/irb.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,9 +745,35 @@ class Binding
745745
# Cooked potato: true
746746
#
747747
# See IRB for more information.
748-
def irb(show_code: true)
748+
#
749+
# When +agent+ is true, the session is designed for non-interactive use by
750+
# AI agents or scripts (experimental). Instead of opening an interactive REPL,
751+
# it starts a Unix socket server that accepts one command per connection. See
752+
# IRB::RemoteServer for the full protocol and workflow.
753+
def irb(show_code: true, agent: false)
754+
if agent
755+
require_relative "irb/remote_server"
756+
sock_path = ENV['IRB_SOCK_PATH']
757+
758+
# Phase 1 (discovery): no socket path set, so print instructions
759+
# teaching the agent how to connect, then exit immediately.
760+
# No IRB.setup needed since we're not starting a session.
761+
unless sock_path
762+
IRB::RemoteServer.print_instructions(self)
763+
exit(0)
764+
end
765+
766+
# Phase 2 (debug session): socket path set, start a request/response
767+
# server that the agent can send commands to.
768+
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
769+
server = IRB::RemoteServer.new(self, sock_path: sock_path)
770+
server.run
771+
return
772+
end
773+
749774
# Setup IRB with the current file's path and no command line arguments
750775
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
776+
751777
# Create a new workspace using the current binding
752778
workspace = IRB::WorkSpace.new(self)
753779
# Print the code around the binding if show_code is true

lib/irb/remote_server.rb

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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.irb(agent: true)</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+
File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier
63+
64+
server = UNIXServer.new(@sock_path)
65+
File.chmod(0600, @sock_path)
66+
67+
original_stdout = $stdout
68+
69+
IRB.conf[:USE_PAGER] = false
70+
71+
binding_irb = create_irb
72+
IRB.conf[:MAIN_CONTEXT] = binding_irb.context
73+
74+
begin
75+
loop do
76+
client = server.accept
77+
input = client.read
78+
break if input.nil? || input.empty?
79+
80+
binding_irb.context.io = StringInput.new(input)
81+
82+
not_exited = catch(:IRB_EXIT) do
83+
begin
84+
$stdout = client
85+
binding_irb.eval_input
86+
ensure
87+
$stdout = original_stdout
88+
end
89+
:not_exited
90+
end
91+
92+
client.close rescue nil # rubocop:disable Style/RescueModifier
93+
94+
break unless not_exited == :not_exited
95+
end
96+
ensure
97+
$stdout = original_stdout
98+
server&.close
99+
File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier
100+
end
101+
end
102+
103+
private
104+
105+
def create_irb
106+
workspace = IRB::WorkSpace.new(@binding_context)
107+
irb = IRB::Irb.new(workspace, StringInput.new(""), from_binding: true)
108+
irb.context.irb_path = File.expand_path(@binding_context.source_location[0])
109+
irb.context.verbose = false
110+
irb.context.newline_before_multiline_output = false
111+
irb
112+
end
113+
114+
class << self
115+
def print_instructions(binding_context)
116+
file, line = binding_context.source_location
117+
method_name = binding_context.eval("__method__") rescue nil # rubocop:disable Style/RescueModifier
118+
location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}"
119+
120+
$stdout.puts <<~MSG
121+
══════════════════════════════════════════════════════════
122+
IRB agent breakpoint hit at #{location}
123+
124+
No IRB_SOCK_PATH set — exiting without starting a debug session.
125+
126+
To debug this breakpoint:
127+
128+
1. Run the app in the BACKGROUND with a socket path:
129+
130+
IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock <your command>
131+
132+
The process will block waiting for a connection.
133+
134+
2. Wait for the socket file to appear:
135+
136+
ls /tmp/irb-UNIQUE_ID.sock
137+
138+
3. Send commands to the socket with FOREGROUND commands:
139+
140+
ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close'
141+
142+
Each invocation sends one command and prints the result.
143+
The IRB session persists between invocations.
144+
145+
Examples:
146+
... s.puts "ls"; s.close_write # list methods and variables
147+
... s.puts "show_source foo"; s.close_write # see source of a method
148+
... s.puts "@name"; s.close_write # inspect a variable
149+
... s.puts "exit"; s.close_write # end session, resume app
150+
151+
══════════════════════════════════════════════════════════
152+
MSG
153+
$stdout.flush
154+
end
155+
end
156+
end
157+
end

0 commit comments

Comments
 (0)