brute 0.4.1 → 1.0.1

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -1,181 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
7
-
8
- module Brute
9
- # Bridges llm.rb's streaming callbacks to the host application.
10
- #
11
- # Text and reasoning chunks fire immediately as the LLM generates them.
12
- # Tool calls are collected but NOT executed — execution is deferred to the
13
- # orchestrator after the stream completes. This ensures text is never
14
- # concurrent with tool execution.
15
- #
16
- # After the stream finishes, the orchestrator reads +pending_tools+ to
17
- # dispatch all tool calls concurrently, then fires +on_tool_call_start+
18
- # once with the full batch.
19
- #
20
- class AgentStream < LLM::Stream
21
- # Tool call metadata recorded during streaming, used by ToolUseGuard
22
- # when ctx.functions is empty (nil-choice bug in llm.rb).
23
- attr_reader :pending_tool_calls
24
-
25
- # Deferred tool/error pairs: [(LLM::Function, error_or_nil), ...]
26
- # The orchestrator reads these after the stream completes.
27
- attr_reader :pending_tools
28
-
29
- def initialize(on_content: nil, on_reasoning: nil, on_question: nil)
30
- @on_content = on_content
31
- @on_reasoning = on_reasoning
32
- @on_question = on_question
33
- @pending_tool_calls = []
34
- @pending_tools = []
35
- end
36
-
37
- # The on_question callback, needed by the orchestrator to set
38
- # thread/fiber-locals before tool execution.
39
- attr_reader :on_question
40
-
41
- def on_content(text)
42
- @on_content&.call(text)
43
- end
44
-
45
- def on_reasoning_content(text)
46
- @on_reasoning&.call(text)
47
- end
48
-
49
- # Called by llm.rb per tool as it arrives during streaming.
50
- # Records only — no execution, no threads, no queue pushes.
51
- def on_tool_call(tool, error)
52
- @pending_tool_calls << { id: tool.id, name: tool.name, arguments: tool.arguments }
53
- @pending_tools << [tool, error]
54
- end
55
-
56
- # Clear only the tool call metadata (used by ToolUseGuard after it
57
- # has consumed the data for synthetic message injection).
58
- def clear_pending_tool_calls!
59
- @pending_tool_calls.clear
60
- end
61
-
62
- # Clear the deferred execution queue after the orchestrator has
63
- # consumed and dispatched all tool calls.
64
- def clear_pending_tools!
65
- @pending_tools.clear
66
- end
67
- end
68
- end
69
-
70
- if __FILE__ == $0
71
- require_relative "../../spec/spec_helper"
72
-
73
- RSpec.describe Brute::AgentStream do
74
- # Build a mock tool that quacks like LLM::Function.
75
- def mock_tool(id:, name:, arguments: {})
76
- instance_double(LLM::Function,
77
- id: id,
78
- name: name,
79
- arguments: arguments,
80
- )
81
- end
82
-
83
- describe "#on_tool_call" do
84
- it "records tool/error pair in pending_tools without spawning threads" do
85
- stream = described_class.new
86
- tool = mock_tool(id: "toolu_1", name: "read")
87
-
88
- stream.on_tool_call(tool, nil)
89
-
90
- expect(stream.pending_tools.size).to eq(1)
91
- recorded_tool, recorded_error = stream.pending_tools.first
92
- expect(recorded_tool).to eq(tool)
93
- expect(recorded_error).to be_nil
94
- end
95
-
96
- it "records error tools in pending_tools" do
97
- stream = described_class.new
98
- tool = mock_tool(id: "toolu_err", name: "bad_tool")
99
- error = LLM::Function::Return.new("toolu_err", "bad_tool", { error: true })
100
-
101
- stream.on_tool_call(tool, error)
102
-
103
- expect(stream.pending_tools.size).to eq(1)
104
- _, recorded_error = stream.pending_tools.first
105
- expect(recorded_error).to eq(error)
106
- end
107
-
108
- it "records pending tool call metadata for ToolUseGuard" do
109
- stream = described_class.new
110
- tool = mock_tool(
111
- id: "toolu_abc",
112
- name: "read",
113
- arguments: { "file_path" => "test.rb" },
114
- )
115
-
116
- stream.on_tool_call(tool, nil)
117
-
118
- calls = stream.pending_tool_calls
119
- expect(calls).not_to be_empty
120
- expect(calls.first).to include(
121
- id: "toolu_abc",
122
- name: "read",
123
- arguments: { "file_path" => "test.rb" },
124
- )
125
- end
126
-
127
- it "records metadata for multiple tool calls" do
128
- stream = described_class.new
129
- tool1 = mock_tool(id: "toolu_1", name: "read", arguments: { "file_path" => "a.rb" })
130
- tool2 = mock_tool(id: "toolu_2", name: "write", arguments: { "file_path" => "b.rb" })
131
-
132
- stream.on_tool_call(tool1, nil)
133
- stream.on_tool_call(tool2, nil)
134
-
135
- expect(stream.pending_tool_calls.size).to eq(2)
136
- expect(stream.pending_tool_calls.map { |c| c[:id] }).to eq(["toolu_1", "toolu_2"])
137
-
138
- expect(stream.pending_tools.size).to eq(2)
139
- expect(stream.pending_tools.map { |t, _| t }).to eq([tool1, tool2])
140
- end
141
- end
142
-
143
- describe "#clear_pending_tool_calls! and #clear_pending_tools!" do
144
- it "empties both pending_tool_calls and pending_tools" do
145
- stream = described_class.new
146
- tool = mock_tool(id: "toolu_1", name: "read")
147
-
148
- stream.on_tool_call(tool, nil)
149
- expect(stream.pending_tool_calls).not_to be_empty
150
- expect(stream.pending_tools).not_to be_empty
151
-
152
- stream.clear_pending_tool_calls!
153
- stream.clear_pending_tools!
154
- expect(stream.pending_tool_calls).to be_empty
155
- expect(stream.pending_tools).to be_empty
156
- end
157
- end
158
-
159
- describe "#on_content" do
160
- it "fires the content callback" do
161
- received = nil
162
- stream = described_class.new(on_content: ->(text) { received = text })
163
-
164
- stream.on_content("hello")
165
-
166
- expect(received).to eq("hello")
167
- end
168
- end
169
-
170
- describe "#on_reasoning_content" do
171
- it "fires the reasoning callback" do
172
- received = nil
173
- stream = described_class.new(on_reasoning: ->(text) { received = text })
174
-
175
- stream.on_reasoning_content("thinking...")
176
-
177
- expect(received).to eq("thinking...")
178
- end
179
- end
180
- end
181
- end
data/lib/brute/hooks.rb DELETED
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # Lifecycle hook system modeled after forgecode's Hook struct.
5
- #
6
- # Six lifecycle events fire during the orchestrator loop:
7
- # :start — conversation processing begins
8
- # :end — conversation processing ends
9
- # :request — before each LLM API call
10
- # :response — after each LLM response
11
- # :toolcall_start — before a tool executes
12
- # :toolcall_end — after a tool executes
13
- #
14
- # Hooks receive (event_name, context_hash) and can inspect or mutate
15
- # the orchestrator state via the context hash.
16
- module Hooks
17
- # Base class. Subclass and override #on_<event> methods.
18
- class Base
19
- def call(event, **data)
20
- method_name = :"on_#{event}"
21
- send(method_name, **data) if respond_to?(method_name, true)
22
- end
23
-
24
- private
25
-
26
- def on_start(**) = nil
27
- def on_end(**) = nil
28
- def on_request(**) = nil
29
- def on_response(**) = nil
30
- def on_toolcall_start(**) = nil
31
- def on_toolcall_end(**) = nil
32
- end
33
-
34
- # Composes multiple hooks into one, firing them in order.
35
- class Composite < Base
36
- def initialize(*hooks)
37
- @hooks = hooks
38
- end
39
-
40
- def call(event, **data)
41
- @hooks.each { |h| h.call(event, **data) }
42
- end
43
-
44
- def <<(hook)
45
- @hooks << hook
46
- self
47
- end
48
- end
49
-
50
- # Logs lifecycle events to a logger.
51
- class Logging < Base
52
- def initialize(logger)
53
- @logger = logger
54
- end
55
-
56
- private
57
-
58
- def on_start(**)
59
- @logger.info("[brute] Conversation started")
60
- end
61
-
62
- def on_end(**)
63
- @logger.info("[brute] Conversation ended")
64
- end
65
-
66
- def on_request(request_count: 0, **)
67
- @logger.debug("[brute] LLM request ##{request_count}")
68
- end
69
-
70
- def on_response(tokens: nil, **)
71
- @logger.debug("[brute] LLM response (tokens: #{tokens || "?"})")
72
- end
73
-
74
- def on_toolcall_start(tool_name: nil, **)
75
- @logger.info("[brute] Tool call: #{tool_name}")
76
- end
77
-
78
- def on_toolcall_end(tool_name: nil, error: false, **)
79
- status = error ? "FAILED" : "ok"
80
- @logger.info("[brute] Tool result: #{tool_name} [#{status}]")
81
- end
82
- end
83
- end
84
- end