Skip to content

Commit be4ac89

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

22 files changed

Lines changed: 1157 additions & 55 deletions

doc/Index.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,43 @@ 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 terminal 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, but the process state from this run is not retained.
103+
- **Set**: starts a Unix socket server at the given path. Each connection accepts one complete request, evaluates it, returns IRB's output, and closes. The IRB session state persists across connections. Send `exit` to end the session and resume app execution, or `exit!` to exit the process.
104+
105+
IRB results, errors, and command output are returned through the socket. Output written by the program itself remains on the program's standard output or error stream, so other application threads are not redirected into an agent response.
106+
107+
Use a unique socket path in a private directory. IRB refuses active and non-socket paths and checks socket identity before cleanup, but reclaiming a stale path is not an atomic security boundary against another process that can write to the same directory.
108+
109+
Commands that inspect or mutate the current IRB session work normally, including registered custom commands, aliases, `history`, `ls`, `show_source`, and `show_doc` with a target. Commands that start another interactive input loop are unavailable: debug commands, deprecated multi-IRB commands, `edit`, and `show_doc` without a target. Agent history lasts for the session and is not written to the user's IRB history file.
110+
111+
```console
112+
# app.rb
113+
require "irb"
114+
binding.agent
115+
```
116+
117+
```console
118+
# 1. First run: discover the breakpoint.
119+
$ ruby app.rb
120+
IRB agent breakpoint hit at app.rb:14 in `cook!`
121+
...
122+
123+
# 2. Re-run in background with a socket path.
124+
$ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb &
125+
126+
# 3. Send commands.
127+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close'
128+
$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close'
129+
```
130+
131+
See IRB::AgentSession for more details.
132+
96133
## Startup
97134

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

683720
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.
721+
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.
685722
3. Access to the previous return value via the underscore `_` is not supported.
686723

687724
## Encodings

lib/irb.rb

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ class << self
101101
attr_reader :from_binding
102102

103103
# Creates a new irb session
104-
def initialize(workspace = nil, input_method = nil, from_binding: false)
104+
def initialize(workspace = nil, input_method = nil, from_binding: false, verbose: IRB.conf[:VERBOSE])
105105
@from_binding = from_binding
106106
@prompt_part_cache = nil
107-
@context = Context.new(self, workspace, input_method)
107+
@context = Context.new(self, workspace, input_method, verbose: verbose)
108108
@context.workspace.load_helper_methods_to_main
109109
@signal_status = :IN_IRB
110110
@scanner = RubyLex.new
@@ -429,7 +429,7 @@ def handle_exception(exc)
429429

430430
order =
431431
if RUBY_VERSION < '3.0.0'
432-
STDOUT.tty? ? :bottom : :top
432+
output.tty? ? :bottom : :top
433433
else # '3.0.0' <= RUBY_VERSION
434434
:top
435435
end
@@ -563,19 +563,19 @@ def output_value(omit = false) # :nodoc:
563563
content = "#{content}\e[0m" if Color.colorable?
564564
end
565565
puts format(@context.return_format, content.chomp)
566-
elsif Pager.should_page? && @context.inspector_support_stream_output?
566+
elsif Pager.should_page?(output: output) && @context.inspector_support_stream_output?
567567
formatter_proc = ->(content, multipage) do
568568
content = content.chomp
569569
content = "\n#{content}" if @context.newline_before_multiline_output? && (multipage || content.include?("\n"))
570570
format(@context.return_format, content)
571571
end
572-
Pager.page_with_preview(winwidth, winheight, formatter_proc) do |out|
572+
Pager.page_with_preview(winwidth, winheight, formatter_proc, output: output) do |out|
573573
@context.inspect_last_value(out)
574574
end
575575
else
576576
content = @context.inspect_last_value.chomp
577577
content = "\n#{content}" if @context.newline_before_multiline_output? && content.include?("\n")
578-
Pager.page_content(format(@context.return_format, content), retain_content: true)
578+
Pager.page_content(format(@context.return_format, content), retain_content: true, output: output)
579579
end
580580
end
581581

@@ -598,6 +598,30 @@ def inspect
598598

599599
private
600600

601+
def output
602+
@context.output
603+
end
604+
605+
def write(*args)
606+
output.write(*args)
607+
end
608+
609+
def print(*args)
610+
output.print(*args)
611+
end
612+
613+
def printf(*args)
614+
output.printf(*args)
615+
end
616+
617+
def puts(*args)
618+
output.puts(*args)
619+
end
620+
621+
def warn(*messages, **kwargs)
622+
output.warn(*messages, **kwargs)
623+
end
624+
601625
def with_prompt_part_cached
602626
@prompt_part_cache = {}
603627
yield
@@ -745,9 +769,11 @@ class Binding
745769
# Cooked potato: true
746770
#
747771
# See IRB for more information.
772+
#
748773
def irb(show_code: true)
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
@@ -775,4 +801,93 @@ def irb(show_code: true)
775801
binding_irb.debug_break
776802
end
777803
end
804+
805+
# Opens a non-interactive IRB session designed for AI agents and scripts
806+
# (experimental). Instead of opening a REPL, it exposes an IRB session over a
807+
# Unix socket using a simple request/response protocol.
808+
#
809+
# The behavior depends on the +IRB_SOCK_PATH+ environment variable:
810+
#
811+
# - *Not set* (Phase 1 - discovery): prints instructions explaining the
812+
# workflow, then exits. This lets the agent discover the breakpoint and
813+
# learn the protocol.
814+
# - *Set* (Phase 2 - debug session): starts a Unix socket server at the
815+
# given path. Each connection accepts one request, evaluates it, returns
816+
# IRB's output, and closes. The IRB session state persists across
817+
# connections. Output written by the host program stays with the host.
818+
# Send +exit+ to end the session and resume app execution.
819+
#
820+
# See IRB::AgentSession for the full protocol and workflow.
821+
def agent
822+
sock_path = ENV["IRB_SOCK_PATH"]
823+
824+
# Phase 1 (discovery): no socket path set, so print instructions
825+
# teaching the agent how to connect, then exit immediately.
826+
# No IRB.setup needed since we're not starting a session.
827+
if sock_path.nil? || sock_path.empty?
828+
print_agent_instructions
829+
Kernel.exit(0)
830+
end
831+
832+
# Phase 2 (debug session): socket path set, start a request/response
833+
# server that the agent can send commands to.
834+
require_relative "irb/agent"
835+
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
836+
session = IRB::AgentSession.new(self, sock_path: sock_path)
837+
session.run
838+
end
839+
840+
private
841+
842+
def print_agent_instructions
843+
file, line = source_location
844+
method_name = begin
845+
eval("__method__")
846+
rescue NameError
847+
nil
848+
end
849+
location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}"
850+
851+
$stdout.puts <<~MSG
852+
==========================================================
853+
IRB agent breakpoint hit at #{location}
854+
855+
No IRB_SOCK_PATH set - exiting without starting a debug session.
856+
This run's process state will not be retained.
857+
858+
Add breakpoints with: require "irb"; binding.agent
859+
860+
To debug this breakpoint:
861+
862+
1. Run the app in the BACKGROUND with a socket path:
863+
864+
IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock <your command> &
865+
866+
The process will block waiting for a connection.
867+
868+
2. Wait for the socket file to appear:
869+
870+
ls /tmp/irb-UNIQUE_ID.sock
871+
872+
3. Send commands to the socket with FOREGROUND commands:
873+
874+
ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close'
875+
876+
Each invocation sends one command and prints the result.
877+
The IRB session persists between invocations.
878+
Output written by the program remains on the program's stdout/stderr.
879+
880+
Commands that start another interactive program are unavailable:
881+
debug commands, multi-IRB commands, edit, and show_doc without a target.
882+
883+
Examples:
884+
... s.puts "ls"; s.close_write # list methods and variables
885+
... s.puts "show_source foo"; s.close_write # see source of a method
886+
... s.puts "@name"; s.close_write # inspect a variable
887+
... s.puts "exit"; s.close_write # end session, resume app
888+
889+
==========================================================
890+
MSG
891+
$stdout.flush
892+
end
778893
end

0 commit comments

Comments
 (0)