ask-tools-shell 0.3.4 → 0.5.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 +60 -0
- data/README.md +34 -133
- data/lib/ask/tools/shell/edit.rb +4 -0
- data/lib/ask/tools/shell/file_ledger.rb +70 -0
- data/lib/ask/tools/shell/grep.rb +7 -1
- data/lib/ask/tools/shell/read.rb +298 -30
- 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/write.rb +13 -0
- data/lib/ask/tools/shell.rb +3 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8805be0a64d1a42f4b0eae3600c81ce0e61565fa19aaf1fd835d36fbb6fa9b58
|
|
4
|
+
data.tar.gz: fe497b0c0604cc2c9a90cd02bc17c31ff8f46140ac88a67e9697b5b9cd15f7ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: defeb70d692dc1da34ad21300ed52dff8496df4fd28c3365b7724d9e3a6c113afbdd7816619cfa0f9017479d9af1a061fb0ef82c140110f0e46209df8829bdc6
|
|
7
|
+
data.tar.gz: e855c29b7b8ed782dab2fccb0764c3eebe4b5b5b7af039aff713fc746c31b3b70f77b1a47abc250a54b217a3bacba4642bef97aa3f409e5f52ee80043a8f0610
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
## [0.5.0] - 2026-08-10
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **Read tool engineering pass** — reads are the bill for building context,
|
|
5
|
+
so every decision inside `Ask::Tools::Read` is a token-budget decision:
|
|
6
|
+
- **Three ceilings, not one**: the 2000-line window (existing), a byte
|
|
7
|
+
budget (128 KB of output by default, `ASK_TOOLS_SHELL_READ_BYTE_BUDGET`
|
|
8
|
+
to override) for wide files, and a per-line clamp (2000 chars) for
|
|
9
|
+
minified bundles. Truncated reads return ok with a **precomputed resume
|
|
10
|
+
offset** — no pagination arithmetic for the model.
|
|
11
|
+
- **Named recovery, facts not errors**: empty files, past-EOF offsets,
|
|
12
|
+
binary files (mime note, never garbage bytes), and PDFs (pdftotext hint)
|
|
13
|
+
all return ok with a one-line answer instead of an error.
|
|
14
|
+
- **Streaming reads**: `File.foreach` with an early break at the budget —
|
|
15
|
+
a 400 MB log costs one read, not one load. "Is there more file?" is only
|
|
16
|
+
answered when it can be (peek, never guess).
|
|
17
|
+
- **Strict input repair**: `offset`/`limit` accept `"2000"` and `2.0` but
|
|
18
|
+
reject `"2abc"` and `1.5` instead of silently reading the wrong window.
|
|
19
|
+
- **Device blocklist**: `/dev/zero`, `/dev/urandom`, `/dev/stdin`,
|
|
20
|
+
`/dev/fd/*`, `/proc/*/fd/*` refused by name before any I/O — a read can
|
|
21
|
+
never hang on them.
|
|
22
|
+
- **Filename repair**: NFD/NFC, narrow NBSP, and curly-quote variants are
|
|
23
|
+
retried for the model; then "did you mean?" (substring + bounded
|
|
24
|
+
Levenshtein ≤ 2, catches `AGENT.md` → `AGENTS.md`).
|
|
25
|
+
- **Self-expiring dedup**: re-reading the same unchanged (path, mtime,
|
|
26
|
+
size, offset, limit) window returns a one-line "already in context" stub
|
|
27
|
+
— consumed on use, complete reads only, kill-switch
|
|
28
|
+
`ASK_TOOLS_SHELL_READ_NO_CACHE=1`.
|
|
29
|
+
- **Partial-view ledger** (`Ask::Tools::Shell::FileLedger`): Read records
|
|
30
|
+
what it showed; **Write refuses to overwrite a partially-read unchanged
|
|
31
|
+
file** ("re-read the full file first"), and Edit records a full read so
|
|
32
|
+
the invariant can't deadlock. Ledger entries auto-invalidate when the
|
|
33
|
+
file's mtime/size change.
|
|
34
|
+
- **Hygiene**: BOM stripped, CRLF → LF, invalid UTF-8 replaced instead of
|
|
35
|
+
raising (a read never crashes on bytes).
|
|
36
|
+
|
|
37
|
+
## [0.4.0] - 2026-08-05
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- **`Ask::Tools::Repl`** — evaluate Ruby code in a persistent session (the
|
|
41
|
+
RLM / recursive language model pattern). A long-lived plain-ruby kernel
|
|
42
|
+
subprocess keeps state across calls: locals, `require`s, and defined
|
|
43
|
+
methods survive between evaluations, so the model composes capabilities as
|
|
44
|
+
code against a working environment instead of re-bootstrapping each time.
|
|
45
|
+
- Framed newline-delimited JSON protocol over stdin/stdout with
|
|
46
|
+
request/response id matching; concurrent calls to a session serialize.
|
|
47
|
+
- Per-evaluation timeout kills the session (state is lost, kernel
|
|
48
|
+
respawns fresh on next call); idle sessions recycle after
|
|
49
|
+
`Repl.idle_timeout` (default 300s).
|
|
50
|
+
- Named sessions shared process-wide (`session:` param, default
|
|
51
|
+
`"default"`); `reset: true` discards state; `Repl.close_session` /
|
|
52
|
+
`Repl.close_all` manage lifetimes; `at_exit` cleanup.
|
|
53
|
+
- Sessions are isolated subprocesses — a crash in one session can't take
|
|
54
|
+
others down, and a dead session is respawned transparently with one
|
|
55
|
+
retry.
|
|
56
|
+
- Kernel spawn strips bundler env vars (RUBYOPT, GEM_HOME, etc.) so the
|
|
57
|
+
session is plain ruby and sees globally installed gems — consistent
|
|
58
|
+
with the one-shot `Code` tool.
|
|
59
|
+
- Registered `repl` in `Shell::TOOLS` / `Shell.all`.
|
|
60
|
+
|
|
1
61
|
## [0.3.4] - 2026-06-25
|
|
2
62
|
|
|
3
63
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,171 +1,72 @@
|
|
|
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 9 tools: Bash, Read, Write, Edit, Glob, Grep, Code, Repl, and ApplyPatch. Bash and Code execute through ask-sandbox-providers; Repl runs a persistent plain-ruby kernel; 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", "repl", "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
|
-
|
|
27
|
+
## The tools
|
|
34
28
|
|
|
35
|
-
|
|
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::Repl` | `code`, `session`, `reset` | Evaluates Ruby in a persistent session — state (variables, requires, methods) survives across calls; timeouts kill the session and respawn fresh |
|
|
39
|
+
| `Ask::Tools::ApplyPatch` | `patchText` | Applies unified diffs inside a `*** Begin Patch` / `*** End Patch` envelope (Add File, Update File, Delete File sections) |
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
## Sandboxed execution
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
| Param | Type | Required | Default | Description |
|
|
42
|
-
|-------|------|----------|---------|-------------|
|
|
43
|
-
| `command` | `string` | Yes | — | The bash command to execute |
|
|
44
|
-
| `timeout` | `integer` | No | 30 | Timeout in seconds |
|
|
45
|
-
| `workdir` | `string` | No | temp dir | Working directory |
|
|
46
|
-
|
|
47
|
-
Returns `{ stdout, stderr, exit_code, timed_out }`. Output truncated to 100KB. Process killed on timeout.
|
|
43
|
+
`Bash` and `Code` run through `Ask::Sandbox.provider` (ask-sandbox-providers), which defaults to the Local provider. Switch to stronger isolation:
|
|
48
44
|
|
|
49
45
|
```ruby
|
|
50
|
-
Ask::
|
|
46
|
+
Ask::Sandbox.provider = :docker
|
|
51
47
|
```
|
|
52
48
|
|
|
53
|
-
|
|
49
|
+
`Repl` is a durable control environment (a persistent subprocess that must
|
|
50
|
+
keep state) and is not sandboxed — don't point it at untrusted code.
|
|
54
51
|
|
|
55
|
-
|
|
52
|
+
### Code vs Repl
|
|
56
53
|
|
|
57
|
-
|
|
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 |
|
|
54
|
+
`Code` runs one Ruby snippet in a sandboxed subprocess and forgets it.
|
|
55
|
+
`Repl` keeps a session alive so variables and methods survive across calls.
|
|
62
56
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### `Ask::Tools::Write`
|
|
69
|
-
|
|
70
|
-
Write content to a file. Creates parent directories automatically.
|
|
57
|
+
Use `Code` for isolated one-off snippets and for code you don't trust (the
|
|
58
|
+
sandbox is the safety boundary). Use `Repl` for multi-step work: load data
|
|
59
|
+
and define helpers once, then keep working with them.
|
|
71
60
|
|
|
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
|
-
```
|
|
61
|
+
## Full documentation
|
|
80
62
|
|
|
81
|
-
|
|
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
|
-
---
|
|
63
|
+
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
64
|
|
|
163
65
|
## Development
|
|
164
66
|
|
|
165
|
-
```
|
|
67
|
+
```
|
|
166
68
|
bundle install
|
|
167
69
|
bundle exec rake test
|
|
168
|
-
gem build ask-tools-shell.gemspec
|
|
169
70
|
```
|
|
170
71
|
|
|
171
72
|
## License
|
data/lib/ask/tools/shell/edit.rb
CHANGED
|
@@ -95,6 +95,10 @@ module Ask
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
raw = operations.read_file(path)
|
|
98
|
+
# Edit reads the whole file, so the model has seen all of it — record
|
|
99
|
+
# that before the write so a later Write isn't blocked by a stale
|
|
100
|
+
# partial view (and so the ledger reflects what was actually shown).
|
|
101
|
+
Shell::FileLedger.record(path, partial: false, lines_seen: [0, raw.count("\n") + 1])
|
|
98
102
|
bom, content = Shell.strip_bom(raw)
|
|
99
103
|
original_ending = Shell.detect_line_ending(content)
|
|
100
104
|
content = Shell.normalize_line_endings(content)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Tools
|
|
7
|
+
module Shell
|
|
8
|
+
# Records what Read has shown of each file, so Write can refuse to
|
|
9
|
+
# destroy content the model never saw. Entries are validated against
|
|
10
|
+
# the file's current mtime/size — a changed file invalidates its
|
|
11
|
+
# entry, so a stale ledger can never block a write it shouldn't.
|
|
12
|
+
#
|
|
13
|
+
# Class-level on purpose: a partial view is a fact about the world,
|
|
14
|
+
# not about one tool instance (agent sessions new up fresh tool
|
|
15
|
+
# instances, and the invariant must survive across them).
|
|
16
|
+
class FileLedger
|
|
17
|
+
Entry = Struct.new(:path, :mtime, :size, :partial, :lines_seen, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
@entries = {}
|
|
20
|
+
@mutex = Monitor.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Record what a read showed of a file.
|
|
24
|
+
# @param partial [Boolean] true when the view was clamped/truncated
|
|
25
|
+
# @param lines_seen [Array(Integer, Integer)] [start, stop) line
|
|
26
|
+
# indices shown, 0-indexed
|
|
27
|
+
def record(path, partial:, lines_seen:)
|
|
28
|
+
path = File.expand_path(path)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@entries[path] = Entry.new(
|
|
31
|
+
path: path,
|
|
32
|
+
mtime: File.mtime(path),
|
|
33
|
+
size: File.size(path),
|
|
34
|
+
partial: partial,
|
|
35
|
+
lines_seen: lines_seen
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
nil # file vanished mid-read; nothing to record
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The entry for a path, or nil when the file changed since the read.
|
|
43
|
+
def entry_for(path)
|
|
44
|
+
path = File.expand_path(path)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
entry = @entries[path]
|
|
47
|
+
next nil unless entry
|
|
48
|
+
|
|
49
|
+
current = File.stat(path)
|
|
50
|
+
(current.mtime == entry.mtime && current.size == entry.size) ? entry : nil
|
|
51
|
+
end
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True when the file was only partially read and hasn't changed
|
|
57
|
+
# since. Write consults this before overwriting.
|
|
58
|
+
def partially_seen?(path)
|
|
59
|
+
entry = entry_for(path)
|
|
60
|
+
!entry.nil? && entry.partial
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reset!
|
|
64
|
+
@mutex.synchronize { @entries.clear }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
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|
|
data/lib/ask/tools/shell/read.rb
CHANGED
|
@@ -5,64 +5,332 @@ require "fileutils"
|
|
|
5
5
|
module Ask
|
|
6
6
|
module Tools
|
|
7
7
|
# Read file contents with line numbers, or list directory contents.
|
|
8
|
-
#
|
|
8
|
+
#
|
|
9
|
+
# Engineered for token budgets. Three ceilings stop the three shapes of
|
|
10
|
+
# hostile file — the long file (line window), the wide file (byte
|
|
11
|
+
# budget), the minified bundle (per-line clamp):
|
|
12
|
+
#
|
|
13
|
+
# max_lines = 2000 lines — the window
|
|
14
|
+
# byte_budget = 128 KB — chars of output returned
|
|
15
|
+
# max_line_chars = 2000 — per-line clamp
|
|
16
|
+
#
|
|
17
|
+
# Truncation is a fact, not an error: reads that stop short return ok
|
|
18
|
+
# with a precomputed resume offset, so the model never does pagination
|
|
19
|
+
# arithmetic and never treats a fact about the world as a failure.
|
|
20
|
+
#
|
|
21
|
+
# The other decisions that make a read cheap instead of expensive:
|
|
22
|
+
# - strict offset/limit repair (never silently mangle "2abc" into 2)
|
|
23
|
+
# - device blocklist — /dev/zero would hang a read forever
|
|
24
|
+
# - filename repair: NFD/NFC, narrow NBSP, curly quotes, did-you-mean
|
|
25
|
+
# - a self-expiring dedup stub for unchanged re-reads (consumed on use,
|
|
26
|
+
# complete reads only, kill-switchable)
|
|
27
|
+
# - a partial-view ledger that Write consults before overwriting
|
|
9
28
|
class Read < Ask::Tool
|
|
10
29
|
description "Read the contents of a file or list a directory. " \
|
|
11
|
-
"Files are displayed with line numbers. " \
|
|
12
|
-
"
|
|
30
|
+
"Files are displayed with line numbers. Output is bounded " \
|
|
31
|
+
"to 2000 lines and 128 KB, with resume hints when truncated."
|
|
13
32
|
|
|
14
33
|
param :path, type: :string, desc: "Absolute path to the file or directory", required: true
|
|
15
34
|
param :offset, type: :integer, desc: "Starting line number (0-indexed)", required: false
|
|
16
35
|
param :limit, type: :integer, desc: "Maximum number of lines to read", required: false
|
|
17
36
|
|
|
18
|
-
|
|
37
|
+
DEFAULT_MAX_LINES = 2000
|
|
38
|
+
DEFAULT_BYTE_BUDGET = 128_000
|
|
39
|
+
DEFAULT_MAX_LINE_CHARS = 2000
|
|
40
|
+
|
|
41
|
+
# Device files that never end or block forever — refused by name
|
|
42
|
+
# before any I/O, so a read can never hang on them.
|
|
43
|
+
DEVICE_PATHS = %w[
|
|
44
|
+
/dev/zero /dev/random /dev/urandom
|
|
45
|
+
/dev/stdin /dev/stdout /dev/stderr
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
MIME_TYPES = {
|
|
49
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
50
|
+
".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
|
|
51
|
+
".pdf" => "application/pdf", ".zip" => "application/zip",
|
|
52
|
+
".gz" => "application/gzip", ".mp3" => "audio/mpeg", ".mp4" => "video/mp4"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
attr_reader :max_lines, :byte_budget, :max_line_chars, :dedup_enabled
|
|
56
|
+
attr_writer :max_lines, :byte_budget, :max_line_chars
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
super
|
|
60
|
+
@max_lines = DEFAULT_MAX_LINES
|
|
61
|
+
@byte_budget = (ENV["ASK_TOOLS_SHELL_READ_BYTE_BUDGET"] || DEFAULT_BYTE_BUDGET).to_i
|
|
62
|
+
@max_line_chars = DEFAULT_MAX_LINE_CHARS
|
|
63
|
+
@dedup_enabled = ENV["ASK_TOOLS_SHELL_READ_NO_CACHE"] != "1"
|
|
64
|
+
@dedup = {}
|
|
65
|
+
end
|
|
19
66
|
|
|
20
67
|
def execute(path:, offset: nil, limit: nil)
|
|
21
68
|
path = File.expand_path(path)
|
|
22
69
|
|
|
70
|
+
if device_path?(path)
|
|
71
|
+
return Ask::Result.error(message: "Refusing to read device file: #{path} (can block forever).")
|
|
72
|
+
end
|
|
73
|
+
|
|
23
74
|
unless File.exist?(path)
|
|
24
|
-
return Ask::Result.error(message:
|
|
75
|
+
return Ask::Result.error(message: missing_path_message(path))
|
|
25
76
|
end
|
|
26
77
|
|
|
27
78
|
if File.directory?(path)
|
|
28
|
-
|
|
29
|
-
entries.map! do |e|
|
|
30
|
-
full = File.join(path, e)
|
|
31
|
-
"#{e}#{File.directory?(full) ? '/' : ''}"
|
|
32
|
-
end
|
|
33
|
-
return Ask::Result.ok(data: entries.join("\n"), metadata: { type: "directory", count: entries.size })
|
|
79
|
+
return directory_listing(path)
|
|
34
80
|
end
|
|
35
81
|
|
|
36
82
|
unless File.file?(path)
|
|
37
83
|
return Ask::Result.error(message: "Not a file: #{path}")
|
|
38
84
|
end
|
|
39
85
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
86
|
+
offset = coerce_int("offset", offset)
|
|
87
|
+
return Ask::Result.error(message: offset) if offset.is_a?(String)
|
|
88
|
+
limit = coerce_int("limit", limit)
|
|
89
|
+
return Ask::Result.error(message: limit) if limit.is_a?(String)
|
|
90
|
+
|
|
91
|
+
offset ||= 0
|
|
92
|
+
limit ||= @max_lines
|
|
93
|
+
return Ask::Result.error(message: "Invalid offset: #{offset} (must be >= 0).") if offset.negative?
|
|
94
|
+
return Ask::Result.error(message: "Invalid limit: #{limit} (must be >= 1).") if limit < 1
|
|
95
|
+
|
|
96
|
+
special = sniff(path)
|
|
97
|
+
return special if special
|
|
98
|
+
|
|
99
|
+
read = read_lines(path, offset, limit)
|
|
100
|
+
partial_view = read[:more] || read[:clamped].positive?
|
|
101
|
+
|
|
102
|
+
if @dedup_enabled && !partial_view && read[:lines].any?
|
|
103
|
+
key = [path, File.mtime(path).to_f, File.size(path), offset, limit]
|
|
104
|
+
if @dedup.key?(key)
|
|
105
|
+
@dedup.delete(key) # self-expiring: one stub, then real content again
|
|
106
|
+
return Ask::Result.ok(
|
|
107
|
+
data: "File unchanged since last read — content is already in context.",
|
|
108
|
+
metadata: { dedup: true }
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
@dedup[key] = true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Shell::FileLedger.record(path, partial: partial_view, lines_seen: [offset, offset + read[:lines].size])
|
|
115
|
+
|
|
116
|
+
data, resume_offset = format_output(path, offset, read)
|
|
117
|
+
|
|
118
|
+
metadata = {
|
|
119
|
+
total_lines: read[:more] || (read[:lines].empty? && read[:saw_any]) ? nil : offset + read[:lines].size,
|
|
120
|
+
start_line: read[:lines].empty? ? nil : offset + 1,
|
|
121
|
+
end_line: offset + read[:lines].size,
|
|
122
|
+
truncated: read[:more],
|
|
123
|
+
partial_view: partial_view,
|
|
124
|
+
clamped_lines: read[:clamped],
|
|
125
|
+
resume_offset: resume_offset
|
|
126
|
+
}
|
|
127
|
+
metadata.delete(:resume_offset) unless read[:more]
|
|
128
|
+
Ask::Result.ok(data: data, metadata: metadata)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# ── the three ceilings ─────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
# Stream the file line by line (never load the whole thing), stopping
|
|
136
|
+
# at the line window, the byte budget, or EOF. Returns
|
|
137
|
+
# { lines:, more:, clamped:, mid_cut:, saw_any: }.
|
|
138
|
+
#
|
|
139
|
+
# "Is there more file?" is only answered when it can be: the window
|
|
140
|
+
# break proves it by having skipped a line; the budget break proves it
|
|
141
|
+
# by holding a line that didn't fit; at EOF there is none.
|
|
142
|
+
def read_lines(path, offset, limit)
|
|
143
|
+
selected = []
|
|
144
|
+
more = false
|
|
145
|
+
clamped = 0
|
|
146
|
+
mid_cut = false
|
|
147
|
+
output_len = 0
|
|
148
|
+
saw_any = false
|
|
149
|
+
|
|
150
|
+
File.foreach(path, chomp: true, invalid: :replace, undef: :replace).with_index do |line, i|
|
|
151
|
+
saw_any = true
|
|
152
|
+
next if i < offset
|
|
153
|
+
|
|
154
|
+
if selected.size >= limit
|
|
155
|
+
more = true
|
|
156
|
+
break
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
line = line.delete_suffix("\r") # CRLF → LF
|
|
160
|
+
line = Shell.strip_bom(line).last if i.zero? && offset.zero?
|
|
161
|
+
|
|
162
|
+
if line.length > @max_line_chars
|
|
163
|
+
line = line[0, @max_line_chars] + "…[clamped at #{@max_line_chars} chars]"
|
|
164
|
+
clamped += 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
numbered = "#{i + 1}: #{line}"
|
|
168
|
+
cost = numbered.length + 1
|
|
169
|
+
if output_len + cost > @byte_budget
|
|
170
|
+
if selected.empty?
|
|
171
|
+
# One line that outgrows the whole budget: show a slice rather
|
|
172
|
+
# than silence — silence is the most expensive thing a tool can
|
|
173
|
+
# return.
|
|
174
|
+
room = [@byte_budget - output_len - 2, 1].max
|
|
175
|
+
selected << "#{i + 1}: #{line[0, room]}…"
|
|
176
|
+
clamped += 1
|
|
177
|
+
mid_cut = true
|
|
178
|
+
end
|
|
179
|
+
more = true
|
|
180
|
+
break
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
output_len += cost
|
|
184
|
+
selected << numbered
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
{ lines: selected, more: more, clamped: clamped, mid_cut: mid_cut, saw_any: saw_any }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ── named recovery: facts, not errors ──────────────────────────────
|
|
191
|
+
|
|
192
|
+
def format_output(path, offset, read)
|
|
193
|
+
if read[:lines].empty?
|
|
194
|
+
return read[:saw_any] ?
|
|
195
|
+
["Offset #{offset} is past the end of the file — retry with a smaller offset.", nil] :
|
|
196
|
+
["File is empty (0 lines).", nil]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
data = read[:lines].join("\n")
|
|
200
|
+
resume = nil
|
|
201
|
+
if read[:more]
|
|
202
|
+
# A mid-line cut resumes ON the line that was cut (it is the last
|
|
203
|
+
# line shown); every other truncation resumes on the next line.
|
|
204
|
+
resume = read[:mid_cut] ? offset + read[:lines].size - 1 : offset + read[:lines].size
|
|
205
|
+
data << "\n... (more lines) — resume with offset=#{resume}"
|
|
206
|
+
end
|
|
207
|
+
[data, resume]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def directory_listing(path)
|
|
211
|
+
entries = Dir.children(path).sort
|
|
212
|
+
entries.map! do |e|
|
|
213
|
+
full = File.join(path, e)
|
|
214
|
+
"#{e}#{File.directory?(full) ? '/' : ''}"
|
|
215
|
+
end
|
|
216
|
+
Ask::Result.ok(data: entries.join("\n"), metadata: { type: "directory", count: entries.size })
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Peek the head of the file: PDF magic first (it's also binary), then
|
|
220
|
+
# a null byte means binary. Returns an Ask::Result for special formats.
|
|
221
|
+
def sniff(path)
|
|
222
|
+
head = File.open(path, "rb") { |f| f.read(1024) } || ""
|
|
223
|
+
if head.start_with?("%PDF")
|
|
224
|
+
return Ask::Result.ok(
|
|
225
|
+
data: "PDF document (#{File.size(path)} bytes) — extract text with pdftotext.",
|
|
226
|
+
metadata: { format: "pdf" }
|
|
43
227
|
)
|
|
44
228
|
end
|
|
229
|
+
if head.include?("\x00")
|
|
230
|
+
mime = MIME_TYPES[File.extname(path).downcase] || "application/octet-stream"
|
|
231
|
+
return Ask::Result.ok(
|
|
232
|
+
data: "Binary file (#{mime}, #{File.size(path)} bytes) — content not shown.",
|
|
233
|
+
metadata: { format: "binary", mime: mime }
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# ── input repair ───────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
# Coerce the value an LLM actually sent into an integer, or return an
|
|
242
|
+
# error message string. Accepts Integer, "2000", 2.0 — rejects "2abc"
|
|
243
|
+
# and 1.5 rather than silently reading a wrong window.
|
|
244
|
+
def coerce_int(name, value)
|
|
245
|
+
case value
|
|
246
|
+
when nil then nil
|
|
247
|
+
when Integer then value
|
|
248
|
+
when String
|
|
249
|
+
value.match?(/\A-?\d+\z/) ? value.to_i : "Invalid #{name}: #{value.inspect} — expected an integer."
|
|
250
|
+
when Float
|
|
251
|
+
value == value.to_i ? value.to_i : "Invalid #{name}: #{value.inspect} — expected an integer."
|
|
252
|
+
else
|
|
253
|
+
"Invalid #{name}: #{value.inspect} — expected an integer."
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ── device blocklist ───────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def device_path?(path)
|
|
260
|
+
DEVICE_PATHS.include?(path) ||
|
|
261
|
+
path.start_with?("/dev/fd/") ||
|
|
262
|
+
path.match?(%r{\A/proc/\d+/fd/}) ||
|
|
263
|
+
path.match?(%r{\A/proc/\d+/task/\d+/fd/})
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ── filename repair ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def missing_path_message(path)
|
|
269
|
+
base = File.basename(path)
|
|
270
|
+
dir = File.dirname(path)
|
|
271
|
+
|
|
272
|
+
# The model can't see byte-level differences: narrow NBSP vs space,
|
|
273
|
+
# NFD vs NFC, straight vs curly quotes. Retry the candidates for it.
|
|
274
|
+
match = filename_variants(base).find { |c| File.exist?(File.join(dir, c)) }
|
|
275
|
+
if match
|
|
276
|
+
return "Path does not exist: #{path} — a close match exists: " \
|
|
277
|
+
"#{File.join(dir, match)} (different characters; use that exact path)."
|
|
278
|
+
end
|
|
45
279
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
280
|
+
if File.directory?(dir)
|
|
281
|
+
suggestions = did_you_mean(base, Dir.children(dir))
|
|
282
|
+
unless suggestions.empty?
|
|
283
|
+
return "Path does not exist: #{path} — did you mean: " \
|
|
284
|
+
"#{suggestions.map { |s| File.join(dir, s) }.join(", ")}?"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
50
287
|
|
|
51
|
-
|
|
52
|
-
|
|
288
|
+
"Path does not exist: #{path}"
|
|
289
|
+
end
|
|
53
290
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
291
|
+
def filename_variants(name)
|
|
292
|
+
variants = []
|
|
293
|
+
[["\u202F", " "], ["\u00A0", " "]].each do |special, plain| # narrow NBSP / NBSP
|
|
294
|
+
variants << name.gsub(special, plain) if name.include?(special)
|
|
295
|
+
variants << name.gsub(plain, special) if name.include?(plain)
|
|
296
|
+
end
|
|
297
|
+
variants << name.unicode_normalize(:nfd) unless name.unicode_normalized?(:nfd)
|
|
298
|
+
variants << name.unicode_normalize(:nfc) unless name.unicode_normalized?(:nfc)
|
|
299
|
+
[["'", "\u2018"], ["'", "\u2019"], ['"', "\u201C"], ['"', "\u201D"]].each do |straight, curly|
|
|
300
|
+
variants << name.gsub(straight, curly) if name.include?(straight)
|
|
301
|
+
variants << name.gsub(curly, straight) if name.include?(curly)
|
|
302
|
+
end
|
|
303
|
+
variants.uniq.reject { |v| v == name }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def did_you_mean(name, siblings)
|
|
307
|
+
others = siblings.reject { |s| s == name }
|
|
308
|
+
if name.length >= 3
|
|
309
|
+
sub = others.select { |s| s.include?(name) || name.include?(s) }
|
|
310
|
+
return sub.sort_by(&:length).first(3) unless sub.empty?
|
|
311
|
+
end
|
|
312
|
+
others
|
|
313
|
+
.select { |s| levenshtein(name, s) <= 2 }
|
|
314
|
+
.sort_by { |s| levenshtein(name, s) }
|
|
315
|
+
.first(3)
|
|
316
|
+
end
|
|
57
317
|
|
|
58
|
-
|
|
318
|
+
# Bounded Wagner–Fischer; cheap because we bail on length gap > max.
|
|
319
|
+
def levenshtein(a, b, max: 2)
|
|
320
|
+
return 0 if a == b
|
|
321
|
+
return max + 1 if (a.length - b.length).abs > max
|
|
59
322
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
323
|
+
row = (0..b.length).to_a
|
|
324
|
+
a.each_char do |ac|
|
|
325
|
+
prev = row[0]
|
|
326
|
+
row[0] = prev + 1
|
|
327
|
+
b.each_char.with_index do |bc, j|
|
|
328
|
+
cur = row[j + 1]
|
|
329
|
+
row[j + 1] = [cur + 1, row[j] + 1, prev + (ac == bc ? 0 : 1)].min
|
|
330
|
+
prev = cur
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
row[b.length]
|
|
66
334
|
end
|
|
67
335
|
end
|
|
68
336
|
end
|
|
@@ -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
|
|
@@ -24,6 +24,19 @@ module Ask
|
|
|
24
24
|
)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Never destroy what the model never saw: if Read only showed part of
|
|
28
|
+
# this file and it hasn't changed since, the rest may hold something
|
|
29
|
+
# the overwrite would silently erase.
|
|
30
|
+
if Shell::FileLedger.partially_seen?(path)
|
|
31
|
+
entry = Shell::FileLedger.entry_for(path)
|
|
32
|
+
seen = entry.lines_seen
|
|
33
|
+
return Ask::Result.error(
|
|
34
|
+
message: "Refusing to overwrite #{path}: only part of the file has been read " \
|
|
35
|
+
"(lines #{seen.first + 1}–#{seen.last} shown, more lines exist). " \
|
|
36
|
+
"Re-read the full file first."
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
27
40
|
dir = File.dirname(path)
|
|
28
41
|
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
29
42
|
|
data/lib/ask/tools/shell.rb
CHANGED
|
@@ -4,17 +4,19 @@ require_relative "shell/version"
|
|
|
4
4
|
require "ask/tools/tool"
|
|
5
5
|
require_relative "shell/bash"
|
|
6
6
|
require_relative "shell/read"
|
|
7
|
+
require_relative "shell/file_ledger"
|
|
7
8
|
require_relative "shell/write"
|
|
8
9
|
require_relative "shell/edit"
|
|
9
10
|
require_relative "shell/glob"
|
|
10
11
|
require_relative "shell/grep"
|
|
11
12
|
require_relative "shell/code"
|
|
12
13
|
require_relative "shell/apply_patch"
|
|
14
|
+
require_relative "shell/repl"
|
|
13
15
|
|
|
14
16
|
module Ask
|
|
15
17
|
module Tools
|
|
16
18
|
module Shell
|
|
17
|
-
TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch].freeze
|
|
19
|
+
TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch, Repl].freeze
|
|
18
20
|
|
|
19
21
|
def self.all
|
|
20
22
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -96,10 +96,13 @@ files:
|
|
|
96
96
|
- lib/ask/tools/shell/bash.rb
|
|
97
97
|
- lib/ask/tools/shell/code.rb
|
|
98
98
|
- lib/ask/tools/shell/edit.rb
|
|
99
|
+
- lib/ask/tools/shell/file_ledger.rb
|
|
99
100
|
- lib/ask/tools/shell/file_mutation_queue.rb
|
|
100
101
|
- lib/ask/tools/shell/glob.rb
|
|
101
102
|
- lib/ask/tools/shell/grep.rb
|
|
102
103
|
- lib/ask/tools/shell/read.rb
|
|
104
|
+
- lib/ask/tools/shell/repl.rb
|
|
105
|
+
- lib/ask/tools/shell/repl/kernel_script.rb
|
|
103
106
|
- lib/ask/tools/shell/version.rb
|
|
104
107
|
- lib/ask/tools/shell/write.rb
|
|
105
108
|
homepage: https://github.com/ask-rb/ask-tools-shell
|