From 572e9d5a56ff1eed400f0c82d7620f5fa745df42 Mon Sep 17 00:00:00 2001 From: "managerbot-app[bot]" Date: Sat, 6 Jun 2026 13:57:08 +0000 Subject: [PATCH] Upgrade llm_gateway to latest version --- Gemfile | 2 +- Gemfile.lock | 6 +- README.md | 26 ++-- agents/sessions/agent_session.rb | 4 +- agents/sessions/base_session_manager.rb | 32 +++- agents/sessions/compaction_prompt.rb | 7 +- agents/sessions/concerns/basic_compaction.rb | 9 +- agents/sessions/usage_normalizer.rb | 73 --------- config/runtime_config.rb | 11 +- lib/agents/agent.rb | 73 ++++----- lib/agents/agent_events.rb | 25 +--- lib/agents/gruv/tools/sql_tool.rb | 2 +- lib/agents/tools.rb | 4 +- lib/agents/tools/bash_tool.rb | 69 --------- lib/agents/tools/edit_tool.rb | 56 ------- lib/agents/tools/read_tool.rb | 88 ----------- lib/agents/tools/tool_utils.rb | 147 ------------------- lib/agents/tools/write_tool.rb | 28 ---- lib/format_stream.rb | 2 +- lib/provider_auth_helper.rb | 15 +- run_agent.rb | 15 +- scripts/clone_task_worker.rb | 5 +- setup.rb | 2 +- setup_provider.rb | 2 +- test/sessions/file_session_manager_test.rb | 10 +- 25 files changed, 130 insertions(+), 583 deletions(-) delete mode 100644 agents/sessions/usage_normalizer.rb delete mode 100644 lib/agents/tools/bash_tool.rb delete mode 100644 lib/agents/tools/edit_tool.rb delete mode 100644 lib/agents/tools/read_tool.rb delete mode 100644 lib/agents/tools/tool_utils.rb delete mode 100644 lib/agents/tools/write_tool.rb diff --git a/Gemfile b/Gemfile index a47af77..9fca0f8 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' ruby '4.0.1' gem 'base64' -gem 'llm_gateway', github: 'Hyper-Unearthing/llm_gateway', branch: 'refactor/major-internal-organisation' +gem 'llm_gateway', github: 'Hyper-Unearthing/llm_gateway', tag: 'v0.8.0' gem 'singleton' gem 'activerecord' gem 'sqlite3' diff --git a/Gemfile.lock b/Gemfile.lock index 38eb799..eb46951 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/Hyper-Unearthing/llm_gateway.git - revision: a9b8eff24b34c80f957fa57ebdf0d34fe52d4a6f - branch: refactor/major-internal-organisation + revision: 838d670daecb90bb79115e737f41d18aae2c6e73 + tag: v0.8.0 specs: - llm_gateway (0.3.0) + llm_gateway (0.8.0) dry-struct GEM diff --git a/README.md b/README.md index 2dc83e5..288883f 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,19 @@ cd gruv Using OpenAI OAuth Codex ```bash ruby setup_provider.rb openai -./gruv -p openai_oauth_codex +./gruv -p openai_codex -m gpt-5.4 ``` Using Anthropic provider (OAuth from auth file if present, otherwise API key env var) ```bash ruby setup_provider.rb anthropic -./gruv -p anthropic_apikey_messages +./gruv -p anthropic_messages -m claude-sonnet-4-20250514 ``` Using OpenAI API key providers ```bash -OPENAI_API_KEY=... ./gruv -p openai_apikey_completions -OPENAI_API_KEY=... ./gruv -p openai_apikey_responses +OPENAI_API_KEY=... ./gruv -p openai_completions -m gpt-5.1 +OPENAI_API_KEY=... ./gruv -p openai_responses -m gpt-5.4 ``` `-p` maps directly to llm_gateway provider keys. Use `-m`/`--model` for model selection. @@ -32,25 +32,25 @@ OAuth auth is read from `~/.config/gruv/auth.json`. Stream test provider/model combinations: ```bash -# openai_apikey_completions_gpt_5_1 -OPENAI_API_KEY=... ./gruv -p openai_apikey_completions -m gpt-5.1 +# openai_completions_gpt_5_1 +OPENAI_API_KEY=... ./gruv -p openai_completions -m gpt-5.1 -# anthropic_apikey_messages_claude_sonnet_4 +# anthropic_messages_claude_sonnet_4 # (uses anthropic OAuth token from auth.json if present, else ANTHROPIC_API_KEY) -ANTHROPIC_API_KEY=... ./gruv -p anthropic_apikey_messages -m claude-sonnet-4-20250514 +ANTHROPIC_API_KEY=... ./gruv -p anthropic_messages -m claude-sonnet-4-20250514 -# openai_apikey_responses_gpt_5_4 -OPENAI_API_KEY=... ./gruv -p openai_apikey_responses -m gpt-5.4 +# openai_responses_gpt_5_4 +OPENAI_API_KEY=... ./gruv -p openai_responses -m gpt-5.4 -# openai_oauth_codex_gpt_5_4 +# openai_codex_gpt_5_4 ruby setup_provider.rb openai -./gruv -p openai_oauth_codex -m gpt-5.4 +./gruv -p openai_codex -m gpt-5.4 ``` Run the unified setup wizard (can be run multiple times safely): ```bash bundle exec ruby setup.rb -./gruv -p openai_oauth_codex -m gpt-5.4 +./gruv -p openai_codex -m gpt-5.4 ``` The wizard lets you pick which things to configure: diff --git a/agents/sessions/agent_session.rb b/agents/sessions/agent_session.rb index 63d7b1f..ac53962 100644 --- a/agents/sessions/agent_session.rb +++ b/agents/sessions/agent_session.rb @@ -31,7 +31,7 @@ def continue(&event_handler) end def compact - session_manager.compaction(@agent.client) + session_manager.compaction(@agent.client, model: @agent.model || RuntimeConfig.model) @agent.transcript = model_input_messages end @@ -48,7 +48,7 @@ def handle_agent_event(event) assistant_message = event.message.to_h @session_manager.push_message(assistant_message) when Agent::Event::TurnEnd - tool_result_content = event.tool_results.flat_map { |tool_result| tool_result.to_h[:content] } + tool_result_content = event.tool_results.map(&:to_h) if tool_result_content.any? tool_result_message = { role: 'user', diff --git a/agents/sessions/base_session_manager.rb b/agents/sessions/base_session_manager.rb index 7ef1b4f..9fac014 100644 --- a/agents/sessions/base_session_manager.rb +++ b/agents/sessions/base_session_manager.rb @@ -1,7 +1,6 @@ require 'securerandom' require 'time' require_relative 'concerns/basic_compaction' -require_relative 'usage_normalizer' class BaseSessionManager include BasicCompaction @@ -60,8 +59,10 @@ def build_model_input_messages end def total_tokens - entry = message_events.reverse.find { |event| event.dig(:data, :usage, :total_tokens) } - entry&.dig(:data, :usage, :total_tokens) || 0 + entry = message_events.reverse.find { |event| usage_total(message_usage(event[:data])) } + return 0 unless entry + + usage_total(message_usage(entry[:data])) || 0 end private @@ -79,7 +80,30 @@ def last_compaction_entry end def message_usage(message) - UsageNormalizer.normalize(message[:usage]) + usage = message[:usage] || message['usage'] + return nil unless usage + + usage = usage.transform_keys(&:to_sym) + return usage if usage.key?(:total) + + input = usage[:input] || usage[:input_tokens] || usage[:prompt_tokens] || 0 + cache_read = usage[:cache_read] || usage[:cache_read_input_tokens] || 0 + cache_write = usage[:cache_write] || usage[:cache_creation_input_tokens] || 0 + output = usage[:output] || usage[:output_tokens] || usage[:completion_tokens] || 0 + total = usage[:total] || usage[:total_tokens] || (input + cache_read + cache_write + output) + + usage.merge( + input: input, + cache_read: cache_read, + cache_write: cache_write, + output: output, + total: total, + total_tokens: total + ) + end + + def usage_total(usage) + usage && (usage[:total] || usage[:total_tokens]) end def persist_entry(entry) diff --git a/agents/sessions/compaction_prompt.rb b/agents/sessions/compaction_prompt.rb index ebf6225..504d77a 100644 --- a/agents/sessions/compaction_prompt.rb +++ b/agents/sessions/compaction_prompt.rb @@ -18,9 +18,8 @@ class CompactionPrompt < LlmGateway::Prompt - Keep the summary concise and structured by topic. PROMPT - def initialize(client, messages, last_summary: nil) - super(client) - @client = client + def initialize(provider:, messages:, model: nil, last_summary: nil) + super(provider: provider, model: model) @messages = messages @last_summary = last_summary end @@ -59,7 +58,7 @@ def system_prompt end def post(&block) - @client.stream( + stream( prompt, tools: [], system: system_prompt, diff --git a/agents/sessions/concerns/basic_compaction.rb b/agents/sessions/concerns/basic_compaction.rb index bcba2f8..7352874 100644 --- a/agents/sessions/concerns/basic_compaction.rb +++ b/agents/sessions/concerns/basic_compaction.rb @@ -1,6 +1,11 @@ module BasicCompaction - def compaction(adapter) - result = CompactionPrompt.new(adapter, active_messages, last_summary: last_compaction_entry&.dig(:data, :summary)).post + def compaction(adapter, model: nil) + result = CompactionPrompt.new( + provider: adapter, + model: model, + messages: active_messages, + last_summary: last_compaction_entry&.dig(:data, :summary) + ).post content_blocks, usage = extract_compaction_content_and_usage(result) text_parts = content_blocks.filter_map do |part| diff --git a/agents/sessions/usage_normalizer.rb b/agents/sessions/usage_normalizer.rb deleted file mode 100644 index 9ac88b6..0000000 --- a/agents/sessions/usage_normalizer.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module UsageNormalizer - # Normalizes provider-specific usage hashes into a unified schema: - # - # input_tokens — non-cached input tokens billed at full price - # output_tokens — output tokens - # cache_read_tokens — tokens served from cache (cheaper) - # cache_write_tokens — tokens written to cache (Anthropic only, 0 for OpenAI) - # total_tokens — input + cache_read + cache_write + output - # - # Anthropic raw keys: input_tokens, cache_read_input_tokens, - # cache_creation_input_tokens, output_tokens - # - # OpenAI raw keys: input_tokens, input_tokens_details: { cached_tokens }, - # output_tokens, total_tokens - def self.normalize(usage) - return nil unless usage - - if usage.key?(:cache_read_tokens) || usage.key?(:cache_write_tokens) - normalize_unified(usage) - elsif usage.key?(:cache_read_input_tokens) || usage.key?(:cache_creation_input_tokens) - normalize_anthropic(usage) - else - normalize_openai(usage) - end - end - - def self.normalize_unified(usage) - input = usage[:input_tokens] || 0 - cache_read = usage[:cache_read_tokens] || 0 - cache_write = usage[:cache_write_tokens] || 0 - output = usage[:output_tokens] || 0 - - { - input_tokens: input, - output_tokens: output, - cache_read_tokens: cache_read, - cache_write_tokens: cache_write, - total_tokens: input + cache_read + cache_write + output - } - end - - def self.normalize_anthropic(usage) - input = usage[:input_tokens] || 0 - cache_read = usage[:cache_read_input_tokens] || 0 - cache_write = usage[:cache_creation_input_tokens] || 0 - output = usage[:output_tokens] || 0 - - { - input_tokens: input, - output_tokens: output, - cache_read_tokens: cache_read, - cache_write_tokens: cache_write, - total_tokens: input + cache_read + cache_write + output - } - end - - def self.normalize_openai(usage) - input_total = usage[:input_tokens] || 0 - cache_read = usage.dig(:input_tokens_details, :cached_tokens) || 0 - input = input_total - cache_read - output = usage[:output_tokens] || 0 - - { - input_tokens: input, - output_tokens: output, - cache_read_tokens: cache_read, - cache_write_tokens: 0, - total_tokens: input + cache_read + output - } - end -end diff --git a/config/runtime_config.rb b/config/runtime_config.rb index 7e117bb..ace4df2 100644 --- a/config/runtime_config.rb +++ b/config/runtime_config.rb @@ -2,11 +2,16 @@ module RuntimeConfig class << self - attr_accessor :provider_name, :model_key + attr_accessor :provider_name, :model - def set(provider_name:, model_key: nil) + def set(provider_name:, model: nil) @provider_name = provider_name - @model_key = model_key + @model = model + end + + # Backward-compatible reader for callers that still refer to model_key. + def model_key + model end end end diff --git a/lib/agents/agent.rb b/lib/agents/agent.rb index 30c0f77..990da82 100644 --- a/lib/agents/agent.rb +++ b/lib/agents/agent.rb @@ -3,30 +3,18 @@ class Agent < LlmGateway::Prompt attr_reader :client - attr_accessor :transcript, :cache_key, :cache_retention + attr_accessor :transcript - def initialize(client, transcript: []) - super(client.client.model_key) + def initialize(client, model: RuntimeConfig.model) + super(provider: client, model: model, reasoning: 'high') @client = client - @transcript = transcript + @transcript = [] end def prompt transcript end - def self.tools - self::TOOLS - end - - def self.find_tool(name) - tools.find { |tool| tool.tool_name == name } - end - - def tools - self.class.tools.map(&:definition) - end - def run(user_input, &block) append_user_message([user_input]) continue(&block) @@ -39,16 +27,18 @@ def continue(&block) send_and_process(messages:, &block) end - def post(&block) - stream_options = { + def stream_options + { tools: tools, system: system_prompt, - reasoning: 'high' - } - - stream_options[:cache_key] = cache_key if cache_key - stream_options[:cache_retention] = cache_retention if cache_retention + reasoning: reasoning, + model: model, + cache_key: cache_key, + cache_retention: cache_retention + }.compact + end + def post(&block) @client.stream( prompt, **stream_options, @@ -75,13 +65,20 @@ def send_and_process(messages:, &block) tool_results = result.content.select { |message| message.type == 'tool_use' }.map do |message| parameters = message.to_h emit(Event::ToolExecutionStart.new(parameters: parameters), &block) - tool_result = execute_tool(message) - emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result.to_h), &block) - transcript << tool_result.to_h - messages.concat([tool_result]) + tool_result = execute_tool_request(message) + emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result), &block) tool_result end + if tool_results.any? + tool_result_message = { + role: 'user', + content: tool_results.map(&:to_h) + } + transcript << tool_result_message + messages << tool_result_message + end + turn_end_event = Event::TurnEnd.new(message: assistant_message, tool_results: tool_results) emit(turn_end_event, &block) @@ -103,26 +100,8 @@ def emit(event, &block) block.call(event) end - def execute_tool(tool_request) - tool_name = tool_request.name - tool_input = tool_request.input - tool_class = self.class.find_tool(tool_name) - - result = begin - if tool_class - tool_class.new.execute(tool_input) - else - "Unknown tool: #{tool_name}" - end - rescue StandardError => e - "Error executing tool: #{e.message}" - end - - Event::ToolCallResult.new( - type: 'tool_result', - tool_use_id: tool_request.id, - content: result - ) + def execute_tool_request(tool_request) + find_and_execute_tool(tool_request) end end diff --git a/lib/agents/agent_events.rb b/lib/agents/agent_events.rb index dbe3219..01f6472 100644 --- a/lib/agents/agent_events.rb +++ b/lib/agents/agent_events.rb @@ -19,9 +19,11 @@ class Base < BaseStruct class Stream < Base StreamEventType = Types.Instance(AssistantStreamMessageEvent) | + Types.Instance(AssistantStreamMessageEndEvent) | Types.Instance(AssistantStreamReasoningEvent) | Types.Instance(AssistantStreamEvent) | - Types.Instance(AssistantToolStartEvent) + Types.Instance(AssistantToolStartEvent) | + Types.Instance(AssistantToolResultStartEvent) attribute :stream_event, StreamEventType end @@ -30,21 +32,6 @@ class Message < Base attribute :message, Types.Instance(AssistantMessage) end - class ToolCallResult < BaseStruct - attribute :type, Types::String.default('tool_result'.freeze).enum('tool_result') - attribute :tool_use_id, Types::String - attribute(:content, Types::Array.of(Types::Any).constructor { |value| value.is_a?(Array) ? value : [value] }) - - def to_h - { role: 'user', content: - [{ - type: type, - tool_use_id: tool_use_id, - content: content - }] } - end - end - class MessageUpdate < Stream attribute :type, Types::Coercible::Symbol.default(:message_update) end @@ -61,16 +48,16 @@ class ToolExecutionStart < Base class ToolExecutionEnd < Base attribute :type, Types::Coercible::Symbol.default(:tool_execution_end) attribute :parameters, Types::Hash - attribute :result, Types::Hash + attribute :result, Types.Instance(::ToolResult) end class TurnEnd < Message attribute :type, Types::Coercible::Symbol.default(:turn_end) - attribute :tool_results, Types::Array.of(Types.Instance(ToolCallResult)) + attribute :tool_results, Types::Array.of(Types.Instance(::ToolResult)) end class AgentEnd < Base - MessageType = Types.Instance(AssistantMessage) | Types.Instance(ToolCallResult) + MessageType = Types.Instance(AssistantMessage) | Types.Instance(::ToolResult) | Types::Hash attribute :type, Types::Coercible::Symbol.default(:agent_end) attribute :messages, Types::Array.of(MessageType) diff --git a/lib/agents/gruv/tools/sql_tool.rb b/lib/agents/gruv/tools/sql_tool.rb index fc47c93..c7ca454 100644 --- a/lib/agents/gruv/tools/sql_tool.rb +++ b/lib/agents/gruv/tools/sql_tool.rb @@ -1,6 +1,6 @@ require 'json' require 'active_record' -require_relative '../../tools/tool_utils' +require 'llm_gateway/agents/tools/tool_utils' require_relative '../../../db/database_config' class SqlTool < LlmGateway::Tool diff --git a/lib/agents/tools.rb b/lib/agents/tools.rb index 72a6497..26aed24 100644 --- a/lib/agents/tools.rb +++ b/lib/agents/tools.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true Dir[File.join(__dir__, '..', '..', 'modes', '*_writer.rb')].sort.each { |path| require path } -Dir[File.join(__dir__, 'tools', '*.rb')].sort.each { |path| require path } +%w[bash read write edit].each do |tool| + require "llm_gateway/agents/tools/#{tool}_tool" +end Dir[File.join(__dir__, 'gruv', 'tools', '*.rb')].sort.each { |path| require path } Dir[File.join(__dir__, 'coding_agent', 'tools', '*.rb')].sort.each { |path| require path } Dir[File.join(__dir__, 'clone_agent', 'tools', '*.rb')].sort.each { |path| require path } diff --git a/lib/agents/tools/bash_tool.rb b/lib/agents/tools/bash_tool.rb deleted file mode 100644 index f870105..0000000 --- a/lib/agents/tools/bash_tool.rb +++ /dev/null @@ -1,69 +0,0 @@ -require 'open3' -require 'securerandom' -require 'tmpdir' -require 'timeout' -require_relative 'tool_utils' - -class BashTool < LlmGateway::Tool - name 'bash' - 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." - input_schema({ - type: 'object', - properties: { - command: { type: 'string', description: 'Bash command to execute' }, - timeout: { type: 'integer', description: 'Timeout in seconds (optional, no default timeout)' } - }, - required: ['command'] - }) - - def execute(input) - command = input[:command] - timeout = input[:timeout] - - output = '' - exit_status = nil - - runner = proc do - Open3.popen2e(command, chdir: Dir.pwd) do |_stdin, stdout_err, wait_thr| - output = stdout_err.read.to_s - exit_status = wait_thr.value.exitstatus - end - end - - if timeout && timeout.to_i.positive? - Timeout.timeout(timeout.to_i, &runner) - else - runner.call - end - - truncation = ToolUtils.truncate_tail(output) - out = truncation[:content].to_s - out = '(no output)' if out.empty? - - if truncation[:truncated] - temp_path = File.join(Dir.tmpdir, "pi-bash-#{SecureRandom.hex(8)}.log") - File.write(temp_path, output) - - start_line = truncation[:total_lines] - truncation[:output_lines] + 1 - end_line = truncation[:total_lines] - - notice = if truncation[:last_line_partial] - last_line = output.split("\n", -1).last.to_s - "[Showing last #{ToolUtils.format_size(truncation[:output_bytes])} of line #{end_line} (line is #{ToolUtils.format_size(last_line.bytesize)}). Full output: #{temp_path}]" - elsif truncation[:truncated_by] == 'lines' - "[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]}. Full output: #{temp_path}]" - else - "[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]} (#{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit). Full output: #{temp_path}]" - end - - out = "#{out}\n\n#{notice}" - end - - out = "#{out}\n\nCommand exited with code #{exit_status}" if exit_status && exit_status != 0 - out - rescue Timeout::Error - "Command timed out after #{timeout} seconds" - rescue StandardError => e - "Error executing command: #{e.message}" - end -end diff --git a/lib/agents/tools/edit_tool.rb b/lib/agents/tools/edit_tool.rb deleted file mode 100644 index b5c18fa..0000000 --- a/lib/agents/tools/edit_tool.rb +++ /dev/null @@ -1,56 +0,0 @@ -require_relative 'tool_utils' - -class EditTool < LlmGateway::Tool - name 'edit' - description 'Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.' - input_schema({ - type: 'object', - properties: { - path: { type: 'string', description: 'Path to the file to edit (relative or absolute)' }, - oldText: { type: 'string', description: 'Exact text to find and replace (must match exactly)' }, - newText: { type: 'string', description: 'New text to replace the old text with' } - }, - required: ['path', 'oldText', 'newText'] - }) - - def execute(input) - path = input[:path] || input['path'] - old_text = input[:oldText] || input['oldText'] - new_text = input[:newText] || input['newText'] - - absolute_path = ToolUtils.resolve_to_cwd(path) - - return "File not found: #{path}" unless File.exist?(absolute_path) - return "Cannot edit directory: #{path}" if File.directory?(absolute_path) - return "File is not writable: #{path}" unless File.writable?(absolute_path) - - raw_content = File.binread(absolute_path) - - bom = raw_content.start_with?("\xEF\xBB\xBF".b) ? "\xEF\xBB\xBF".b : ''.b - content_without_bom = bom.empty? ? raw_content : raw_content.byteslice(3..) - content_utf8 = content_without_bom.force_encoding('UTF-8') - - original_ending = content_utf8.include?("\r\n") ? "\r\n" : "\n" - normalized_content = content_utf8.gsub("\r\n", "\n") - normalized_old = old_text.to_s.gsub("\r\n", "\n") - normalized_new = new_text.to_s.gsub("\r\n", "\n") - - return "Could not find the exact text in #{path}. The old text must match exactly including all whitespace and newlines." unless normalized_content.include?(normalized_old) - - occurrences = normalized_content.scan(Regexp.new(Regexp.escape(normalized_old))).length - if occurrences > 1 - return "Found #{occurrences} occurrences of the text in #{path}. The text must be unique. Please provide more context to make it unique." - end - - new_content = normalized_content.sub(normalized_old, normalized_new) - return "No changes made to #{path}. The replacement produced identical content." if new_content == normalized_content - - restored = original_ending == "\r\n" ? new_content.gsub("\n", "\r\n") : new_content - final_bytes = bom + restored.encode('UTF-8') - - File.binwrite(absolute_path, final_bytes) - "Successfully replaced text in #{path}." - rescue StandardError => e - "Error editing file: #{e.message}" - end -end diff --git a/lib/agents/tools/read_tool.rb b/lib/agents/tools/read_tool.rb deleted file mode 100644 index 76b6000..0000000 --- a/lib/agents/tools/read_tool.rb +++ /dev/null @@ -1,88 +0,0 @@ -require 'base64' -require_relative 'tool_utils' - -class ReadTool < LlmGateway::Tool - name 'read' - 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." - input_schema({ - type: 'object', - properties: { - path: { type: 'string', description: 'Path to the file to read (relative or absolute)' }, - offset: { type: 'integer', description: 'Line number to start reading from (1-indexed)' }, - limit: { type: 'integer', description: 'Maximum number of lines to read' } - }, - required: ['path'] - }) - - IMAGE_MIME_BY_EXT = { - '.jpg' => 'image/jpeg', - '.jpeg' => 'image/jpeg', - '.png' => 'image/png', - '.gif' => 'image/gif', - '.webp' => 'image/webp' - }.freeze - - def execute(input) - path = input[:path] || input['path'] - offset = input[:offset] || input['offset'] - limit = input[:limit] || input['limit'] - - absolute_path = ToolUtils.resolve_read_path(path) - - return "File not found: #{path}" unless File.exist?(absolute_path) - return "Cannot read directory: #{path}" if File.directory?(absolute_path) - return "File is not readable: #{path}" unless File.readable?(absolute_path) - - mime_type = IMAGE_MIME_BY_EXT[File.extname(absolute_path).downcase] - if mime_type - data = Base64.strict_encode64(File.binread(absolute_path)) - return [ - { type: 'text', text: "Read image file [#{mime_type}]" }, - { type: 'image', data: data, media_type: mime_type } - ] - end - - content = File.read(absolute_path, mode: 'r:bom|utf-8') - all_lines = content.split("\n", -1) - total_file_lines = all_lines.length - - start_line = [0, (offset || 1).to_i - 1].max - return "Offset #{offset} is beyond end of file (#{all_lines.length} lines total)" if start_line >= all_lines.length - - selected_content = if limit - end_line = [start_line + limit.to_i, all_lines.length].min - all_lines[start_line...end_line].join("\n") - else - all_lines[start_line..].join("\n") - end - - truncation = ToolUtils.truncate_head(selected_content) - start_display = start_line + 1 - - if truncation[:first_line_exceeds_limit] - first_line_size = ToolUtils.format_size(all_lines[start_line].to_s.bytesize) - 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}]" - end - - output = truncation[:content] - - if truncation[:truncated] - end_display = start_display + truncation[:output_lines] - 1 - next_offset = end_display + 1 - suffix = if truncation[:truncated_by] == 'lines' - "[Showing lines #{start_display}-#{end_display} of #{total_file_lines}. Use offset=#{next_offset} to continue.]" - else - "[Showing lines #{start_display}-#{end_display} of #{total_file_lines} (#{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit). Use offset=#{next_offset} to continue.]" - end - output = "#{output}\n\n#{suffix}" - elsif limit && (start_line + limit.to_i) < all_lines.length - next_offset = start_line + limit.to_i + 1 - remaining = all_lines.length - (start_line + limit.to_i) - output = "#{output}\n\n[#{remaining} more lines in file. Use offset=#{next_offset} to continue.]" - end - - output - rescue StandardError => e - "Error reading file: #{e.message}" - end -end diff --git a/lib/agents/tools/tool_utils.rb b/lib/agents/tools/tool_utils.rb deleted file mode 100644 index 0a8f8c2..0000000 --- a/lib/agents/tools/tool_utils.rb +++ /dev/null @@ -1,147 +0,0 @@ -require 'pathname' - -module ToolUtils - DEFAULT_MAX_LINES = 2000 - DEFAULT_MAX_BYTES = 50 * 1024 - - module_function - - def format_size(bytes) - return "#{bytes}B" if bytes < 1024 - return format('%.1fKB', bytes / 1024.0) if bytes < 1024 * 1024 - - format('%.1fMB', bytes / (1024.0 * 1024.0)) - end - - def expand_path(file_path) - normalized = file_path.to_s.sub(/^@/, '').tr("\u00A0", ' ') - return Dir.home if normalized == '~' - return File.join(Dir.home, normalized[2..]) if normalized.start_with?('~/') - - normalized - end - - def resolve_to_cwd(file_path, cwd = Dir.pwd) - expanded = expand_path(file_path) - Pathname.new(expanded).absolute? ? expanded : File.expand_path(expanded, cwd) - end - - def resolve_read_path(file_path, cwd = Dir.pwd) - resolved = resolve_to_cwd(file_path, cwd) - return resolved if File.exist?(resolved) - - am_pm_variant = resolved.gsub(/ (AM|PM)\./, "\u202F\\1.") - return am_pm_variant if File.exist?(am_pm_variant) - - nfd_variant = resolved.unicode_normalize(:nfd) - return nfd_variant if File.exist?(nfd_variant) - - curly_variant = resolved.tr("'", "\u2019") - return curly_variant if File.exist?(curly_variant) - - nfd_curly_variant = nfd_variant.tr("'", "\u2019") - return nfd_curly_variant if File.exist?(nfd_curly_variant) - - resolved - end - - def truncate_head(content, max_lines: DEFAULT_MAX_LINES, max_bytes: DEFAULT_MAX_BYTES) - lines = content.split("\n", -1) - total_lines = lines.length - total_bytes = content.bytesize - - if total_lines <= max_lines && total_bytes <= max_bytes - return truncation_result(content, false, nil, total_lines, total_bytes, total_lines, total_bytes, false, false, max_lines, max_bytes) - end - - first_line_bytes = lines.first.to_s.bytesize - if first_line_bytes > max_bytes - return truncation_result('', true, 'bytes', total_lines, total_bytes, 0, 0, false, true, max_lines, max_bytes) - end - - out_lines = [] - out_bytes = 0 - truncated_by = 'lines' - - lines.each_with_index do |line, index| - break if index >= max_lines - - line_bytes = line.bytesize + (index.positive? ? 1 : 0) - if out_bytes + line_bytes > max_bytes - truncated_by = 'bytes' - break - end - - out_lines << line - out_bytes += line_bytes - end - - output = out_lines.join("\n") - truncation_result(output, true, truncated_by, total_lines, total_bytes, out_lines.length, output.bytesize, false, false, max_lines, max_bytes) - end - - def truncate_tail(content, max_lines: DEFAULT_MAX_LINES, max_bytes: DEFAULT_MAX_BYTES) - lines = content.split("\n", -1) - total_lines = lines.length - total_bytes = content.bytesize - - if total_lines <= max_lines && total_bytes <= max_bytes - return truncation_result(content, false, nil, total_lines, total_bytes, total_lines, total_bytes, false, false, max_lines, max_bytes) - end - - out_lines = [] - out_bytes = 0 - truncated_by = 'lines' - last_line_partial = false - - (lines.length - 1).downto(0) do |i| - break if out_lines.length >= max_lines - - line = lines[i] - line_bytes = line.bytesize + (out_lines.empty? ? 0 : 1) - - if out_bytes + line_bytes > max_bytes - truncated_by = 'bytes' - if out_lines.empty? - out_lines.unshift(truncate_string_to_bytes_from_end(line, max_bytes)) - out_bytes = out_lines.first.bytesize - last_line_partial = true - end - break - end - - out_lines.unshift(line) - out_bytes += line_bytes - end - - output = out_lines.join("\n") - truncation_result(output, true, truncated_by, total_lines, total_bytes, out_lines.length, output.bytesize, last_line_partial, false, max_lines, max_bytes) - end - - 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) - { - content: content, - truncated: truncated, - truncated_by: truncated_by, - total_lines: total_lines, - total_bytes: total_bytes, - output_lines: output_lines, - output_bytes: output_bytes, - last_line_partial: last_line_partial, - first_line_exceeds_limit: first_line_exceeds_limit, - max_lines: max_lines, - max_bytes: max_bytes - } - end - - def truncate_string_to_bytes_from_end(str, max_bytes) - bytes = str.dup.force_encoding('UTF-8').bytes - return str if bytes.length <= max_bytes - - tail = bytes.last(max_bytes).pack('C*') - until tail.valid_encoding? - tail = tail.bytes.drop(1).pack('C*') - end - tail - end -end diff --git a/lib/agents/tools/write_tool.rb b/lib/agents/tools/write_tool.rb deleted file mode 100644 index a1194c5..0000000 --- a/lib/agents/tools/write_tool.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'fileutils' -require_relative 'tool_utils' - -class WriteTool < LlmGateway::Tool - name 'write' - description 'Write content to a file. Creates the file if it doesn\'t exist, overwrites if it does. Automatically creates parent directories.' - input_schema({ - type: 'object', - properties: { - path: { type: 'string', description: 'Path to the file to write (relative or absolute)' }, - content: { type: 'string', description: 'Content to write to the file' } - }, - required: ['path', 'content'] - }) - - def execute(input) - path = input[:path] || input['path'] - content = input[:content] || input['content'] - - absolute_path = ToolUtils.resolve_to_cwd(path) - FileUtils.mkdir_p(File.dirname(absolute_path)) - File.write(absolute_path, content.to_s) - - "Successfully wrote #{content.to_s.bytesize} bytes to #{path}" - rescue StandardError => e - "Error writing file: #{e.message}" - end -end diff --git a/lib/format_stream.rb b/lib/format_stream.rb index ab4a545..ad499b2 100755 --- a/lib/format_stream.rb +++ b/lib/format_stream.rb @@ -26,7 +26,7 @@ def render_agent_event(agent_event) when :turn_end tool_result_message = { role: 'user', - content: event[:tool_results].flat_map { |tool_result| tool_result[:content] } + content: event[:tool_results] } render_message_payload(tool_result_message) when :agent_end diff --git a/lib/provider_auth_helper.rb b/lib/provider_auth_helper.rb index 7c7ff31..27a6841 100644 --- a/lib/provider_auth_helper.rb +++ b/lib/provider_auth_helper.rb @@ -42,7 +42,7 @@ def oauth_access_token_for(provider) case provider when 'anthropic' - token = LlmGateway::Clients::Claude.new.get_oauth_access_token( + token = LlmGateway::Clients::Anthropic.new.get_oauth_access_token( access_token: creds['access_token'], refresh_token: creds['refresh_token'], expires_at: creds['expires_at'] @@ -57,7 +57,7 @@ def oauth_access_token_for(provider) persist_auth_credentials('anthropic', { 'access_token' => token }) if token != creds['access_token'] token when 'openai' - token = LlmGateway::Clients::OpenAi.new.get_oauth_access_token( + token = LlmGateway::Clients::OpenAI.new.get_oauth_access_token( access_token: creds['access_token'], refresh_token: creds['refresh_token'], expires_at: creds['expires_at'], @@ -79,7 +79,7 @@ def oauth_access_token_for(provider) def apply_provider_auth!(provider_name, config) case provider_name - when 'anthropic_apikey_messages' + when 'anthropic_messages' if auth_credentials_available?('anthropic') config['api_key'] = oauth_access_token_for('anthropic') else @@ -88,14 +88,19 @@ def apply_provider_auth!(provider_name, config) config['api_key'] = api_key end - when 'openai_oauth_codex' + when 'openai_codex' creds = load_auth_credentials('openai') config['api_key'] = oauth_access_token_for('openai') config['account_id'] = creds['account_id'] if creds['account_id'] - when 'openai_apikey_completions', 'openai_apikey_responses' + when 'openai_completions', 'openai_responses' api_key = ENV['OPENAI_API_KEY'] raise 'OPENAI_API_KEY is required for OpenAI API key providers' unless api_key + config['api_key'] = api_key + when 'groq_completions' + api_key = ENV['GROQ_API_KEY'] + raise 'GROQ_API_KEY is required for Groq API key provider' unless api_key + config['api_key'] = api_key else raise "Unsupported provider '#{provider_name}'" diff --git a/run_agent.rb b/run_agent.rb index eb06b59..678ce38 100755 --- a/run_agent.rb +++ b/run_agent.rb @@ -25,10 +25,11 @@ class AgentRunner include ProviderAuthHelper SUPPORTED_PROVIDERS = %w[ - anthropic_apikey_messages - openai_apikey_completions - openai_apikey_responses - openai_oauth_codex + anthropic_messages + openai_completions + openai_responses + openai_codex + groq_completions ].freeze INBOX_DB_PATH = InstanceFileScope.path('gruv.sqlite3') @@ -111,12 +112,12 @@ def build_client exit 1 end + model = resolve_model(provider) agent_config = { - 'provider' => provider, - 'model_key' => resolve_model(provider) + 'provider' => provider } - RuntimeConfig.set(provider_name: provider, model_key: agent_config['model_key']) + RuntimeConfig.set(provider_name: provider, model: model) begin apply_provider_auth!(provider, agent_config) diff --git a/scripts/clone_task_worker.rb b/scripts/clone_task_worker.rb index 5463d4d..1ea9666 100644 --- a/scripts/clone_task_worker.rb +++ b/scripts/clone_task_worker.rb @@ -82,11 +82,10 @@ def build_agent_session(task) provider_name = @options[:provider] model = @options[:model] - RuntimeConfig.set(provider_name: provider_name, model_key: model) + RuntimeConfig.set(provider_name: provider_name, model: model) config = { - 'provider' => provider_name, - 'model_key' => resolve_model(model) + 'provider' => provider_name } apply_provider_auth!(provider_name, config) diff --git a/setup.rb b/setup.rb index 8883408..fd4a137 100755 --- a/setup.rb +++ b/setup.rb @@ -202,7 +202,7 @@ def setup_openai(providers) true end - tokens = run_oauth ? LlmGateway::Clients::OpenAi::OAuthFlow.new.login : nil + tokens = run_oauth ? LlmGateway::Clients::OpenAI::OAuthFlow.new.login : nil entry = existing.dup entry['reasoning'] = reasoning diff --git a/setup_provider.rb b/setup_provider.rb index fb8f779..c0804c4 100644 --- a/setup_provider.rb +++ b/setup_provider.rb @@ -38,7 +38,7 @@ def run_oauth_flow(provider) flow.exchange_code(auth_code, result[:code_verifier]) when 'openai' - LlmGateway::Clients::OpenAiCodex::OAuthFlow.new.login + LlmGateway::Clients::OpenAI::OAuthFlow.new.login end end diff --git a/test/sessions/file_session_manager_test.rb b/test/sessions/file_session_manager_test.rb index b4d2e64..0e1b1fb 100644 --- a/test/sessions/file_session_manager_test.rb +++ b/test/sessions/file_session_manager_test.rb @@ -194,11 +194,12 @@ def test_compaction_reads_fixture_and_calls_client_chat_with_expected_parameters assistant_message_class.new( id: 'msg_compact_fixture_1', model: 'test-model', - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + usage: { input: 10, output: 5, total: 15 }, role: 'assistant', stop_reason: 'stop', provider: 'test', api: 'responses', + timestamp: Time.now.to_i, content: [text_content_class.new(type: 'text', text: "# Topic\n- Compacted")] ) end @@ -207,7 +208,7 @@ def test_compaction_reads_fixture_and_calls_client_chat_with_expected_parameters assert_equal 'compaction', compaction_entry[:type] assert_equal "# Topic\n- Compacted", compaction_entry.dig(:data, :summary) - assert_equal({ input_tokens: 10, output_tokens: 5, total_tokens: 15 }, compaction_entry[:usage]) + assert_equal({ input: 10, output: 5, total: 15 }, compaction_entry[:usage]) assert_equal 9, manager.events.length assert_equal 'compaction', manager.events.last[:type] end @@ -230,11 +231,12 @@ def test_compaction_accepts_struct_response_objects_from_streaming_clients result = assistant_message_class.new( id: 'msg_compact_1', model: 'test-model', - usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + usage: { input: 4, output: 2, total: 6 }, role: 'assistant', stop_reason: 'stop', provider: 'test', api: 'responses', + timestamp: Time.now.to_i, content: [text_content_class.new(type: 'text', text: "# Summary\n- Done")] ) @@ -250,7 +252,7 @@ def test_compaction_accepts_struct_response_objects_from_streaming_clients assert_equal 'compaction', compaction_entry[:type] assert_equal "# Summary\n- Done", compaction_entry.dig(:data, :summary) - assert_equal({ input_tokens: 4, output_tokens: 2, total_tokens: 6 }, compaction_entry[:usage]) + assert_equal({ input: 4, output: 2, total: 6 }, compaction_entry[:usage]) end end