Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion doc/Index.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,43 @@ 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.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.

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, but the process state from this run is not retained.
- **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.

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.

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.

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.

```console
# app.rb
require "irb"
binding.agent
```

```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::AgentSession for more details.

## Startup

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

1. `binding.irb` doesn't support `pre` and `do` arguments like [binding.break](https://github.com/ruby/debug#bindingbreak-method).
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.
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.
3. Access to the previous return value via the underscore `_` is not supported.

## Encodings
Expand Down
127 changes: 121 additions & 6 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@ class << self
attr_reader :from_binding

# Creates a new irb session
def initialize(workspace = nil, input_method = nil, from_binding: false)
def initialize(workspace = nil, input_method = nil, from_binding: false, verbose: IRB.conf[:VERBOSE])
@from_binding = from_binding
@prompt_part_cache = nil
@context = Context.new(self, workspace, input_method)
@context = Context.new(self, workspace, input_method, verbose: verbose)
@context.workspace.load_helper_methods_to_main
@signal_status = :IN_IRB
@scanner = RubyLex.new
Expand Down Expand Up @@ -429,7 +429,7 @@ def handle_exception(exc)

order =
if RUBY_VERSION < '3.0.0'
STDOUT.tty? ? :bottom : :top
output.tty? ? :bottom : :top
else # '3.0.0' <= RUBY_VERSION
:top
end
Expand Down Expand Up @@ -563,19 +563,19 @@ def output_value(omit = false) # :nodoc:
content = "#{content}\e[0m" if Color.colorable?
end
puts format(@context.return_format, content.chomp)
elsif Pager.should_page? && @context.inspector_support_stream_output?
elsif Pager.should_page?(output: output) && @context.inspector_support_stream_output?
formatter_proc = ->(content, multipage) do
content = content.chomp
content = "\n#{content}" if @context.newline_before_multiline_output? && (multipage || content.include?("\n"))
format(@context.return_format, content)
end
Pager.page_with_preview(winwidth, winheight, formatter_proc) do |out|
Pager.page_with_preview(winwidth, winheight, formatter_proc, output: output) do |out|
@context.inspect_last_value(out)
end
else
content = @context.inspect_last_value.chomp
content = "\n#{content}" if @context.newline_before_multiline_output? && content.include?("\n")
Pager.page_content(format(@context.return_format, content), retain_content: true)
Pager.page_content(format(@context.return_format, content), retain_content: true, output: output)
end
end

Expand All @@ -598,6 +598,30 @@ def inspect

private

def output
@context.output
end

def write(*args)
output.write(*args)
end

def print(*args)
output.print(*args)
end

def printf(*args)
output.printf(*args)
end

def puts(*args)
output.puts(*args)
end

def warn(*messages, **kwargs)
output.warn(*messages, **kwargs)
end

def with_prompt_part_cached
@prompt_part_cache = {}
yield
Expand Down Expand Up @@ -745,9 +769,11 @@ class Binding
# Cooked potato: true
#
# See IRB for more information.
#
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?

# Create a new workspace using the current binding
workspace = IRB::WorkSpace.new(self)
# Print the code around the binding if show_code is true
Expand Down Expand Up @@ -775,4 +801,93 @@ def irb(show_code: true)
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 request, evaluates it, returns
# IRB's output, and closes. The IRB session state persists across
# connections. Output written by the host program stays with the host.
# Send +exit+ to end the session and resume app execution.
#
# See IRB::AgentSession for the full protocol and workflow.
def agent
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.
if sock_path.nil? || sock_path.empty?
print_agent_instructions
Kernel.exit(0)
end

# Phase 2 (debug session): socket path set, start a request/response
# server that the agent can send commands to.
require_relative "irb/agent"
IRB.setup(source_location[0], argv: []) unless IRB.initialized?
session = IRB::AgentSession.new(self, sock_path: sock_path)
session.run
end

private

def print_agent_instructions
file, line = source_location
method_name = begin
eval("__method__")
rescue NameError
nil
end
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.
This run's process state will not be retained.

Add breakpoints with: require "irb"; binding.agent

To debug this breakpoint:

1. Run the app in the BACKGROUND with a socket path:

IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock <your command> &

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.
Output written by the program remains on the program's stdout/stderr.

Commands that start another interactive program are unavailable:
debug commands, multi-IRB commands, edit, and show_doc without a target.

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
Loading