llm_gateway 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +350 -43
  4. data/docs/migration_guide_0.6.0.md +386 -0
  5. data/docs/migration_guide_0.7.0.md +193 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +8 -11
  7. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  8. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +61 -11
  9. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  11. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  12. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +132 -39
  13. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  14. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +40 -16
  15. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  16. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  17. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +173 -24
  18. data/lib/llm_gateway/adapters/stream_mapper.rb +9 -2
  19. data/lib/llm_gateway/adapters/structs.rb +140 -55
  20. data/lib/llm_gateway/agents/event.rb +105 -0
  21. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  22. data/lib/llm_gateway/agents/harness.rb +176 -0
  23. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  24. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  25. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  26. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  27. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  28. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  29. data/lib/llm_gateway/base_client.rb +5 -7
  30. data/lib/llm_gateway/clients/anthropic.rb +10 -9
  31. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  32. data/lib/llm_gateway/clients/groq.rb +8 -6
  33. data/lib/llm_gateway/clients/openai.rb +22 -20
  34. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  35. data/lib/llm_gateway/prompt.rb +107 -52
  36. data/lib/llm_gateway/utils.rb +116 -13
  37. data/lib/llm_gateway/version.rb +1 -1
  38. data/lib/llm_gateway.rb +7 -21
  39. metadata +13 -2
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module LlmGateway
7
+ module Agents
8
+ class InMemorySessionManager
9
+ MESSAGE_QUEUED = :queued
10
+ MESSAGE_STARTED = :started
11
+ QUEUES = [ :steer, :follow_up, :next_turn ].freeze
12
+ DRAIN_MODES = [ :one_at_a_time, :all ].freeze
13
+
14
+ attr_reader :session_id, :session_start
15
+
16
+ def initialize(session_id = nil)
17
+ @state = :idle
18
+ @session_id = session_id
19
+ @message_queues = Hash.new { |hash, key| hash[key] = [] }
20
+ end
21
+
22
+ def busy!
23
+ @state = :busy
24
+ end
25
+
26
+ def idle!
27
+ @state = :idle
28
+ end
29
+
30
+ def drain_message_queue(queue = :next_turn, mode: :all)
31
+ messages = queued_messages(queue, mode)
32
+ messages.each { |message| push_message(message) }
33
+ messages
34
+ end
35
+
36
+ def queued_messages?(queue = :next_turn)
37
+ @message_queues[validate_queue!(queue)].any?
38
+ end
39
+
40
+ def push_message_to_queue(message, queue = :next_turn)
41
+ @message_queues[validate_queue!(queue)] << message
42
+ end
43
+
44
+ def busy?
45
+ @state == :busy
46
+ end
47
+
48
+ def validate_queue!(queue)
49
+ queue = queue.to_sym
50
+ raise ArgumentError, "Invalid queue mode: #{queue}" unless QUEUES.include?(queue)
51
+
52
+ queue
53
+ end
54
+
55
+ def validate_drain_mode!(mode)
56
+ mode = mode.to_sym
57
+ raise ArgumentError, "Invalid queue drain mode: #{mode}" unless DRAIN_MODES.include?(mode)
58
+
59
+ mode
60
+ end
61
+
62
+ def start_or_enqueue_user_message(payload, queue: :next_turn)
63
+ if busy?
64
+ push_message_to_queue(payload, queue)
65
+ MESSAGE_QUEUED
66
+ else
67
+ yield if block_given?
68
+ push_message(payload)
69
+ busy!
70
+ MESSAGE_STARTED
71
+ end
72
+ end
73
+
74
+ def push_message(payload)
75
+ payload = payload.deep_symbolize_keys
76
+
77
+ push_entry(
78
+ type: "message",
79
+ usage: message_usage(payload),
80
+ data: payload,
81
+ )
82
+ end
83
+
84
+ def push_entry(entry)
85
+ id = SecureRandom.uuid
86
+ new_entry = {
87
+ id: id,
88
+ parent_id: parent_id_for_new_entry,
89
+ timestamp: Time.now.iso8601,
90
+ **entry
91
+ }
92
+
93
+ persist_entry(new_entry)
94
+ new_entry
95
+ end
96
+
97
+ def active_messages
98
+ active_message_events.map { |event| event[:data] }
99
+ end
100
+
101
+ def last_message_id
102
+ message_events.last&.dig(:id)
103
+ end
104
+
105
+ def last_model_used
106
+ events.reverse.find { |event| event[:type] == "model_change" }&.dig(:model_id)
107
+ end
108
+
109
+ def last_reasoning_level_used
110
+ events.reverse.find { |event| event[:type] == "reasoning_change" }&.dig(:reasoning)
111
+ end
112
+
113
+ def events_until(event_id)
114
+ index = events.index { |event| event[:id] == event_id }
115
+ raise ArgumentError, "Event not found in session: #{event_id}" unless index
116
+
117
+ events[0..index]
118
+ end
119
+
120
+ def events
121
+ @events ||= [ new_session_event ]
122
+ end
123
+
124
+ def build_model_input_messages
125
+ return active_messages unless last_compaction_entry
126
+
127
+ [ last_compaction_entry[:data], *active_messages ]
128
+ end
129
+
130
+ def total_tokens
131
+ entry = active_message_events.reverse.find { |event| event.dig(:usage, :total_tokens) }
132
+ entry&.dig(:usage, :total_tokens) || 0
133
+ end
134
+
135
+ def last_assistant_message_at
136
+ entry = active_message_events.reverse.find { |event| event.dig(:data, :role) == "assistant" }
137
+ Time.parse(entry[:timestamp]) if entry
138
+ end
139
+
140
+ def compaction(adapter)
141
+ response = adapter.stream(
142
+ active_messages,
143
+ system: "Summarize the conversation so far for future context.",
144
+ tools: []
145
+ )
146
+ message = response.to_h
147
+
148
+ push_entry(
149
+ type: "compaction",
150
+ usage: message_usage(message),
151
+ data: message
152
+ )
153
+ end
154
+
155
+ private
156
+
157
+ def queued_messages(queue, mode)
158
+ queue = validate_queue!(queue)
159
+ case validate_drain_mode!(mode)
160
+ when :one_at_a_time
161
+ message = @message_queues[queue].shift
162
+ message ? [ message ] : []
163
+ when :all
164
+ @message_queues[queue].shift(@message_queues[queue].length)
165
+ end
166
+ end
167
+
168
+ def parent_id_for_new_entry
169
+ events.length.positive? ? events.last[:id] : nil
170
+ end
171
+
172
+ def message_events
173
+ events.select { |event| event[:type] == "message" }
174
+ end
175
+
176
+ def active_message_events
177
+ compaction_event = last_compaction_entry
178
+ return message_events unless compaction_event
179
+
180
+ compaction_index = events.index(compaction_event)
181
+ events[(compaction_index + 1)..].select { |event| event[:type] == "message" }
182
+ end
183
+
184
+ def last_compaction_entry
185
+ events.reverse.find { |event| event[:type] == "compaction" }
186
+ end
187
+
188
+ def message_usage(message)
189
+ usage = message[:usage] || message["usage"]
190
+ return {} unless usage
191
+
192
+ usage.transform_keys(&:to_sym)
193
+ end
194
+
195
+ def persist_entry(entry)
196
+ attributes = {
197
+ session_id: @session_id,
198
+ position: next_position,
199
+ id: entry[:id],
200
+ parent_id: entry[:parent_id],
201
+ timestamp: entry[:timestamp],
202
+ type: entry[:type],
203
+ usage: entry[:usage],
204
+ data: entry[:data]
205
+ }
206
+
207
+ events << entry
208
+ attributes
209
+ end
210
+
211
+ def next_position
212
+ events.length
213
+ end
214
+
215
+ def new_session_event
216
+ @session_id ||= SecureRandom.uuid
217
+ @session_start = Time.now.strftime("%Y%m%d_%H%M%S")
218
+ { type: "session", id: session_id, timestamp: session_start }
219
+ end
220
+ end
221
+ end
222
+ end
@@ -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