ask-tools-shell 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +25 -137
- data/lib/ask/tools/shell/grep.rb +7 -1
- data/lib/ask/tools/shell/repl/kernel_script.rb +89 -0
- data/lib/ask/tools/shell/repl.rb +357 -0
- data/lib/ask/tools/shell/version.rb +1 -1
- data/lib/ask/tools/shell.rb +2 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9920ae3e941453ed140a053b18b39f59d336f5dbc20334c6b8a262b7554f956
|
|
4
|
+
data.tar.gz: 3a4ad48154b393b9d69f39d2a6cc4af8fcf4a465a59921017fe7d2c62892d440
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ff7cd4230e7e4be6e2ae9b2a2a0410ed994959fedb0da3628caedff322cb84bd531e8bb62cf5d9b669fb12d759c675ef47d2f4deb32738d292bbbf710a193f7
|
|
7
|
+
data.tar.gz: a404e7da5f72e0ca458f04bfee0d72ed56d609e39db59e665f4e3a837b1b229a11879317770e6fa901c6cb5ba6e1813834d7464fbba69d34f2dee4fa64a9fba3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## [0.4.0] - 2026-08-05
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **`Ask::Tools::Repl`** — evaluate Ruby code in a persistent session (the
|
|
5
|
+
RLM / recursive-language-model pattern). A long-lived plain-ruby kernel
|
|
6
|
+
subprocess keeps state across calls: locals, `require`s, and defined
|
|
7
|
+
methods survive between evaluations, so the model composes capabilities as
|
|
8
|
+
code against a working environment instead of re-bootstrapping each time.
|
|
9
|
+
- Framed newline-delimited JSON protocol over stdin/stdout with
|
|
10
|
+
request/response id matching; concurrent calls to a session serialize.
|
|
11
|
+
- Per-evaluation timeout kills the session (state is lost, kernel
|
|
12
|
+
respawns fresh on next call); idle sessions recycle after
|
|
13
|
+
`Repl.idle_timeout` (default 300s).
|
|
14
|
+
- Named sessions shared process-wide (`session:` param, default
|
|
15
|
+
`"default"`); `reset: true` discards state; `Repl.close_session` /
|
|
16
|
+
`Repl.close_all` manage lifetimes; `at_exit` cleanup.
|
|
17
|
+
- Sessions are isolated subprocesses — a crash in one session can't take
|
|
18
|
+
others down, and a dead session is respawned transparently with one
|
|
19
|
+
retry.
|
|
20
|
+
- Kernel spawn strips bundler env vars (RUBYOPT, GEM_HOME, etc.) so the
|
|
21
|
+
session is plain ruby and sees globally installed gems — consistent
|
|
22
|
+
with the one-shot `Code` tool.
|
|
23
|
+
- Registered `repl` in `Shell::TOOLS` / `Shell.all`.
|
|
24
|
+
|
|
1
25
|
## [0.3.4] - 2026-06-25
|
|
2
26
|
|
|
3
27
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,171 +1,59 @@
|
|
|
1
1
|
# ask-tools-shell
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/ask-tools-shell)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Shell, filesystem, and code execution tools for AI agents. Ships 8 tools: Bash, Read, Write, Edit, Glob, Grep, Code, and ApplyPatch. Bash and Code execute through ask-sandbox-providers; the rest operate directly on the local filesystem.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
6
8
|
|
|
7
9
|
```ruby
|
|
8
10
|
gem "ask-tools-shell"
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Dependencies
|
|
12
|
-
|
|
13
|
-
- **ask-tools** ~> 0.1 (provides `Ask::Tool` base class and `Ask::Result`)
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
13
|
## Quick Start
|
|
18
14
|
|
|
19
15
|
```ruby
|
|
20
16
|
require "ask-tools-shell"
|
|
21
17
|
|
|
22
|
-
# List all available tools
|
|
23
18
|
Ask::Tools::Shell.all.map(&:name)
|
|
24
|
-
# => ["bash", "read", "write", "edit", "glob", "grep", "code"]
|
|
19
|
+
# => ["bash", "read", "write", "edit", "glob", "grep", "code", "apply_patch"]
|
|
25
20
|
|
|
26
|
-
# Use a tool standalone
|
|
27
21
|
result = Ask::Tools::Bash.new.call(command: "echo hello")
|
|
28
|
-
result.ok?
|
|
29
|
-
result.output[:stdout]
|
|
30
|
-
result.output[:exit_code]
|
|
22
|
+
result.ok? # => true
|
|
23
|
+
result.output[:stdout] # => "hello\n"
|
|
24
|
+
result.output[:exit_code] # => 0
|
|
31
25
|
```
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
## Tools
|
|
36
|
-
|
|
37
|
-
### `Ask::Tools::Bash`
|
|
38
|
-
|
|
39
|
-
Execute shell commands in a sandboxed temp directory.
|
|
27
|
+
## The tools
|
|
40
28
|
|
|
41
|
-
|
|
|
42
|
-
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
29
|
+
| Tool | Parameters | Notes |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `Ask::Tools::Bash` | `command`, `timeout` (30), `workdir` | Runs via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code, timed_out }`, output truncated to 100KB |
|
|
32
|
+
| `Ask::Tools::Read` | `path`, `offset` (0-indexed), `limit` (2000) | Reads files with line numbers, or lists a directory |
|
|
33
|
+
| `Ask::Tools::Write` | `path`, `content` | Creates parent directories automatically |
|
|
34
|
+
| `Ask::Tools::Edit` | `path`, `old_string`, `new_string`, `replace_all` | Exact string replacement |
|
|
35
|
+
| `Ask::Tools::Glob` | `pattern`, `path` | Up to 1000 files, newest first |
|
|
36
|
+
| `Ask::Tools::Grep` | `pattern`, `path`, `include` | Regex search; 100 matches max, skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log` |
|
|
37
|
+
| `Ask::Tools::Code` | `code` | Runs Ruby via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code }` |
|
|
38
|
+
| `Ask::Tools::ApplyPatch` | `patchText` | Applies unified diffs inside a `*** Begin Patch` / `*** End Patch` envelope (Add File, Update File, Delete File sections) |
|
|
46
39
|
|
|
47
|
-
|
|
40
|
+
## Sandboxed execution
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
Ask::Tools::Bash.new.call(command: "ls -la", timeout: 10)
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### `Ask::Tools::Read`
|
|
54
|
-
|
|
55
|
-
Read file contents with line numbers, or list directory entries.
|
|
56
|
-
|
|
57
|
-
| Param | Type | Required | Default | Description |
|
|
58
|
-
|-------|------|----------|---------|-------------|
|
|
59
|
-
| `path` | `string` | Yes | — | Absolute path to file or directory |
|
|
60
|
-
| `offset` | `integer` | No | 0 | Starting line number (0-indexed) |
|
|
61
|
-
| `limit` | `integer` | No | 2000 | Maximum lines to read |
|
|
42
|
+
`Bash` and `Code` run through `Ask::Sandbox.provider` (ask-sandbox-providers), which defaults to the Local provider. Switch to stronger isolation:
|
|
62
43
|
|
|
63
44
|
```ruby
|
|
64
|
-
Ask::
|
|
65
|
-
Ask::Tools::Read.new.call(path: "large.log", offset: 100, limit: 50)
|
|
45
|
+
Ask::Sandbox.provider = :docker
|
|
66
46
|
```
|
|
67
47
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Write content to a file. Creates parent directories automatically.
|
|
48
|
+
## Full documentation
|
|
71
49
|
|
|
72
|
-
|
|
73
|
-
|-------|------|----------|---------|-------------|
|
|
74
|
-
| `path` | `string` | Yes | — | Absolute path to write to |
|
|
75
|
-
| `content` | `string` | Yes | — | File content (max 500KB) |
|
|
76
|
-
|
|
77
|
-
```ruby
|
|
78
|
-
Ask::Tools::Write.new.call(path: "/tmp/hello.txt", content: "Hello, World!")
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### `Ask::Tools::Edit`
|
|
82
|
-
|
|
83
|
-
Replace exact text in a file. Uses exact string matching.
|
|
84
|
-
|
|
85
|
-
| Param | Type | Required | Default | Description |
|
|
86
|
-
|-------|------|----------|---------|-------------|
|
|
87
|
-
| `path` | `string` | Yes | — | Absolute path to the file |
|
|
88
|
-
| `old_string` | `string` | Yes | — | Exact text to replace |
|
|
89
|
-
| `new_string` | `string` | Yes | — | Replacement text |
|
|
90
|
-
| `replace_all` | `boolean` | No | false | Replace all occurrences |
|
|
91
|
-
|
|
92
|
-
```ruby
|
|
93
|
-
Ask::Tools::Edit.new.call(path: "file.rb", old_string: "foo", new_string: "bar")
|
|
94
|
-
Ask::Tools::Edit.new.call(path: "file.rb", old_string: "x", new_string: "y", replace_all: true)
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### `Ask::Tools::Glob`
|
|
98
|
-
|
|
99
|
-
Find files matching a glob pattern, sorted by modification time (newest first).
|
|
100
|
-
|
|
101
|
-
| Param | Type | Required | Default | Description |
|
|
102
|
-
|-------|------|----------|---------|-------------|
|
|
103
|
-
| `pattern` | `string` | Yes | — | Glob pattern (e.g. `**/*.rb`) |
|
|
104
|
-
| `path` | `string` | No | current dir | Base directory |
|
|
105
|
-
|
|
106
|
-
Max 1000 results.
|
|
107
|
-
|
|
108
|
-
```ruby
|
|
109
|
-
Ask::Tools::Glob.new.call(pattern: "**/*.rb", path: "/path/to/project")
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### `Ask::Tools::Grep`
|
|
113
|
-
|
|
114
|
-
Search file contents using a regex pattern.
|
|
115
|
-
|
|
116
|
-
| Param | Type | Required | Default | Description |
|
|
117
|
-
|-------|------|----------|---------|-------------|
|
|
118
|
-
| `pattern` | `string` | Yes | — | Regex pattern to search for |
|
|
119
|
-
| `path` | `string` | No | current dir | Directory to search |
|
|
120
|
-
| `include` | `string` | No | `**/*` | File pattern filter (e.g. `*.rb`) |
|
|
121
|
-
|
|
122
|
-
Max 100 matches. Line content capped at 500 chars. Skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log`.
|
|
123
|
-
|
|
124
|
-
```ruby
|
|
125
|
-
Ask::Tools::Grep.new.call(pattern: "TODO", path: ".", include: "*.rb")
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### `Ask::Tools::Code`
|
|
129
|
-
|
|
130
|
-
Write and execute Ruby code in a subprocess. Uses gems already available in the environment.
|
|
131
|
-
|
|
132
|
-
| Param | Type | Required | Default | Description |
|
|
133
|
-
|-------|------|----------|---------|-------------|
|
|
134
|
-
| `code` | `string` | Yes | — | Ruby source code to execute |
|
|
135
|
-
|
|
136
|
-
Returns `{ stdout, stderr, exit_code }`. Output truncated to 100KB.
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
Ask::Tools::Code.new.call(code: <<~RUBY)
|
|
140
|
-
puts "Hello from Ruby!"
|
|
141
|
-
result = 2 + 2
|
|
142
|
-
puts "2 + 2 = #{result}"
|
|
143
|
-
RUBY
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
---
|
|
147
|
-
|
|
148
|
-
## Using Tools with an Agent
|
|
149
|
-
|
|
150
|
-
```ruby
|
|
151
|
-
require "ask-tools-shell"
|
|
152
|
-
|
|
153
|
-
# All tools
|
|
154
|
-
tools = Ask::Tools::Shell.all
|
|
155
|
-
|
|
156
|
-
# Find by name
|
|
157
|
-
bash = Ask::Tools["bash"]
|
|
158
|
-
bash.call(command: "date")
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
---
|
|
50
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-tools in depth](https://ask-rb.github.io/ask-docs/core/tools) covers the shell tools, the ApplyPatch format, and sandbox configuration. API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
162
51
|
|
|
163
52
|
## Development
|
|
164
53
|
|
|
165
|
-
```
|
|
54
|
+
```
|
|
166
55
|
bundle install
|
|
167
56
|
bundle exec rake test
|
|
168
|
-
gem build ask-tools-shell.gemspec
|
|
169
57
|
```
|
|
170
58
|
|
|
171
59
|
## License
|
data/lib/ask/tools/shell/grep.rb
CHANGED
|
@@ -34,7 +34,13 @@ module Ask
|
|
|
34
34
|
|
|
35
35
|
Dir.glob(File.join(base, glob)).each do |file|
|
|
36
36
|
next unless File.file?(file)
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
# Exclude by path segment relative to the search root, not by
|
|
39
|
+
# absolute-path substring: the old check matched any path
|
|
40
|
+
# containing "/tmp/" (e.g. a search rooted under the system tmp
|
|
41
|
+
# dir on Linux), excluding every file.
|
|
42
|
+
relative = file.delete_prefix("#{base}/")
|
|
43
|
+
next if relative.split("/").any? { |seg| EXCLUDE_DIRS.include?(seg) }
|
|
38
44
|
|
|
39
45
|
begin
|
|
40
46
|
File.readlines(file).each_with_index do |line, i|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Persistent Ruby kernel for Ask::Tools::Shell::Repl.
|
|
4
|
+
#
|
|
5
|
+
# A standalone plain-Ruby script run as a long-lived subprocess. Reads framed
|
|
6
|
+
# JSON requests from stdin, evaluates each snippet into a persistent binding,
|
|
7
|
+
# and writes a framed JSON response to stdout.
|
|
8
|
+
#
|
|
9
|
+
# Protocol (newline-delimited JSON):
|
|
10
|
+
# request: {"id": 1, "code": "1 + 1"}
|
|
11
|
+
# response: {"id": 1, "result": "2", "stdout": "", "stderr": "", "error": null}
|
|
12
|
+
#
|
|
13
|
+
# The binding persists across requests, so locals, requires, and defined
|
|
14
|
+
# methods survive between calls. stdout/stderr are captured per evaluation.
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require "stringio"
|
|
18
|
+
|
|
19
|
+
# Die immediately on TERM (sent by the parent to shut the session down).
|
|
20
|
+
# exit! bypasses the rescue Exception below, which would otherwise swallow
|
|
21
|
+
# the SignalException raised mid-eval and keep the kernel alive.
|
|
22
|
+
trap("TERM") { exit!(0) }
|
|
23
|
+
|
|
24
|
+
def read_frame
|
|
25
|
+
line = $stdin.gets
|
|
26
|
+
return nil if line.nil?
|
|
27
|
+
|
|
28
|
+
JSON.parse(line)
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
{ "error" => "invalid request frame" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write_frame(frame)
|
|
34
|
+
$stdout.puts(JSON.generate(frame))
|
|
35
|
+
$stdout.flush
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def capture_output
|
|
39
|
+
old_out = $stdout
|
|
40
|
+
old_err = $stderr
|
|
41
|
+
out = StringIO.new
|
|
42
|
+
err = StringIO.new
|
|
43
|
+
$stdout = out
|
|
44
|
+
$stderr = err
|
|
45
|
+
yield
|
|
46
|
+
[out.string, err.string]
|
|
47
|
+
ensure
|
|
48
|
+
$stdout = old_out
|
|
49
|
+
$stderr = old_err
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_inspect(value)
|
|
53
|
+
value.inspect
|
|
54
|
+
rescue StandardError
|
|
55
|
+
"#<#{value.class}>"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
binding = TOPLEVEL_BINDING
|
|
59
|
+
|
|
60
|
+
loop do
|
|
61
|
+
request = read_frame
|
|
62
|
+
break if request.nil?
|
|
63
|
+
|
|
64
|
+
id = request["id"]
|
|
65
|
+
code = request["code"].to_s
|
|
66
|
+
|
|
67
|
+
result = nil
|
|
68
|
+
stdout = ""
|
|
69
|
+
stderr = ""
|
|
70
|
+
error = nil
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
stdout, stderr = capture_output do
|
|
74
|
+
result = binding.eval(code)
|
|
75
|
+
end
|
|
76
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
77
|
+
error = "#{e.class}: #{e.message}"
|
|
78
|
+
trace = (e.backtrace || []).first(10).join("\n")
|
|
79
|
+
stderr += trace
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
write_frame(
|
|
83
|
+
"id" => id,
|
|
84
|
+
"result" => safe_inspect(result),
|
|
85
|
+
"stdout" => stdout,
|
|
86
|
+
"stderr" => stderr,
|
|
87
|
+
"error" => error
|
|
88
|
+
)
|
|
89
|
+
end
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "io/wait"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module Tools
|
|
9
|
+
# Evaluate Ruby code in a persistent, long-lived subprocess.
|
|
10
|
+
#
|
|
11
|
+
# Unlike {Code} (which spawns a fresh `ruby -e` per call), Repl keeps a
|
|
12
|
+
# kernel process alive across calls and evaluates every snippet into the
|
|
13
|
+
# same binding — so state (locals, requires, defined methods) survives
|
|
14
|
+
# between calls. This is the RLM (recursive language model) pattern: the
|
|
15
|
+
# model composes capabilities as code against a persistent environment.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# repl = Ask::Tools::Repl.new
|
|
19
|
+
# repl.call(code: 'require "json"; data = JSON.parse(%q({"a": 1}))')
|
|
20
|
+
# repl.call(code: "data['a'] + 1") # => 2 — `data` still exists
|
|
21
|
+
# repl.call(code: "def double(x); x * 2; end")
|
|
22
|
+
# repl.call(code: "double(21)") # => 42
|
|
23
|
+
#
|
|
24
|
+
# Sessions are named and shared process-wide: calling with the same
|
|
25
|
+
# +session+ name from any tool instance reaches the same kernel. A
|
|
26
|
+
# session is closed by +reset: true+, {Repl.close_session}, or after
|
|
27
|
+
# {Repl.idle_timeout} seconds without use.
|
|
28
|
+
#
|
|
29
|
+
# @note The kernel runs with the caller's permissions (like {Code}).
|
|
30
|
+
# It is a durable control environment, not a security sandbox.
|
|
31
|
+
class Repl < Ask::Tool
|
|
32
|
+
description "Evaluate Ruby code in a persistent session. " \
|
|
33
|
+
"State (variables, requires, defined methods) survives " \
|
|
34
|
+
"across calls in the same session. " \
|
|
35
|
+
"Use sessions to keep working context alive."
|
|
36
|
+
|
|
37
|
+
param :code, type: :string, desc: "Ruby source code to evaluate", required: true
|
|
38
|
+
param :session, type: :string, desc: "Session name; state persists per name", required: false
|
|
39
|
+
param :reset, type: :boolean, desc: "Discard session state before evaluating", required: false
|
|
40
|
+
|
|
41
|
+
# Timeout for a single evaluation, in seconds.
|
|
42
|
+
DEFAULT_EVAL_TIMEOUT = 30
|
|
43
|
+
|
|
44
|
+
# Sessions idle longer than this are closed on next access.
|
|
45
|
+
DEFAULT_IDLE_TIMEOUT = 300
|
|
46
|
+
|
|
47
|
+
# Env vars that would drag the parent's bundler context into the
|
|
48
|
+
# kernel subprocess (RUBYOPT=-rbundler/setup restricts $LOAD_PATH to
|
|
49
|
+
# the parent's Gemfile). Nil overrides remove them at spawn so the
|
|
50
|
+
# session is plain ruby, like the one-shot Code tool's sandbox.
|
|
51
|
+
BUNDLER_ENV = %w[
|
|
52
|
+
RUBYOPT RUBYLIB BASH_ENV GEM_PATH GEM_HOME
|
|
53
|
+
BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLER_SETUP
|
|
54
|
+
BUNDLER_VERSION BUNDLE_WITHOUT BUNDLE_FROZEN BUNDLE_DEPLOYMENT
|
|
55
|
+
BUNDLE_LOCKFILE BUNDLE_APP_CONFIG
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# @return [Integer] seconds a session may sit unused before it is
|
|
60
|
+
# closed on next access
|
|
61
|
+
attr_accessor :idle_timeout
|
|
62
|
+
|
|
63
|
+
# @return [Integer] seconds a single evaluation may take
|
|
64
|
+
attr_accessor :eval_timeout
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
self.idle_timeout = DEFAULT_IDLE_TIMEOUT
|
|
68
|
+
self.eval_timeout = DEFAULT_EVAL_TIMEOUT
|
|
69
|
+
|
|
70
|
+
@registry = {}
|
|
71
|
+
@registry_mutex = Monitor.new
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
# The kernel for +session+, spawning one if needed (or after reset).
|
|
75
|
+
#
|
|
76
|
+
# @param session [String]
|
|
77
|
+
# @param reset [Boolean] discard existing session state
|
|
78
|
+
# @return [Kernel]
|
|
79
|
+
def kernel_for(session, reset: false)
|
|
80
|
+
@registry_mutex.synchronize do
|
|
81
|
+
close_session(session) if reset
|
|
82
|
+
kernel = @registry[session]
|
|
83
|
+
if kernel.nil? || kernel.dead?
|
|
84
|
+
kernel = Kernel.new(session: session)
|
|
85
|
+
@registry[session] = kernel
|
|
86
|
+
elsif kernel.idle_seconds > idle_timeout
|
|
87
|
+
kernel.close
|
|
88
|
+
kernel = Kernel.new(session: session)
|
|
89
|
+
@registry[session] = kernel
|
|
90
|
+
end
|
|
91
|
+
kernel
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Close and forget a session's kernel.
|
|
96
|
+
#
|
|
97
|
+
# @param session [String]
|
|
98
|
+
# @return [void]
|
|
99
|
+
def close_session(session)
|
|
100
|
+
@registry_mutex.synchronize do
|
|
101
|
+
kernel = @registry.delete(session)
|
|
102
|
+
kernel&.close
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Close every session kernel. Called at exit; call it explicitly to
|
|
108
|
+
# free subprocesses early.
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
def close_all
|
|
112
|
+
@registry_mutex.synchronize do
|
|
113
|
+
@registry.each_value(&:close)
|
|
114
|
+
@registry.clear
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Array<String>] active session names
|
|
120
|
+
def sessions
|
|
121
|
+
@registry_mutex.synchronize { @registry.keys }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
at_exit { Repl.close_all }
|
|
126
|
+
|
|
127
|
+
def execute(code:, session: "default", reset: false)
|
|
128
|
+
build_result(eval_code(session, code, reset: reset), session)
|
|
129
|
+
rescue Kernel::TimeoutError => e
|
|
130
|
+
Ask::Result.error(message: "REPL session '#{session}' timed out (#{e.timeout}s); session state was lost")
|
|
131
|
+
rescue Kernel::DeadError => e
|
|
132
|
+
# The session died (crash, external kill, closed stdin). Respawn a
|
|
133
|
+
# fresh kernel and retry once; only give up if it dies again.
|
|
134
|
+
begin
|
|
135
|
+
build_result(eval_code(session, code), session)
|
|
136
|
+
rescue Kernel::DeadError
|
|
137
|
+
Ask::Result.error(message: "REPL session '#{session}' died repeatedly: #{e.message}")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def eval_code(session, code, reset: false)
|
|
144
|
+
Repl.kernel_for(session, reset: reset).eval(code, timeout: Repl.eval_timeout)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_result(result, session)
|
|
148
|
+
if result["error"]
|
|
149
|
+
Ask::Result.error(
|
|
150
|
+
message: result["error"],
|
|
151
|
+
metadata: { session: session, stdout: result["stdout"], stderr: result["stderr"] }
|
|
152
|
+
)
|
|
153
|
+
else
|
|
154
|
+
Ask::Result.ok(data: {
|
|
155
|
+
result: result["result"],
|
|
156
|
+
stdout: result["stdout"],
|
|
157
|
+
stderr: result["stderr"],
|
|
158
|
+
session: session
|
|
159
|
+
})
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A single long-lived Ruby subprocess executing the kernel script.
|
|
164
|
+
#
|
|
165
|
+
# Communicates over newline-delimited JSON on stdin/stdout. One eval
|
|
166
|
+
# at a time per kernel; concurrent calls serialize on an internal
|
|
167
|
+
# monitor.
|
|
168
|
+
class Kernel
|
|
169
|
+
class Error < StandardError; end
|
|
170
|
+
class TimeoutError < Error
|
|
171
|
+
attr_reader :timeout
|
|
172
|
+
|
|
173
|
+
def initialize(timeout)
|
|
174
|
+
@timeout = timeout
|
|
175
|
+
super("evaluation exceeded #{timeout}s")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
class DeadError < Error; end
|
|
179
|
+
|
|
180
|
+
# @return [String] session name this kernel belongs to
|
|
181
|
+
attr_reader :session
|
|
182
|
+
|
|
183
|
+
# @return [Time] last time an evaluation completed
|
|
184
|
+
attr_reader :last_used
|
|
185
|
+
|
|
186
|
+
def initialize(session:, ruby: "ruby")
|
|
187
|
+
@session = session
|
|
188
|
+
@script = File.expand_path("repl/kernel_script.rb", __dir__)
|
|
189
|
+
@monitor = Monitor.new
|
|
190
|
+
@last_used = Time.now
|
|
191
|
+
@next_id = 0
|
|
192
|
+
spawn_process(ruby)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [Float] seconds since the last evaluation
|
|
196
|
+
def idle_seconds
|
|
197
|
+
Time.now - @last_used
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @return [Boolean] whether the kernel process is gone. Reaps the
|
|
201
|
+
# child if it already exited (zombies count as dead).
|
|
202
|
+
def dead?
|
|
203
|
+
return true if @closed
|
|
204
|
+
return true unless @pid
|
|
205
|
+
|
|
206
|
+
_, status = Process.waitpid(@pid, Process::WNOHANG)
|
|
207
|
+
if status.nil?
|
|
208
|
+
false
|
|
209
|
+
else
|
|
210
|
+
@pid = nil
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
|
|
214
|
+
@pid = nil
|
|
215
|
+
true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Evaluate +code+ in the persistent binding.
|
|
219
|
+
#
|
|
220
|
+
# @param code [String]
|
|
221
|
+
# @param timeout [Integer] max seconds for this evaluation
|
|
222
|
+
# @return [Hash] {"result" => String, "stdout" => String,
|
|
223
|
+
# "stderr" => String, "error" => String or nil}
|
|
224
|
+
# @raise [TimeoutError] evaluation exceeded +timeout+; the kernel
|
|
225
|
+
# was killed and session state lost
|
|
226
|
+
# @raise [DeadError] the kernel process died
|
|
227
|
+
def eval(code, timeout: Repl.eval_timeout)
|
|
228
|
+
@monitor.synchronize do
|
|
229
|
+
raise DeadError, "process not running" if dead?
|
|
230
|
+
|
|
231
|
+
id = (@next_id += 1)
|
|
232
|
+
write_frame("id" => id, "code" => code)
|
|
233
|
+
response = read_frame(id, timeout)
|
|
234
|
+
@last_used = Time.now
|
|
235
|
+
response
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Terminate the kernel process and close pipes. TERM is normally
|
|
240
|
+
# enough (the kernel script exits on it); KILL is the fallback for
|
|
241
|
+
# user code that overrode the trap or wedged the VM.
|
|
242
|
+
#
|
|
243
|
+
# @return [void]
|
|
244
|
+
def close
|
|
245
|
+
@monitor.synchronize do
|
|
246
|
+
return if @closed
|
|
247
|
+
|
|
248
|
+
@closed = true
|
|
249
|
+
if @pid
|
|
250
|
+
begin
|
|
251
|
+
Process.kill("TERM", @pid)
|
|
252
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
253
|
+
@pid = nil
|
|
254
|
+
end
|
|
255
|
+
wait_for_exit(2)
|
|
256
|
+
if @pid
|
|
257
|
+
begin
|
|
258
|
+
Process.kill("KILL", @pid)
|
|
259
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
260
|
+
@pid = nil
|
|
261
|
+
end
|
|
262
|
+
wait_for_exit(1)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
@in_w.close unless @in_w.closed?
|
|
266
|
+
@out_r.close unless @out_r.closed?
|
|
267
|
+
@err_r.close unless @err_r.closed?
|
|
268
|
+
end
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
# Poll until the child exits or +seconds+ elapse. Reaps the child
|
|
275
|
+
# when it does. Avoids blocking +Process.wait+ which Timeout cannot
|
|
276
|
+
# always interrupt.
|
|
277
|
+
#
|
|
278
|
+
# @param seconds [Numeric]
|
|
279
|
+
# @return [void]
|
|
280
|
+
def wait_for_exit(seconds)
|
|
281
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
282
|
+
loop do
|
|
283
|
+
_, status = Process.waitpid(@pid, Process::WNOHANG)
|
|
284
|
+
return if status
|
|
285
|
+
|
|
286
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
287
|
+
|
|
288
|
+
sleep 0.01
|
|
289
|
+
end
|
|
290
|
+
rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
|
|
291
|
+
@pid = nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def spawn_process(ruby)
|
|
295
|
+
@in_r, @in_w = IO.pipe
|
|
296
|
+
@out_r, @out_w = IO.pipe
|
|
297
|
+
@err_r, @err_w = IO.pipe
|
|
298
|
+
|
|
299
|
+
# The kernel is a plain ruby environment: remove bundler plumbing
|
|
300
|
+
# inherited from the parent (RUBYOPT=-rbundler/setup etc.) so the
|
|
301
|
+
# session sees globally installed gems, not the parent's Gemfile
|
|
302
|
+
# subset. Note: Process.spawn *merges* its env hash with the
|
|
303
|
+
# parent environment — only explicit nil values delete keys.
|
|
304
|
+
env = BUNDLER_ENV.to_h { |key| [key, nil] }
|
|
305
|
+
|
|
306
|
+
@pid = Process.spawn(
|
|
307
|
+
env, ruby, @script,
|
|
308
|
+
in: @in_r, out: @out_w, err: @err_w
|
|
309
|
+
)
|
|
310
|
+
@in_r.close
|
|
311
|
+
@out_w.close
|
|
312
|
+
@err_w.close
|
|
313
|
+
rescue StandardError
|
|
314
|
+
close
|
|
315
|
+
raise
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def write_frame(frame)
|
|
319
|
+
@in_w.write(JSON.generate(frame) + "\n")
|
|
320
|
+
@in_w.flush
|
|
321
|
+
rescue Errno::EPIPE, IOError => e
|
|
322
|
+
raise DeadError, e.message
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def read_frame(expected_id, timeout)
|
|
326
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
327
|
+
|
|
328
|
+
loop do
|
|
329
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
330
|
+
if remaining <= 0
|
|
331
|
+
close
|
|
332
|
+
raise TimeoutError, timeout
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
ready = @out_r.wait_readable(remaining)
|
|
336
|
+
unless ready
|
|
337
|
+
close
|
|
338
|
+
raise TimeoutError, timeout
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
line = @out_r.gets
|
|
342
|
+
if line.nil?
|
|
343
|
+
err = @err_r.read
|
|
344
|
+
close
|
|
345
|
+
raise DeadError, "kernel exited unexpectedly#{err.empty? ? "" : ": #{err.strip}"}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
frame = JSON.parse(line)
|
|
349
|
+
return frame if frame["id"] == expected_id
|
|
350
|
+
rescue JSON::ParserError
|
|
351
|
+
next
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
data/lib/ask/tools/shell.rb
CHANGED
|
@@ -10,11 +10,12 @@ require_relative "shell/glob"
|
|
|
10
10
|
require_relative "shell/grep"
|
|
11
11
|
require_relative "shell/code"
|
|
12
12
|
require_relative "shell/apply_patch"
|
|
13
|
+
require_relative "shell/repl"
|
|
13
14
|
|
|
14
15
|
module Ask
|
|
15
16
|
module Tools
|
|
16
17
|
module Shell
|
|
17
|
-
TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch].freeze
|
|
18
|
+
TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch, Repl].freeze
|
|
18
19
|
|
|
19
20
|
def self.all
|
|
20
21
|
TOOLS.map(&:new)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-tools-shell
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -100,6 +100,8 @@ files:
|
|
|
100
100
|
- lib/ask/tools/shell/glob.rb
|
|
101
101
|
- lib/ask/tools/shell/grep.rb
|
|
102
102
|
- lib/ask/tools/shell/read.rb
|
|
103
|
+
- lib/ask/tools/shell/repl.rb
|
|
104
|
+
- lib/ask/tools/shell/repl/kernel_script.rb
|
|
103
105
|
- lib/ask/tools/shell/version.rb
|
|
104
106
|
- lib/ask/tools/shell/write.rb
|
|
105
107
|
homepage: https://github.com/ask-rb/ask-tools-shell
|