brute 0.1.5 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2148d5db68a8400e3f3decc496561a7beca3225cc89171c013ba37f3ca48fa6
4
- data.tar.gz: bbea8457ac274e5953b55ea54ecc5f29a75cb05cceab3631ab2b29eb47c01346
3
+ metadata.gz: 48b59415efb00c3e5da0f9264aa04291f477b4255fb35407a2ed0e68fec69fc2
4
+ data.tar.gz: 668d46ac81b5ff1f8dfd3f832f08736aa38820fda44c4432940b7f82e1261079
5
5
  SHA512:
6
- metadata.gz: b179135722aca285350004e8ce425a79f4b493d8417090bbd367fcff6fa0213a6cb207b2bd0ca3ede30ff11c7ee972bf7fce71406244f3dc5aa3a0190d939406
7
- data.tar.gz: 48eff670f59f22f5dc1389510835ae8f2ca50ca8ec8c0e615bbac10c2c559a50b708859cd47a01fa24c5c406d73000bb7e2f7b49f160832ac157c106ccaed6a5
6
+ metadata.gz: c9f9b4f883b6b77a7e81980528e9ed0c703280114ff8b7ee26409b69c8127ec699740c43deea027394045c33a7b18a0a59dd72d7ccdf0694eaa62754b75ed2d9
7
+ data.tar.gz: 1ee08e461182f1065e9fe01313a823fa8af31f820341d680d4b43619016745db28876c061352eab33f25049cd6a87d9c44b039f87eae5c837101cc8cd07bd159
data/lib/brute/diff.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'diff/lcs'
4
+ require 'diff/lcs/hunk'
5
+
6
+ module Brute
7
+ module Diff
8
+ # Generate a unified diff string from two texts.
9
+ def self.unified(old_text, new_text, context: 3)
10
+ old_lines = old_text.lines
11
+ new_lines = new_text.lines
12
+ diffs = ::Diff::LCS.diff(old_lines, new_lines)
13
+ return '' if diffs.empty?
14
+
15
+ output = +''
16
+ file_length_difference = 0
17
+ diffs.each do |piece|
18
+ hunk = ::Diff::LCS::Hunk.new(old_lines, new_lines, piece, context, file_length_difference)
19
+ file_length_difference = hunk.file_length_difference
20
+ output << hunk.diff(:unified)
21
+ output << "\n"
22
+ end
23
+ output
24
+ end
25
+ end
26
+ end
@@ -2,16 +2,19 @@
2
2
 
3
3
  module Brute
4
4
  module Middleware
5
- # Tracks per-tool error counts across LLM calls and signals when
6
- # the error ceiling is reached.
5
+ # Tracks per-tool error counts and total tool call count across LLM
6
+ # calls, and signals when the error ceiling is reached.
7
7
  #
8
8
  # This middleware doesn't execute tools itself — it inspects the tool
9
9
  # results that were sent as input to the LLM call (env[:tool_results])
10
- # and counts failures.
10
+ # and counts failures and totals.
11
11
  #
12
12
  # When any tool exceeds max_failures, it sets env[:metadata][:tool_error_limit_reached]
13
13
  # so the orchestrator can decide to stop.
14
14
  #
15
+ # Also stores env[:metadata][:tool_calls] with the cumulative number of
16
+ # tool invocations in the current session.
17
+ #
15
18
  class ToolErrorTracking < Base
16
19
  DEFAULT_MAX_FAILURES = 3
17
20
 
@@ -19,27 +22,30 @@ module Brute
19
22
  super(app)
20
23
  @max_failures = max_failures
21
24
  @errors = Hash.new(0) # tool_name → count
25
+ @total_tool_calls = 0
22
26
  end
23
27
 
24
28
  def call(env)
25
- # PRE: count errors from tool results that are about to be sent
29
+ # PRE: count errors and totals from tool results that are about to be sent
26
30
  if (results = env[:tool_results])
31
+ @total_tool_calls += results.size
32
+
27
33
  results.each do |name, result|
28
- if result.is_a?(Hash) && result[:error]
29
- @errors[name] += 1
30
- end
34
+ @errors[name] += 1 if result.is_a?(Hash) && result[:error]
31
35
  end
32
36
  end
33
37
 
38
+ env[:metadata][:tool_calls] = @total_tool_calls
34
39
  env[:metadata][:tool_errors] = @errors.dup
35
40
  env[:metadata][:tool_error_limit_reached] = @errors.any? { |_, c| c >= @max_failures }
36
41
 
37
42
  @app.call(env)
38
43
  end
39
44
 
40
- # Reset error counts (e.g., between user turns).
45
+ # Reset counts (e.g., between user turns).
41
46
  def reset!
42
47
  @errors.clear
48
+ @total_tool_calls = 0
43
49
  end
44
50
  end
45
51
  end
@@ -2,31 +2,60 @@
2
2
 
3
3
  module Brute
4
4
  module Middleware
5
- # Logs timing and token usage for every LLM call.
5
+ # Logs timing and token usage for every LLM call, and tracks cumulative
6
+ # timing data in env[:metadata][:timing].
6
7
  #
7
- # Wraps the call with wall-clock timing. Logs:
8
- # PRE: request number, message count
9
- # POST: elapsed time, token usage, finish reason
8
+ # As the outermost middleware, it sees the full pipeline elapsed time per
9
+ # call. It also tracks total wall-clock time across all calls in a turn
10
+ # (including tool execution gaps between LLM calls).
11
+ #
12
+ # A new turn is detected when env[:tool_results] is nil (the orchestrator
13
+ # sets this on the first call of each run()).
14
+ #
15
+ # Stores in env[:metadata][:timing]:
16
+ # total_elapsed: wall-clock since the turn began (includes tool gaps)
17
+ # total_llm_elapsed: cumulative time spent inside LLM calls only
18
+ # llm_call_count: number of LLM calls so far
19
+ # last_call_elapsed: duration of the most recent LLM call
10
20
  #
11
21
  class Tracing < Base
12
22
  def initialize(app, logger:)
13
23
  super(app)
14
24
  @logger = logger
15
25
  @call_count = 0
26
+ @total_llm_elapsed = 0.0
27
+ @turn_start = nil
16
28
  end
17
29
 
18
30
  def call(env)
19
31
  @call_count += 1
32
+
33
+ # Detect new turn: tool_results is nil on the first pipeline call
34
+ if env[:tool_results].nil?
35
+ @turn_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ @total_llm_elapsed = 0.0
37
+ end
38
+
20
39
  messages = env[:context].messages.to_a
21
40
  @logger.debug("[brute] LLM call ##{@call_count} (#{messages.size} messages in context)")
22
41
 
23
42
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
43
  response = @app.call(env)
25
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
44
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ elapsed = now - start
46
+
47
+ @total_llm_elapsed += elapsed
26
48
 
27
- tokens = response.respond_to?(:usage) ? response.usage&.total_tokens : "?"
49
+ tokens = response.respond_to?(:usage) ? response.usage&.total_tokens : '?'
28
50
  @logger.info("[brute] LLM response ##{@call_count}: #{tokens} tokens, #{elapsed.round(2)}s")
29
51
 
52
+ env[:metadata][:timing] = {
53
+ total_elapsed: now - (@turn_start || start),
54
+ total_llm_elapsed: @total_llm_elapsed,
55
+ llm_call_count: @call_count,
56
+ last_call_elapsed: elapsed
57
+ }
58
+
30
59
  response
31
60
  end
32
61
  end
@@ -3,14 +3,14 @@
3
3
  module Brute
4
4
  module Tools
5
5
  class FSPatch < LLM::Tool
6
- name "patch"
7
- description "Replace a specific string in a file. The old_string must match exactly " \
8
- "(including whitespace and indentation). Always read a file before patching it."
6
+ name 'patch'
7
+ description 'Replace a specific string in a file. The old_string must match exactly ' \
8
+ '(including whitespace and indentation). Always read a file before patching it.'
9
9
 
10
- param :file_path, String, "Path to the file to patch", required: true
11
- param :old_string, String, "The exact text to find and replace", required: true
12
- param :new_string, String, "The replacement text", required: true
13
- param :replace_all, Boolean, "Replace all occurrences (default: false)"
10
+ param :file_path, String, 'Path to the file to patch', required: true
11
+ param :old_string, String, 'The exact text to find and replace', required: true
12
+ param :new_string, String, 'The replacement text', required: true
13
+ param :replace_all, Boolean, 'Replace all occurrences (default: false)'
14
14
 
15
15
  def call(file_path:, old_string:, new_string:, replace_all: false)
16
16
  path = File.expand_path(file_path)
@@ -23,13 +23,15 @@ module Brute
23
23
  Brute::SnapshotStore.save(path)
24
24
 
25
25
  updated = if replace_all
26
- original.gsub(old_string, new_string)
27
- else
28
- original.sub(old_string, new_string)
29
- end
26
+ original.gsub(old_string, new_string)
27
+ else
28
+ original.sub(old_string, new_string)
29
+ end
30
30
 
31
31
  File.write(path, updated)
32
- {success: true, file_path: path, replacements: replace_all ? original.scan(old_string).size : 1}
32
+ diff = Brute::Diff.unified(original, updated)
33
+ count = replace_all ? original.scan(old_string).size : 1
34
+ { success: true, file_path: path, replacements: count, diff: diff }
33
35
  end
34
36
  end
35
37
  end
@@ -1,24 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
3
+ require 'fileutils'
4
4
 
5
5
  module Brute
6
6
  module Tools
7
7
  class FSWrite < LLM::Tool
8
- name "write"
8
+ name 'write'
9
9
  description "Write content to a file. Creates parent directories if they don't exist. " \
10
- "Use this for creating new files or completely replacing file contents."
10
+ 'Use this for creating new files or completely replacing file contents.'
11
11
 
12
- param :file_path, String, "Path to the file to write", required: true
13
- param :content, String, "The full content to write to the file", required: true
12
+ param :file_path, String, 'Path to the file to write', required: true
13
+ param :content, String, 'The full content to write to the file', required: true
14
14
 
15
15
  def call(file_path:, content:)
16
16
  path = File.expand_path(file_path)
17
17
  Brute::FileMutationQueue.serialize(path) do
18
+ old_content = File.exist?(path) ? File.read(path) : ''
18
19
  Brute::SnapshotStore.save(path)
19
20
  FileUtils.mkdir_p(File.dirname(path))
20
21
  File.write(path, content)
21
- {success: true, file_path: path, bytes: content.bytesize}
22
+ diff = Brute::Diff.unified(old_content, content)
23
+ { success: true, file_path: path, bytes: content.bytesize, diff: diff }
22
24
  end
23
25
  end
24
26
  end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.6"
5
5
  end
data/lib/brute.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm"
4
- require "timeout"
5
- require "logger"
3
+ require 'llm'
4
+ require 'timeout'
5
+ require 'logger'
6
6
 
7
7
  # Brute — a coding agent built on llm.rb
8
8
  #
@@ -11,7 +11,7 @@ require "logger"
11
11
  #
12
12
  # Tracing → Retry → Session → Tokens → Compaction → ToolErrors → DoomLoop → Reasoning → [LLM Call]
13
13
  #
14
- require_relative "brute/version"
14
+ require_relative 'brute/version'
15
15
 
16
16
  module Brute
17
17
  module Tools; end
@@ -20,51 +20,51 @@ module Brute
20
20
  end
21
21
 
22
22
  # Infrastructure
23
- require_relative "brute/snapshot_store"
24
- require_relative "brute/todo_store"
25
- require_relative "brute/file_mutation_queue"
26
- require_relative "brute/doom_loop"
27
- require_relative "brute/hooks"
28
- require_relative "brute/compactor"
29
- require_relative "brute/system_prompt"
30
- require_relative "brute/session"
31
- require_relative "brute/pipeline"
32
- require_relative "brute/agent_stream"
23
+ require_relative 'brute/diff'
24
+ require_relative 'brute/snapshot_store'
25
+ require_relative 'brute/todo_store'
26
+ require_relative 'brute/file_mutation_queue'
27
+ require_relative 'brute/doom_loop'
28
+ require_relative 'brute/hooks'
29
+ require_relative 'brute/compactor'
30
+ require_relative 'brute/system_prompt'
31
+ require_relative 'brute/session'
32
+ require_relative 'brute/pipeline'
33
+ require_relative 'brute/agent_stream'
33
34
 
34
35
  # Provider patches
35
- require_relative "brute/patches/anthropic_tool_role"
36
- require_relative "brute/patches/buffer_nil_guard"
36
+ require_relative 'brute/patches/anthropic_tool_role'
37
+ require_relative 'brute/patches/buffer_nil_guard'
37
38
 
38
39
  # Middleware (Rack-style)
39
- require_relative "brute/middleware/base"
40
- require_relative "brute/middleware/llm_call"
41
- require_relative "brute/middleware/retry"
42
- require_relative "brute/middleware/doom_loop_detection"
43
- require_relative "brute/middleware/token_tracking"
44
- require_relative "brute/middleware/compaction_check"
45
- require_relative "brute/middleware/session_persistence"
46
- require_relative "brute/middleware/tracing"
47
- require_relative "brute/middleware/tool_error_tracking"
48
- require_relative "brute/middleware/reasoning_normalizer"
40
+ require_relative 'brute/middleware/base'
41
+ require_relative 'brute/middleware/llm_call'
42
+ require_relative 'brute/middleware/retry'
43
+ require_relative 'brute/middleware/doom_loop_detection'
44
+ require_relative 'brute/middleware/token_tracking'
45
+ require_relative 'brute/middleware/compaction_check'
46
+ require_relative 'brute/middleware/session_persistence'
47
+ require_relative 'brute/middleware/tracing'
48
+ require_relative 'brute/middleware/tool_error_tracking'
49
+ require_relative 'brute/middleware/reasoning_normalizer'
49
50
 
50
51
  # Tools
51
- require_relative "brute/tools/fs_read"
52
- require_relative "brute/tools/fs_write"
53
- require_relative "brute/tools/fs_patch"
54
- require_relative "brute/tools/fs_remove"
55
- require_relative "brute/tools/fs_search"
56
- require_relative "brute/tools/fs_undo"
57
- require_relative "brute/tools/shell"
58
- require_relative "brute/tools/net_fetch"
59
- require_relative "brute/tools/todo_write"
60
- require_relative "brute/tools/todo_read"
61
- require_relative "brute/tools/delegate"
52
+ require_relative 'brute/tools/fs_read'
53
+ require_relative 'brute/tools/fs_write'
54
+ require_relative 'brute/tools/fs_patch'
55
+ require_relative 'brute/tools/fs_remove'
56
+ require_relative 'brute/tools/fs_search'
57
+ require_relative 'brute/tools/fs_undo'
58
+ require_relative 'brute/tools/shell'
59
+ require_relative 'brute/tools/net_fetch'
60
+ require_relative 'brute/tools/todo_write'
61
+ require_relative 'brute/tools/todo_read'
62
+ require_relative 'brute/tools/delegate'
62
63
 
63
64
  # Orchestrator (depends on tools, middleware, and infrastructure)
64
- require_relative "brute/orchestrator"
65
+ require_relative 'brute/orchestrator'
65
66
 
66
67
  module Brute
67
-
68
68
  # The complete set of tools available to the agent.
69
69
  TOOLS = [
70
70
  Tools::FSRead,
@@ -77,7 +77,7 @@ module Brute
77
77
  Tools::NetFetch,
78
78
  Tools::TodoWrite,
79
79
  Tools::TodoRead,
80
- Tools::Delegate,
80
+ Tools::Delegate
81
81
  ].freeze
82
82
 
83
83
  # Default provider, resolved from environment.
@@ -102,12 +102,12 @@ module Brute
102
102
  end
103
103
 
104
104
  PROVIDERS = {
105
- "anthropic" => ->(key) { LLM.anthropic(key: key).tap { Patches::AnthropicToolRole.apply! } },
106
- "openai" => ->(key) { LLM.openai(key: key) },
107
- "google" => ->(key) { LLM.google(key: key) },
108
- "deepseek" => ->(key) { LLM.deepseek(key: key) },
109
- "ollama" => ->(key) { LLM.ollama(key: key) },
110
- "xai" => ->(key) { LLM.xai(key: key) },
105
+ 'anthropic' => ->(key) { LLM.anthropic(key: key).tap { Patches::AnthropicToolRole.apply! } },
106
+ 'openai' => ->(key) { LLM.openai(key: key) },
107
+ 'google' => ->(key) { LLM.google(key: key) },
108
+ 'deepseek' => ->(key) { LLM.deepseek(key: key) },
109
+ 'ollama' => ->(key) { LLM.ollama(key: key) },
110
+ 'xai' => ->(key) { LLM.xai(key: key) }
111
111
  }.freeze
112
112
 
113
113
  # Resolve the LLM provider from environment variables.
@@ -120,24 +120,24 @@ module Brute
120
120
  #
121
121
  # Returns nil if no key is found. Error is deferred to Orchestrator#run.
122
122
  def self.resolve_provider
123
- if ENV["LLM_API_KEY"]
124
- key = ENV["LLM_API_KEY"]
125
- name = ENV.fetch("LLM_PROVIDER", "anthropic").downcase
126
- elsif ENV["ANTHROPIC_API_KEY"]
127
- key = ENV["ANTHROPIC_API_KEY"]
128
- name = "anthropic"
129
- elsif ENV["OPENAI_API_KEY"]
130
- key = ENV["OPENAI_API_KEY"]
131
- name = "openai"
132
- elsif ENV["GOOGLE_API_KEY"]
133
- key = ENV["GOOGLE_API_KEY"]
134
- name = "google"
123
+ if ENV['LLM_API_KEY']
124
+ key = ENV['LLM_API_KEY']
125
+ name = ENV.fetch('LLM_PROVIDER', 'anthropic').downcase
126
+ elsif ENV['ANTHROPIC_API_KEY']
127
+ key = ENV['ANTHROPIC_API_KEY']
128
+ name = 'anthropic'
129
+ elsif ENV['OPENAI_API_KEY']
130
+ key = ENV['OPENAI_API_KEY']
131
+ name = 'openai'
132
+ elsif ENV['GOOGLE_API_KEY']
133
+ key = ENV['GOOGLE_API_KEY']
134
+ name = 'google'
135
135
  else
136
136
  return nil
137
137
  end
138
138
 
139
139
  factory = PROVIDERS[name]
140
- raise "Unknown LLM provider: #{name}. Available: #{PROVIDERS.keys.join(", ")}" unless factory
140
+ raise "Unknown LLM provider: #{name}. Available: #{PROVIDERS.keys.join(', ')}" unless factory
141
141
 
142
142
  factory.call(key)
143
143
  end
metadata CHANGED
@@ -1,30 +1,30 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: llm.rb
13
+ name: async
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '4.11'
18
+ version: '2.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '4.11'
25
+ version: '2.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: async
27
+ name: diff-lcs
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: llm.rb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '4.11'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '4.11'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: minitest
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -74,6 +88,7 @@ files:
74
88
  - lib/brute.rb
75
89
  - lib/brute/agent_stream.rb
76
90
  - lib/brute/compactor.rb
91
+ - lib/brute/diff.rb
77
92
  - lib/brute/doom_loop.rb
78
93
  - lib/brute/file_mutation_queue.rb
79
94
  - lib/brute/hooks.rb