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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +255 -1
  4. data/docs/migration_guide_0.7.0.md +193 -0
  5. data/lib/llm_gateway/adapters/adapter.rb +1 -1
  6. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  7. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
  8. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  9. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  11. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
  12. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  13. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  14. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  15. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
  16. data/lib/llm_gateway/adapters/structs.rb +45 -10
  17. data/lib/llm_gateway/agents/event.rb +105 -0
  18. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  19. data/lib/llm_gateway/agents/harness.rb +176 -0
  20. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  21. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  22. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  23. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  24. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  25. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  26. data/lib/llm_gateway/base_client.rb +3 -3
  27. data/lib/llm_gateway/clients/anthropic.rb +5 -5
  28. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  29. data/lib/llm_gateway/clients/openai.rb +2 -2
  30. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  31. data/lib/llm_gateway/prompt.rb +105 -68
  32. data/lib/llm_gateway/utils.rb +116 -13
  33. data/lib/llm_gateway/version.rb +1 -1
  34. data/lib/llm_gateway.rb +4 -0
  35. 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