brute 5.0.5 → 6.0.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -4,80 +4,82 @@ module Brute
4
4
 
5
5
  # Brute::Message -> ruby-openai Hash payload
6
6
  def self.dump(message)
7
- payload = {
8
- role: message.role.to_s
9
- }
10
-
11
- # Include content if present
12
- payload[:content] = message.content if message.content
7
+ {
8
+ role: message.role.to_s,
9
+ }.tap do |payload|
10
+ # Include content if present
11
+ if message.content
12
+ payload[:content] = message.content
13
+ end
13
14
 
14
- # Include tool call ID for tool outputs
15
- payload[:tool_call_id] = message.tool_call_id if message.tool_call_id
15
+ # Include tool call ID for tool outputs
16
+ if message.tool_call_id
17
+ payload[:tool_call_id] = message.tool_call_id
18
+ end
16
19
 
17
- # Convert Brute::ToolCall objects to ruby-openai nested tool call hashes
18
- if message.tool_call?
19
- payload[:tool_calls] = message.tool_calls.map do |tc|
20
- {
21
- id: tc.id,
22
- type: "function",
23
- function: {
24
- name: tc.name,
25
- arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
20
+ # Convert Brute::ToolCall objects to ruby-openai nested tool call hashes
21
+ if message.tool_call?
22
+ payload[:tool_calls] = message.tool_calls.map do |tc|
23
+ {
24
+ id: tc.id,
25
+ type: "function",
26
+ function: {
27
+ name: tc.name,
28
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json,
29
+ },
26
30
  }
27
- }
31
+ end
28
32
  end
29
33
  end
30
-
31
- payload
32
34
  end
33
35
 
34
36
  private
35
37
 
36
38
  # ruby-openai Hash (or API response choice message) -> Brute::Message
37
- def wrap(message)
38
- # Normalize keys to symbols for pattern matching
39
- hash = message.to_h.transform_keys(&:to_sym)
39
+ def wrap(message)
40
+ # Normalize keys to symbols for pattern matching
41
+ hash = message.to_h.transform_keys(&:to_sym)
40
42
 
41
- case hash
42
- # Branch 1: System, User, or Tool responses with text content
43
- in { role: ("system" | "user" | "tool") => role }
44
- Brute::Message.new(
45
- role: role,
46
- content: hash[:content],
47
- tool_call_id: hash[:tool_call_id]
48
- )
43
+ case hash
44
+ # Branch 1: System, User, or Tool responses with text content
45
+ in { role: ("system" | "user" | "tool") => role }
46
+ Brute::Message.new(
47
+ role: role,
48
+ content: hash[:content],
49
+ tool_call_id: hash[:tool_call_id],
50
+ )
49
51
 
50
- # Branch 2: Assistant tool calls request
51
- in { role: "assistant", tool_calls: Array => raw_calls }
52
- tool_calls = raw_calls.map do |tc|
53
- # Handle both string and symbol keys within nested tool_call hashes
54
- tc_hash = tc.to_h.transform_keys(&:to_sym)
55
- fn_hash = (tc_hash[:function] || {}).transform_keys(&:to_sym)
52
+ # Branch 2: Assistant tool calls request
53
+ in { role: "assistant", tool_calls: Array => raw_calls }
54
+ tool_calls = raw_calls.map do |tc|
55
+ # Handle both string and symbol keys within nested tool_call hashes
56
+ tc_hash = tc.to_h.transform_keys(&:to_sym)
57
+ fn_hash = (tc_hash[:function] || {}).transform_keys(&:to_sym)
56
58
 
57
- Brute::ToolCall.new(
58
- id: tc_hash[:id],
59
- name: fn_hash[:name],
60
- arguments: fn_hash[:arguments]
61
- )
62
- end
59
+ Brute::ToolCall.new(
60
+ id: tc_hash[:id],
61
+ name: fn_hash[:name],
62
+ arguments: fn_hash[:arguments],
63
+ )
64
+ end
63
65
 
64
- Brute::Message.new(
65
- role: :assistant,
66
- content: hash[:content],
67
- tool_calls: tool_calls
68
- )
66
+ Brute::Message.new(
67
+ role: :assistant,
68
+ content: hash[:content],
69
+ tool_calls: tool_calls,
70
+ )
69
71
 
70
- # Branch 3: Standard Assistant text message
71
- in { role: "assistant" }
72
- Brute::Message.new(
73
- role: :assistant,
74
- content: hash[:content]
75
- )
72
+ # Branch 3: Standard Assistant text message
73
+ in { role: "assistant" }
74
+ Brute::Message.new(
75
+ role: :assistant,
76
+ content: hash[:content],
77
+ )
76
78
 
77
- else
78
- raise "Unrecognised message format for ruby-openai: #{message.inspect}"
79
+ else
80
+ raise "Unrecognised message format for ruby-openai: #{message.inspect}"
81
+ end
79
82
  end
80
- end
81
83
  end
82
84
  end
83
85
  end
@@ -22,27 +22,34 @@ module Brute
22
22
  # Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" }),
23
23
  # ])
24
24
  # Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
25
- Message = Data.define(:role, :content, :tool_calls, :tool_call_id) do
25
+ Message = Data.define(
26
+ :role,
27
+ :content,
28
+ :tool_calls,
29
+ :tool_call_id,
30
+ ) do
26
31
  def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil)
27
32
  formatted_calls = tool_calls&.map do |tc|
28
33
  tc.is_a?(ToolCall) ? tc : ToolCall.new(**tc.to_h.transform_keys(&:to_sym))
29
34
  end
30
-
35
+
31
36
  super(
32
- role: role.to_sym,
33
- content: content,
34
- tool_calls: formatted_calls,
37
+ role: role.to_sym,
38
+ content: content,
39
+ tool_calls: formatted_calls,
35
40
  tool_call_id: tool_call_id
36
41
  )
37
42
  end
38
-
43
+
39
44
  def tool_call? = !tool_calls.nil? && !tool_calls.empty?
40
45
  alias_method :has_tool_calls?, :tool_call?
41
-
46
+
42
47
  # Clean, JSON-ready hash export dropping nil values
43
48
  def to_h(...)
44
49
  hash = super
45
- hash[:tool_calls] = tool_calls.map(&:to_h) if tool_calls
50
+ if tool_calls
51
+ hash[:tool_calls] = tool_calls.map(&:to_h)
52
+ end
46
53
  hash.compact
47
54
  end
48
55
  end
@@ -11,13 +11,13 @@ module Brute
11
11
  #
12
12
  # class Shout < Brute::Middleware::Base
13
13
  # def call(env)
14
- # emit(ENTER_EVENT, env, self)
14
+ # env.emit(MIDDLEWARE_START_EVENT, self)
15
15
  # @app.call(env)
16
16
  # end
17
17
  # end
18
18
  #
19
19
  # Brute::Hooks is included, so the event names are first class in every
20
- # subclass — ENTER_EVENT, not Brute::Hooks::ENTER_EVENT.
20
+ # subclass — MIDDLEWARE_START_EVENT, not Brute::Hooks::MIDDLEWARE_START_EVENT.
21
21
  class Base
22
22
  include Brute::Hooks
23
23
 
@@ -49,13 +49,13 @@ describe "brute/middleware/000_base" do
49
49
  Brute::Middleware::Base.new(terminal, :extra, keyword: true).call({}).should == {}
50
50
 
51
51
  # The event names resolve through the class itself, so a subclass writes
52
- # ENTER_EVENT rather than Brute::Hooks::ENTER_EVENT.
53
- Brute::Middleware::Base.const_get(:ENTER_EVENT).should == :enter
54
- Brute::Middleware::SystemPrompt.const_get(:AFTER_TOOL_EVENT).should == :after_tool
52
+ # MIDDLEWARE_START_EVENT rather than Brute::Hooks::MIDDLEWARE_START_EVENT.
53
+ Brute::Middleware::Base.const_get(:MIDDLEWARE_START_EVENT).should == :middleware_start
54
+ Brute::Middleware::SystemPrompt.const_get(:TOOL_END_EVENT).should == :tool_end
55
55
 
56
56
  # Every middleware in the chain descends from it.
57
57
  Brute::Middleware::SystemPrompt.ancestors.should.include Brute::Middleware::Base
58
- Brute::Middleware::ToolPipeline.ancestors.should.include Brute::Middleware::Base
58
+ Brute::Middleware::DefaultToolPipeline.ancestors.should.include Brute::Middleware::Base
59
59
  Brute::Middleware::Loop::ToolResult.ancestors.should.include Brute::Middleware::Base
60
60
  end
61
61
  end
@@ -30,9 +30,13 @@ module Brute
30
30
  end
31
31
 
32
32
  def call(env)
33
- load_into(env[:messages]) if @path && File.exist?(@path)
33
+ if @path && File.exist?(@path)
34
+ load_into(env[:messages])
35
+ end
34
36
  @app.call(env)
35
- persist(env[:messages]) if @path
37
+ if @path
38
+ persist(env[:messages])
39
+ end
36
40
  env
37
41
  end
38
42
 
@@ -42,7 +46,9 @@ module Brute
42
46
  loaded = []
43
47
  File.foreach(@path) do |line|
44
48
  line = line.strip
45
- loaded << Brute::Message.new(**JSON.parse(line, symbolize_names: true)) unless line.empty?
49
+ unless line.empty?
50
+ loaded << Brute::Message.new(**JSON.parse(line, symbolize_names: true))
51
+ end
46
52
  end
47
53
  messages.unshift(*loaded)
48
54
  end
@@ -51,7 +57,9 @@ module Brute
51
57
  FileUtils.mkdir_p(File.dirname(@path))
52
58
  File.open(@path, "w") do |f|
53
59
  messages.each do |message|
54
- next if message.role == :system
60
+ if message.role == :system
61
+ next
62
+ end
55
63
 
56
64
  f.puts(JSON.generate(message.to_h))
57
65
  end
@@ -16,7 +16,7 @@ module Brute
16
16
  # use Brute::Middleware::Loop::ToolResult
17
17
  # use Brute::Middleware::Checkpoint, path: "tmp/checkpoints.jsonl"
18
18
  # use Brute::Middleware::MaxIterations
19
- # use Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL
19
+ # use Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL
20
20
  #
21
21
  # The store is just a JSONL log of snapshots — one line per checkpoint,
22
22
  # each carrying the full message log plus its own id and the id of the
@@ -41,7 +41,9 @@ module Brute
41
41
  end
42
42
 
43
43
  def call(env)
44
- restore(env) unless env[:metadata][:checkpoint]
44
+ unless env[:metadata][:checkpoint]
45
+ restore(env)
46
+ end
45
47
  @app.call(env)
46
48
  persist(env)
47
49
  env
@@ -50,11 +52,15 @@ module Brute
50
52
  # Parsed checkpoint records (symbol keys, messages as plain hashes),
51
53
  # oldest first.
52
54
  def self.list(path)
53
- return [] unless path && File.exist?(path)
54
-
55
- File.foreach(path).filter_map do |line|
56
- line = line.strip
57
- JSON.parse(line, symbolize_names: true) unless line.empty?
55
+ if path && File.exist?(path)
56
+ File.foreach(path).filter_map do |line|
57
+ line = line.strip
58
+ unless line.empty?
59
+ JSON.parse(line, symbolize_names: true)
60
+ end
61
+ end
62
+ else
63
+ []
58
64
  end
59
65
  end
60
66
 
@@ -62,19 +68,21 @@ module Brute
62
68
 
63
69
  def restore(env)
64
70
  env[:metadata][:checkpoint] = { path: @path }
65
- return unless @resume
66
-
67
- record = find_record
68
- if record.nil?
69
- return if @resume == :latest
70
-
71
- raise KeyError, "no checkpoint #{@resume.inspect} in #{@path}"
71
+ if @resume
72
+ record = find_record
73
+ if record.nil?
74
+ unless @resume == :latest
75
+ raise KeyError, "no checkpoint #{@resume.inspect} in #{@path}"
76
+ end
77
+ else
78
+ history = record[:messages].map { |h| Brute::Message.new(**h) }
79
+ index = env[:messages].index { |m| m.role != :system } || env[:messages].size
80
+ env[:messages].insert(index, *history)
81
+ env[:metadata][:checkpoint][:id] = record[:id]
82
+ end
83
+ else
84
+ nil
72
85
  end
73
-
74
- history = record[:messages].map { |h| Brute::Message.new(**h) }
75
- index = env[:messages].index { |m| m.role != :system } || env[:messages].size
76
- env[:messages].insert(index, *history)
77
- env[:metadata][:checkpoint][:id] = record[:id]
78
86
  end
79
87
 
80
88
  def find_record
@@ -23,7 +23,7 @@ module Brute
23
23
  def call(env)
24
24
  if max_iterations_reached?(env)
25
25
  env[:messages] << Brute::Message.new(
26
- role: :user,
26
+ role: :user,
27
27
  content: "Maximum iterations reached.",
28
28
  )
29
29
  else
@@ -37,7 +37,7 @@ module Brute
37
37
  result = @system_prompt.prepare(ctx)
38
38
  unless result.empty?
39
39
  env[:messages].unshift(
40
- Brute::Message.new(role: :system, content: result.to_s)
40
+ Brute::Message.new(role: :system, content: result.to_s),
41
41
  )
42
42
  end
43
43
  end