llm_gateway 0.6.0 → 0.7.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 +12 -0
- data/README.md +255 -1
- data/docs/migration_guide_0.7.0.md +193 -0
- data/lib/llm_gateway/adapters/adapter.rb +1 -1
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
- data/lib/llm_gateway/adapters/structs.rb +45 -10
- data/lib/llm_gateway/agents/event.rb +105 -0
- data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
- data/lib/llm_gateway/agents/harness.rb +176 -0
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
- data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
- data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
- data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
- data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
- data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
- data/lib/llm_gateway/base_client.rb +3 -3
- data/lib/llm_gateway/clients/anthropic.rb +5 -5
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
- data/lib/llm_gateway/clients/openai.rb +2 -2
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
- data/lib/llm_gateway/prompt.rb +105 -68
- data/lib/llm_gateway/utils.rb +116 -13
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +4 -0
- metadata +12 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "tool_utils"
|
|
4
|
+
|
|
5
|
+
class BashTool < LlmGateway::Tool
|
|
6
|
+
# Pi adaptation notes:
|
|
7
|
+
# - Keep timeout schema as integer: gruv treats integer and number schemas equivalently for seconds.
|
|
8
|
+
# - Do not add pi's pluggable operations, shell/env hooks, command prefix, AbortSignal handling, partial updates, or UI render details: those are runtime/UI extension concerns outside this tool contract.
|
|
9
|
+
name "bash"
|
|
10
|
+
description "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last #{ToolUtils::DEFAULT_MAX_LINES} lines or #{ToolUtils::DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds."
|
|
11
|
+
input_schema({
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
command: { type: "string", description: "Bash command to execute" },
|
|
15
|
+
timeout: { type: "integer", description: "Timeout in seconds (optional, no default timeout)" }
|
|
16
|
+
},
|
|
17
|
+
required: [ "command" ]
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
def execute(input)
|
|
21
|
+
command = input[:command]
|
|
22
|
+
timeout = input[:timeout]
|
|
23
|
+
|
|
24
|
+
result = run_command(command, timeout)
|
|
25
|
+
out = format_output(result[:output], empty_text: result[:timed_out] ? "" : "(no output)")
|
|
26
|
+
|
|
27
|
+
if result[:timed_out]
|
|
28
|
+
return append_status(out, "Command timed out after #{timeout} seconds")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if result[:exit_status] && result[:exit_status] != 0
|
|
32
|
+
return append_status(out, "Command exited with code #{result[:exit_status]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
out
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
e.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def run_command(command, timeout)
|
|
43
|
+
output = +""
|
|
44
|
+
timed_out = false
|
|
45
|
+
read_io, write_io = IO.pipe
|
|
46
|
+
pid = Process.spawn(command, chdir: Dir.pwd, in: File::NULL, out: write_io, err: write_io, pgroup: true)
|
|
47
|
+
write_io.close
|
|
48
|
+
|
|
49
|
+
deadline = timeout && timeout.positive? ? Time.now + timeout : nil
|
|
50
|
+
|
|
51
|
+
loop do
|
|
52
|
+
remaining = deadline ? deadline - Time.now : nil
|
|
53
|
+
if remaining && remaining <= 0
|
|
54
|
+
timed_out = true
|
|
55
|
+
terminate_process_group(pid)
|
|
56
|
+
break
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ready = IO.select([ read_io ], nil, nil, remaining)
|
|
60
|
+
unless ready
|
|
61
|
+
timed_out = true
|
|
62
|
+
terminate_process_group(pid)
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
output << read_io.readpartial(16 * 1024)
|
|
68
|
+
rescue EOFError
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
_, status = Process.wait2(pid)
|
|
74
|
+
drain_available_output(read_io, output)
|
|
75
|
+
read_io.close
|
|
76
|
+
|
|
77
|
+
{ output: output, exit_status: status.exitstatus, timed_out: timed_out }
|
|
78
|
+
ensure
|
|
79
|
+
write_io.close if write_io && !write_io.closed?
|
|
80
|
+
read_io.close if read_io && !read_io.closed?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def drain_available_output(read_io, output)
|
|
84
|
+
loop do
|
|
85
|
+
ready = IO.select([ read_io ], nil, nil, 0.1)
|
|
86
|
+
break unless ready
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
output << read_io.readpartial(16 * 1024)
|
|
90
|
+
rescue EOFError
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def terminate_process_group(pid)
|
|
97
|
+
Process.kill("TERM", -pid)
|
|
98
|
+
sleep 0.1
|
|
99
|
+
Process.kill("KILL", -pid)
|
|
100
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_output(output, empty_text: "(no output)")
|
|
105
|
+
truncation = ToolUtils.truncate_tail(output)
|
|
106
|
+
out = truncation[:content]
|
|
107
|
+
out = empty_text if out.empty?
|
|
108
|
+
|
|
109
|
+
return out unless truncation[:truncated]
|
|
110
|
+
|
|
111
|
+
temp_path = File.join(Dir.tmpdir, "pi-bash-#{SecureRandom.hex(8)}.log")
|
|
112
|
+
File.write(temp_path, output)
|
|
113
|
+
|
|
114
|
+
start_line = truncation[:total_lines] - truncation[:output_lines] + 1
|
|
115
|
+
end_line = truncation[:total_lines]
|
|
116
|
+
|
|
117
|
+
notice = if truncation[:last_line_partial]
|
|
118
|
+
last_line = output.split("\n", -1).last
|
|
119
|
+
"[Showing last #{ToolUtils.format_size(truncation[:output_bytes])} of line #{end_line} (line is #{ToolUtils.format_size(last_line.bytesize)}). Full output: #{temp_path}]"
|
|
120
|
+
elsif truncation[:truncated_by] == "lines"
|
|
121
|
+
"[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]}. Full output: #{temp_path}]"
|
|
122
|
+
else
|
|
123
|
+
"[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]} (#{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit). Full output: #{temp_path}]"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
"#{out}\n\n#{notice}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def append_status(text, status)
|
|
130
|
+
text.empty? ? status : "#{text}\n\n#{status}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "tool_utils"
|
|
3
|
+
|
|
4
|
+
class EditTool < LlmGateway::Tool
|
|
5
|
+
# Pi adaptation notes:
|
|
6
|
+
# - Legacy single-edit input: pi accepts oldText/newText and converts to edits,
|
|
7
|
+
# but this tool intentionally exposes/supports only the edits array to keep the LLM contract unambiguous.
|
|
8
|
+
# - Do not add pi's diff/patch details, preview rendering, pluggable operations, or AbortSignal handling: those are UI/runtime extension concerns outside this tool contract.
|
|
9
|
+
name "edit"
|
|
10
|
+
|
|
11
|
+
class EditError < StandardError; end
|
|
12
|
+
description "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits."
|
|
13
|
+
input_schema({
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: "string", description: "Path to the file to edit (relative or absolute)" },
|
|
17
|
+
edits: {
|
|
18
|
+
type: "array",
|
|
19
|
+
description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.",
|
|
20
|
+
items: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
oldText: { type: "string", description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call." },
|
|
24
|
+
newText: { type: "string", description: "Replacement text for this targeted edit." }
|
|
25
|
+
},
|
|
26
|
+
required: [ "oldText", "newText" ]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: [ "path", "edits" ]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
def execute(input)
|
|
34
|
+
path = input[:path]
|
|
35
|
+
edits = prepare_edits(input[:edits])
|
|
36
|
+
|
|
37
|
+
return "Edit tool input is invalid. edits must contain at least one replacement." if !edits.is_a?(Array) || edits.empty?
|
|
38
|
+
|
|
39
|
+
absolute_path = ToolUtils.resolve_to_cwd(path)
|
|
40
|
+
|
|
41
|
+
ToolUtils.with_file_mutation_lock(absolute_path) do
|
|
42
|
+
begin
|
|
43
|
+
File.open(absolute_path, File::RDWR) { }
|
|
44
|
+
rescue SystemCallError => e
|
|
45
|
+
return "Could not edit file: #{path}. Error code: #{e.class.name.split("::").last}."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raw_content = File.binread(absolute_path)
|
|
49
|
+
|
|
50
|
+
bom = raw_content.start_with?("\xEF\xBB\xBF".b) ? "\xEF\xBB\xBF".b : "".b
|
|
51
|
+
content_without_bom = bom.empty? ? raw_content : raw_content.byteslice(3..)
|
|
52
|
+
content_utf8 = content_without_bom.force_encoding("UTF-8")
|
|
53
|
+
|
|
54
|
+
original_ending = detect_line_ending(content_utf8)
|
|
55
|
+
normalized_content = normalize_to_lf(content_utf8)
|
|
56
|
+
base_content, new_content = apply_edits_to_normalized_content(normalized_content, edits, path)
|
|
57
|
+
|
|
58
|
+
restored = restore_line_endings(new_content, original_ending)
|
|
59
|
+
final_bytes = bom + restored.encode("UTF-8").b
|
|
60
|
+
|
|
61
|
+
File.binwrite(absolute_path, final_bytes)
|
|
62
|
+
"Successfully replaced #{edits.length} block(s) in #{path}."
|
|
63
|
+
end
|
|
64
|
+
rescue EditError => e
|
|
65
|
+
e.message
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
"Error editing file: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def prepare_edits(edits)
|
|
73
|
+
return JSON.parse(edits, symbolize_names: true) if edits.is_a?(String)
|
|
74
|
+
|
|
75
|
+
edits
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def detect_line_ending(content)
|
|
79
|
+
crlf_index = content.index("\r\n")
|
|
80
|
+
lf_index = content.index("\n")
|
|
81
|
+
return "\n" unless lf_index
|
|
82
|
+
return "\n" unless crlf_index
|
|
83
|
+
|
|
84
|
+
crlf_index < lf_index ? "\r\n" : "\n"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_to_lf(text)
|
|
88
|
+
text.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def restore_line_endings(text, ending)
|
|
92
|
+
ending == "\r\n" ? text.gsub("\n", "\r\n") : text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_for_fuzzy_match(text)
|
|
96
|
+
text
|
|
97
|
+
.unicode_normalize(:nfkc)
|
|
98
|
+
.split("\n", -1)
|
|
99
|
+
.map(&:rstrip)
|
|
100
|
+
.join("\n")
|
|
101
|
+
.gsub(/[\u2018\u2019\u201A\u201B]/, "'")
|
|
102
|
+
.gsub(/[\u201C\u201D\u201E\u201F]/, '"')
|
|
103
|
+
.gsub(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/, "-")
|
|
104
|
+
.gsub(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/, " ")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fuzzy_find_text(content, old_text)
|
|
108
|
+
exact_index = content.index(old_text)
|
|
109
|
+
if exact_index
|
|
110
|
+
return {
|
|
111
|
+
found: true,
|
|
112
|
+
index: exact_index,
|
|
113
|
+
match_length: old_text.length,
|
|
114
|
+
used_fuzzy_match: false,
|
|
115
|
+
content_for_replacement: content
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
120
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
121
|
+
fuzzy_index = fuzzy_content.index(fuzzy_old_text)
|
|
122
|
+
|
|
123
|
+
return { found: false, index: -1, match_length: 0, used_fuzzy_match: false, content_for_replacement: content } unless fuzzy_index
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
found: true,
|
|
127
|
+
index: fuzzy_index,
|
|
128
|
+
match_length: fuzzy_old_text.length,
|
|
129
|
+
used_fuzzy_match: true,
|
|
130
|
+
content_for_replacement: fuzzy_content
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def count_occurrences(content, old_text)
|
|
135
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
136
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
137
|
+
fuzzy_content.split(fuzzy_old_text, -1).length - 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def apply_edits_to_normalized_content(normalized_content, edits, path)
|
|
141
|
+
normalized_edits = edits.map do |edit|
|
|
142
|
+
{
|
|
143
|
+
oldText: normalize_to_lf(edit[:oldText]),
|
|
144
|
+
newText: normalize_to_lf(edit[:newText])
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
normalized_edits.each_with_index do |edit, index|
|
|
149
|
+
raise EditError, empty_old_text_error(path, index, normalized_edits.length) if edit[:oldText].empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
initial_matches = normalized_edits.map { |edit| fuzzy_find_text(normalized_content, edit[:oldText]) }
|
|
153
|
+
base_content = initial_matches.any? { |match| match[:used_fuzzy_match] } ? normalize_for_fuzzy_match(normalized_content) : normalized_content
|
|
154
|
+
|
|
155
|
+
matched_edits = []
|
|
156
|
+
normalized_edits.each_with_index do |edit, index|
|
|
157
|
+
match = fuzzy_find_text(base_content, edit[:oldText])
|
|
158
|
+
raise EditError, not_found_error(path, index, normalized_edits.length) unless match[:found]
|
|
159
|
+
|
|
160
|
+
occurrences = count_occurrences(base_content, edit[:oldText])
|
|
161
|
+
raise EditError, duplicate_error(path, index, normalized_edits.length, occurrences) if occurrences > 1
|
|
162
|
+
|
|
163
|
+
matched_edits << {
|
|
164
|
+
edit_index: index,
|
|
165
|
+
match_index: match[:index],
|
|
166
|
+
match_length: match[:match_length],
|
|
167
|
+
new_text: edit[:newText]
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
matched_edits.sort_by! { |edit| edit[:match_index] }
|
|
172
|
+
matched_edits.each_cons(2) do |previous, current|
|
|
173
|
+
next unless previous[:match_index] + previous[:match_length] > current[:match_index]
|
|
174
|
+
|
|
175
|
+
raise EditError, "edits[#{previous[:edit_index]}] and edits[#{current[:edit_index]}] overlap in #{path}. Merge them into one edit or target disjoint regions."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
new_content = base_content.dup
|
|
179
|
+
matched_edits.reverse_each do |edit|
|
|
180
|
+
new_content = new_content[0...edit[:match_index]] + edit[:new_text] + new_content[(edit[:match_index] + edit[:match_length])..]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
raise EditError, no_change_error(path, normalized_edits.length) if base_content == new_content
|
|
184
|
+
|
|
185
|
+
[ base_content, new_content ]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def not_found_error(path, edit_index, total_edits)
|
|
189
|
+
return "Could not find the exact text in #{path}. The old text must match exactly including all whitespace and newlines." if total_edits == 1
|
|
190
|
+
|
|
191
|
+
"Could not find edits[#{edit_index}] in #{path}. The oldText must match exactly including all whitespace and newlines."
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def duplicate_error(path, edit_index, total_edits, occurrences)
|
|
195
|
+
if total_edits == 1
|
|
196
|
+
return "Found #{occurrences} occurrences of the text in #{path}. The text must be unique. Please provide more context to make it unique."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
"Found #{occurrences} occurrences of edits[#{edit_index}] in #{path}. Each oldText must be unique. Please provide more context to make it unique."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def empty_old_text_error(path, edit_index, total_edits)
|
|
203
|
+
return "oldText must not be empty in #{path}." if total_edits == 1
|
|
204
|
+
|
|
205
|
+
"edits[#{edit_index}].oldText must not be empty in #{path}."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def no_change_error(path, total_edits)
|
|
209
|
+
if total_edits == 1
|
|
210
|
+
return "No changes made to #{path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected."
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
"No changes made to #{path}. The replacements produced identical content."
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require_relative "tool_utils"
|
|
3
|
+
|
|
4
|
+
class ReadTool < LlmGateway::Tool
|
|
5
|
+
# Pi adaptation notes:
|
|
6
|
+
# - Keep offset/limit schema as integer: gruv treats integer and number schemas equivalently for line counts.
|
|
7
|
+
# - Do not add pi's image resize/model-omission behavior: current LLMs allow larger images than pi's conservative limit, and gruv tools do not receive model capability context.
|
|
8
|
+
# - Do not add pi's compact read UI, pluggable operations, AbortSignal handling, or details metadata: those are UI/runtime extension concerns outside this tool contract.
|
|
9
|
+
name "read"
|
|
10
|
+
description "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to #{ToolUtils::DEFAULT_MAX_LINES} lines or #{ToolUtils::DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
|
|
11
|
+
input_schema({
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
path: { type: "string", description: "Path to the file to read (relative or absolute)" },
|
|
15
|
+
offset: { type: "integer", description: "Line number to start reading from (1-indexed)" },
|
|
16
|
+
limit: { type: "integer", description: "Maximum number of lines to read" }
|
|
17
|
+
},
|
|
18
|
+
required: [ "path" ]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
IMAGE_TYPE_SNIFF_BYTES = 4100
|
|
22
|
+
PNG_SIGNATURE = [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ].freeze
|
|
23
|
+
|
|
24
|
+
def execute(input)
|
|
25
|
+
path = input[:path] || input["path"]
|
|
26
|
+
offset = input[:offset] || input["offset"]
|
|
27
|
+
limit = input[:limit] || input["limit"]
|
|
28
|
+
|
|
29
|
+
absolute_path = ToolUtils.resolve_read_path(path)
|
|
30
|
+
|
|
31
|
+
return "File not found: #{path}" unless File.exist?(absolute_path)
|
|
32
|
+
return "Cannot read directory: #{path}" if File.directory?(absolute_path)
|
|
33
|
+
return "File is not readable: #{path}" unless File.readable?(absolute_path)
|
|
34
|
+
|
|
35
|
+
mime_type = detect_supported_image_mime_type_from_file(absolute_path)
|
|
36
|
+
if mime_type
|
|
37
|
+
data = Base64.strict_encode64(File.binread(absolute_path))
|
|
38
|
+
return [
|
|
39
|
+
{ type: "text", text: "Read image file [#{mime_type}]" },
|
|
40
|
+
{ type: "image", data: data, media_type: mime_type }
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
content = File.read(absolute_path, mode: "r:bom|utf-8")
|
|
45
|
+
all_lines = content.split("\n", -1)
|
|
46
|
+
total_file_lines = all_lines.length
|
|
47
|
+
|
|
48
|
+
start_line = [ 0, (offset || 1).to_i - 1 ].max
|
|
49
|
+
return "Offset #{offset} is beyond end of file (#{all_lines.length} lines total)" if start_line >= all_lines.length
|
|
50
|
+
|
|
51
|
+
selected_content = if limit
|
|
52
|
+
end_line = [ start_line + limit.to_i, all_lines.length ].min
|
|
53
|
+
all_lines[start_line...end_line].join("\n")
|
|
54
|
+
else
|
|
55
|
+
all_lines[start_line..].join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
truncation = ToolUtils.truncate_head(selected_content)
|
|
59
|
+
start_display = start_line + 1
|
|
60
|
+
|
|
61
|
+
if truncation[:first_line_exceeds_limit]
|
|
62
|
+
first_line_size = ToolUtils.format_size(all_lines[start_line].to_s.bytesize)
|
|
63
|
+
return "[Line #{start_display} is #{first_line_size}, exceeds #{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '#{start_display}p' #{path} | head -c #{ToolUtils::DEFAULT_MAX_BYTES}]"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
output = truncation[:content]
|
|
67
|
+
|
|
68
|
+
if truncation[:truncated]
|
|
69
|
+
end_display = start_display + truncation[:output_lines] - 1
|
|
70
|
+
next_offset = end_display + 1
|
|
71
|
+
suffix = if truncation[:truncated_by] == "lines"
|
|
72
|
+
"[Showing lines #{start_display}-#{end_display} of #{total_file_lines}. Use offset=#{next_offset} to continue.]"
|
|
73
|
+
else
|
|
74
|
+
"[Showing lines #{start_display}-#{end_display} of #{total_file_lines} (#{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit). Use offset=#{next_offset} to continue.]"
|
|
75
|
+
end
|
|
76
|
+
output = "#{output}\n\n#{suffix}"
|
|
77
|
+
elsif limit && (start_line + limit.to_i) < all_lines.length
|
|
78
|
+
next_offset = start_line + limit.to_i + 1
|
|
79
|
+
remaining = all_lines.length - (start_line + limit.to_i)
|
|
80
|
+
output = "#{output}\n\n[#{remaining} more lines in file. Use offset=#{next_offset} to continue.]"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
output
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
"Error reading file: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def detect_supported_image_mime_type_from_file(path)
|
|
91
|
+
detect_supported_image_mime_type(File.binread(path, IMAGE_TYPE_SNIFF_BYTES))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def detect_supported_image_mime_type(buffer)
|
|
95
|
+
bytes = buffer.bytes
|
|
96
|
+
return "image/jpeg" if jpeg?(bytes)
|
|
97
|
+
return "image/png" if png?(bytes) && !animated_png?(bytes)
|
|
98
|
+
return "image/gif" if ascii_at?(bytes, 0, "GIF")
|
|
99
|
+
return "image/webp" if ascii_at?(bytes, 0, "RIFF") && ascii_at?(bytes, 8, "WEBP")
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def jpeg?(bytes)
|
|
105
|
+
bytes.length >= 4 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff && bytes[3] != 0xf7
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def png?(bytes)
|
|
109
|
+
starts_with?(bytes, PNG_SIGNATURE) && bytes.length >= 16 && read_uint32_be(bytes, PNG_SIGNATURE.length) == 13 && ascii_at?(bytes, 12, "IHDR")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def animated_png?(bytes)
|
|
113
|
+
offset = PNG_SIGNATURE.length
|
|
114
|
+
while offset + 8 <= bytes.length
|
|
115
|
+
chunk_length = read_uint32_be(bytes, offset)
|
|
116
|
+
chunk_type_offset = offset + 4
|
|
117
|
+
return true if ascii_at?(bytes, chunk_type_offset, "acTL")
|
|
118
|
+
return false if ascii_at?(bytes, chunk_type_offset, "IDAT")
|
|
119
|
+
|
|
120
|
+
next_offset = offset + 8 + chunk_length + 4
|
|
121
|
+
return false if next_offset <= offset || next_offset > bytes.length
|
|
122
|
+
|
|
123
|
+
offset = next_offset
|
|
124
|
+
end
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def read_uint32_be(bytes, offset)
|
|
129
|
+
((bytes[offset] || 0) << 24) + ((bytes[offset + 1] || 0) << 16) + ((bytes[offset + 2] || 0) << 8) + (bytes[offset + 3] || 0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def starts_with?(bytes, prefix)
|
|
133
|
+
return false if bytes.length < prefix.length
|
|
134
|
+
|
|
135
|
+
prefix.each_with_index.all? { |byte, index| bytes[index] == byte }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ascii_at?(bytes, offset, text)
|
|
139
|
+
return false if bytes.length < offset + text.length
|
|
140
|
+
|
|
141
|
+
text.bytes.each_with_index.all? { |byte, index| bytes[offset + index] == byte }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "thread"
|
|
3
|
+
|
|
4
|
+
module ToolUtils
|
|
5
|
+
DEFAULT_MAX_LINES = 2000
|
|
6
|
+
DEFAULT_MAX_BYTES = 50 * 1024
|
|
7
|
+
|
|
8
|
+
@file_mutation_locks = Hash.new { |hash, key| hash[key] = Mutex.new }
|
|
9
|
+
@file_mutation_locks_mutex = Mutex.new
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def with_file_mutation_lock(path)
|
|
14
|
+
lock = @file_mutation_locks_mutex.synchronize { @file_mutation_locks[path] }
|
|
15
|
+
lock.synchronize { yield }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def format_size(bytes)
|
|
19
|
+
return "#{bytes}B" if bytes < 1024
|
|
20
|
+
return format("%.1fKB", bytes / 1024.0) if bytes < 1024 * 1024
|
|
21
|
+
|
|
22
|
+
format("%.1fMB", bytes / (1024.0 * 1024.0))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expand_path(file_path)
|
|
26
|
+
normalized = file_path.to_s.sub(/^@/, "").gsub(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/, " ")
|
|
27
|
+
return Dir.home if normalized == "~"
|
|
28
|
+
return File.join(Dir.home, normalized[2..]) if normalized.start_with?("~/")
|
|
29
|
+
|
|
30
|
+
normalized
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve_to_cwd(file_path, cwd = Dir.pwd)
|
|
34
|
+
expanded = expand_path(file_path)
|
|
35
|
+
Pathname.new(expanded).absolute? ? expanded : File.expand_path(expanded, cwd)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve_read_path(file_path, cwd = Dir.pwd)
|
|
39
|
+
resolved = resolve_to_cwd(file_path, cwd)
|
|
40
|
+
return resolved if File.exist?(resolved)
|
|
41
|
+
|
|
42
|
+
am_pm_variant = resolved.gsub(/ (AM|PM)\./i) { "\u202F#{Regexp.last_match(1)}." }
|
|
43
|
+
return am_pm_variant if File.exist?(am_pm_variant)
|
|
44
|
+
|
|
45
|
+
nfd_variant = resolved.unicode_normalize(:nfd)
|
|
46
|
+
return nfd_variant if File.exist?(nfd_variant)
|
|
47
|
+
|
|
48
|
+
curly_variant = resolved.tr("'", "\u2019")
|
|
49
|
+
return curly_variant if File.exist?(curly_variant)
|
|
50
|
+
|
|
51
|
+
nfd_curly_variant = nfd_variant.tr("'", "\u2019")
|
|
52
|
+
return nfd_curly_variant if File.exist?(nfd_curly_variant)
|
|
53
|
+
|
|
54
|
+
resolved
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def truncate_head(content, max_lines: DEFAULT_MAX_LINES, max_bytes: DEFAULT_MAX_BYTES)
|
|
58
|
+
lines = split_lines_for_counting(content)
|
|
59
|
+
total_lines = lines.length
|
|
60
|
+
total_bytes = content.bytesize
|
|
61
|
+
|
|
62
|
+
if total_lines <= max_lines && total_bytes <= max_bytes
|
|
63
|
+
return truncation_result(content, false, nil, total_lines, total_bytes, total_lines, total_bytes, false, false, max_lines, max_bytes)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
first_line_bytes = lines.first.to_s.bytesize
|
|
67
|
+
if first_line_bytes > max_bytes
|
|
68
|
+
return truncation_result("", true, "bytes", total_lines, total_bytes, 0, 0, false, true, max_lines, max_bytes)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
out_lines = []
|
|
72
|
+
out_bytes = 0
|
|
73
|
+
truncated_by = "lines"
|
|
74
|
+
|
|
75
|
+
lines.each_with_index do |line, index|
|
|
76
|
+
break if index >= max_lines
|
|
77
|
+
|
|
78
|
+
line_bytes = line.bytesize + (index.positive? ? 1 : 0)
|
|
79
|
+
if out_bytes + line_bytes > max_bytes
|
|
80
|
+
truncated_by = "bytes"
|
|
81
|
+
break
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
out_lines << line
|
|
85
|
+
out_bytes += line_bytes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
output = out_lines.join("\n")
|
|
89
|
+
truncation_result(output, true, truncated_by, total_lines, total_bytes, out_lines.length, output.bytesize, false, false, max_lines, max_bytes)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def truncate_tail(content, max_lines: DEFAULT_MAX_LINES, max_bytes: DEFAULT_MAX_BYTES)
|
|
93
|
+
lines = split_lines_for_counting(content)
|
|
94
|
+
total_lines = lines.length
|
|
95
|
+
total_bytes = content.bytesize
|
|
96
|
+
|
|
97
|
+
if total_lines <= max_lines && total_bytes <= max_bytes
|
|
98
|
+
return truncation_result(content, false, nil, total_lines, total_bytes, total_lines, total_bytes, false, false, max_lines, max_bytes)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
out_lines = []
|
|
102
|
+
out_bytes = 0
|
|
103
|
+
truncated_by = "lines"
|
|
104
|
+
last_line_partial = false
|
|
105
|
+
|
|
106
|
+
(lines.length - 1).downto(0) do |i|
|
|
107
|
+
break if out_lines.length >= max_lines
|
|
108
|
+
|
|
109
|
+
line = lines[i]
|
|
110
|
+
line_bytes = line.bytesize + (out_lines.empty? ? 0 : 1)
|
|
111
|
+
|
|
112
|
+
if out_bytes + line_bytes > max_bytes
|
|
113
|
+
truncated_by = "bytes"
|
|
114
|
+
if out_lines.empty?
|
|
115
|
+
out_lines.unshift(truncate_string_to_bytes_from_end(line, max_bytes))
|
|
116
|
+
out_bytes = out_lines.first.bytesize
|
|
117
|
+
last_line_partial = true
|
|
118
|
+
end
|
|
119
|
+
break
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
out_lines.unshift(line)
|
|
123
|
+
out_bytes += line_bytes
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
output = out_lines.join("\n")
|
|
127
|
+
truncation_result(output, true, truncated_by, total_lines, total_bytes, out_lines.length, output.bytesize, last_line_partial, false, max_lines, max_bytes)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def split_lines_for_counting(content)
|
|
131
|
+
return [] if content.empty?
|
|
132
|
+
|
|
133
|
+
lines = content.split("\n", -1)
|
|
134
|
+
lines.pop if content.end_with?("\n")
|
|
135
|
+
lines
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def truncation_result(content, truncated, truncated_by, total_lines, total_bytes, output_lines, output_bytes, last_line_partial, first_line_exceeds_limit, max_lines, max_bytes)
|
|
139
|
+
{
|
|
140
|
+
content: content,
|
|
141
|
+
truncated: truncated,
|
|
142
|
+
truncated_by: truncated_by,
|
|
143
|
+
total_lines: total_lines,
|
|
144
|
+
total_bytes: total_bytes,
|
|
145
|
+
output_lines: output_lines,
|
|
146
|
+
output_bytes: output_bytes,
|
|
147
|
+
last_line_partial: last_line_partial,
|
|
148
|
+
first_line_exceeds_limit: first_line_exceeds_limit,
|
|
149
|
+
max_lines: max_lines,
|
|
150
|
+
max_bytes: max_bytes
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def truncate_string_to_bytes_from_end(str, max_bytes)
|
|
155
|
+
bytes = str.dup.force_encoding("UTF-8").bytes
|
|
156
|
+
return str if bytes.length <= max_bytes
|
|
157
|
+
|
|
158
|
+
tail = bytes.last(max_bytes).pack("C*")
|
|
159
|
+
until tail.valid_encoding?
|
|
160
|
+
tail = tail.bytes.drop(1).pack("C*")
|
|
161
|
+
end
|
|
162
|
+
tail
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "tool_utils"
|
|
3
|
+
|
|
4
|
+
class WriteTool < LlmGateway::Tool
|
|
5
|
+
# Pi adaptation notes:
|
|
6
|
+
# - Keep Ruby bytesize in the success message rather than pi's JS string length; the byte count is more accurate for UTF-8 content.
|
|
7
|
+
# - Do not add pi's pluggable operations, AbortSignal handling, render previews, or details metadata: those are UI/runtime extension concerns outside this tool contract.
|
|
8
|
+
name "write"
|
|
9
|
+
description "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
|
|
10
|
+
input_schema({
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
path: { type: "string", description: "Path to the file to write (relative or absolute)" },
|
|
14
|
+
content: { type: "string", description: "Content to write to the file" }
|
|
15
|
+
},
|
|
16
|
+
required: [ "path", "content" ]
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
def execute(input)
|
|
20
|
+
path = input[:path] || input["path"]
|
|
21
|
+
content = input[:content] || input["content"]
|
|
22
|
+
|
|
23
|
+
absolute_path = ToolUtils.resolve_to_cwd(path)
|
|
24
|
+
|
|
25
|
+
ToolUtils.with_file_mutation_lock(absolute_path) do
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
|
27
|
+
File.write(absolute_path, content)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
"Successfully wrote #{content.bytesize} bytes to #{path}"
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
e.message
|
|
33
|
+
end
|
|
34
|
+
end
|