brute 1.0.0 → 2.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/truncation"
6
+ require "async"
7
+ require "async/barrier"
8
+
9
+ module Brute
10
+ module Middleware
11
+ # Executes pending tool calls from the LLM response.
12
+ #
13
+ # Existing features (ref: opencode tool.ts wrap / truncate.ts):
14
+ #
15
+ # 1. Universal output truncation — after every tool.call(), pass the
16
+ # result string through Brute::Truncation.truncate() which enforces
17
+ # a 2000-line / 50 KB cap. This is a safety net so no single tool
18
+ # result can blow up the context window, regardless of whether the
19
+ # tool itself has internal limits.
20
+ # 2. Overflow to disk — when truncating, the full output is saved to
21
+ # a temp file under the truncation directory. The path is included
22
+ # in the truncated result with a hint.
23
+ # 3. Configurable limits — MAX_LINES / MAX_BYTES default to 2000 / 50 KB.
24
+ # 4. Skip truncation when tool already truncated — if the tool result
25
+ # already contains the truncation marker (e.g. Shell or FSSearch
26
+ # truncated internally), don't double-truncate.
27
+ #
28
+ # == Concurrency model (Async)
29
+ #
30
+ # Tool calls are executed concurrently using the `async` gem's fiber-based
31
+ # scheduler. Each tool call is dispatched as an Async::Task inside an
32
+ # Async::Barrier, so all tools run in parallel and we wait for every task
33
+ # to complete before moving on.
34
+ #
35
+ # Key design decisions:
36
+ #
37
+ # - Sync {} (not Async{}.wait) — reuses an existing event loop if one is
38
+ # already running, or creates one on demand. Blocks the caller until all
39
+ # inner work completes, which is what the middleware stack requires.
40
+ #
41
+ # - Async::Barrier — the idiomatic fan-out / join primitive. Each tool call
42
+ # becomes a child task via barrier.async; barrier.wait blocks until every
43
+ # task finishes. This is preferable to Async::Queue for a fixed batch of
44
+ # work with no producer/consumer relationship.
45
+ #
46
+ # - Deterministic result ordering — tool results are collected into an array
47
+ # during concurrent execution, then sorted back into the original
48
+ # tools_to_run key order before appending to env[:messages]. This ensures
49
+ # the LLM always sees results in a stable order regardless of which tool
50
+ # finishes first.
51
+ #
52
+ # - Fiber-safe shared state — appending to the results array from multiple
53
+ # fibers is safe because Async fibers are cooperatively scheduled (only
54
+ # one fiber runs at a time within a Sync block). No mutex needed.
55
+ #
56
+ # - FileMutationQueue compatibility — tools that mutate files use
57
+ # Brute::Queue::FileMutationQueue.serialize, which uses Ruby 3.4's
58
+ # fiber-scheduler-aware Mutex. Operations on the same file are serialized;
59
+ # operations on different files proceed in parallel.
60
+ #
61
+ class ToolCall
62
+ def initialize(app)
63
+ @app = app
64
+ end
65
+
66
+ def call(env)
67
+ @app.call(env)
68
+
69
+ tools_to_run = pending_tool_calls(env[:messages].last)
70
+ if tools_to_run.any?
71
+ available_tools = resolve_tools(env[:tools])
72
+ env[:events] << on_tool_call_start_event(tools_to_run)
73
+
74
+ results = []
75
+
76
+ Sync do
77
+ barrier = Async::Barrier.new
78
+
79
+ tools_to_run.each do |id, tool_call|
80
+ barrier.async do
81
+ tool = available_tools[tool_call.name.to_sym]
82
+ result = tool.call(tool_call.arguments)
83
+
84
+ # Coerce to String so RubyLLM::Message doesn't treat Hash results
85
+ # (e.g. Shell's {stdout:, stderr:, exit_code:}) as attachments.
86
+ content = result.is_a?(String) ? result : result.to_s
87
+
88
+ # Universal truncation safety net — skip if already truncated
89
+ unless Brute::Truncation.already_truncated?(content)
90
+ content = Brute::Truncation.truncate(content)
91
+ end
92
+
93
+ results << [id, tool_call, content]
94
+ rescue => e
95
+ # Capture the error as a tool result so the LLM can see it
96
+ # and reason about the failure, rather than crashing the
97
+ # entire middleware chain.
98
+ env[:events] << { type: :error, data: { error: e, message: e.message } }
99
+ results << [id, tool_call, "Error: #{e.class}: #{e.message}"]
100
+ end
101
+ end
102
+
103
+ barrier.wait
104
+ ensure
105
+ barrier&.cancel
106
+ end
107
+
108
+ # Append events and messages in the original tool_call order so the
109
+ # LLM sees a deterministic sequence regardless of completion order.
110
+ order = tools_to_run.keys
111
+ results.sort_by! { |id, _, _| order.index(id) }
112
+
113
+ results.each do |_id, tool_call, content|
114
+ env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
115
+ env[:messages] << RubyLLM::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
116
+ end
117
+ end
118
+
119
+ return env
120
+ end
121
+
122
+ private
123
+
124
+ def pending_tool_calls(message)
125
+ message.tool_calls.to_h.reject { |_id, tc| tc.name == "question" }
126
+ end
127
+
128
+ def resolve_tools(tools)
129
+ tools.each_with_object({}) do |tool, hash|
130
+ instance = tool.is_a?(Class) ? tool.new : tool
131
+ hash[instance.name.to_sym] = instance
132
+ end
133
+ end
134
+
135
+ def on_tool_call_start_event(pending_tools)
136
+ {
137
+ type: :tool_call_start,
138
+ data: pending_tools.map { |_id, tc|
139
+ {
140
+ name: tc.name,
141
+ call_id: tc.id,
142
+ arguments: tc.arguments
143
+ }
144
+ }
145
+ }
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ test do
152
+ require "brute/session"
153
+ require "brute/truncation"
154
+
155
+ it "passes through when no tool calls pending" do
156
+ inner = ->(env) {
157
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hi")
158
+ }
159
+ mw = Brute::Middleware::ToolCall.new(inner)
160
+ env = {
161
+ messages: Brute::Session.new,
162
+ tools: [],
163
+ events: [],
164
+ }
165
+ env[:messages].user("hello")
166
+ mw.call(env)
167
+ env[:messages].last.content.should == "hi"
168
+ end
169
+
170
+ # --- Universal output truncation ---
171
+
172
+ it "truncates large tool results via Truncation" do
173
+ # A fake tool that returns a huge string
174
+ big_tool = Class.new(RubyLLM::Tool) do
175
+ description "test tool"
176
+ param :input, type: "string", desc: "input"
177
+ def name; "big_tool"; end
178
+ def execute(input:)
179
+ "line\n" * 3000
180
+ end
181
+ end
182
+
183
+ call_id = "tc_1"
184
+ tool_calls = {
185
+ call_id => RubyLLM::ToolCall.new(
186
+ id: call_id,
187
+ name: "big_tool",
188
+ arguments: { "input" => "go" },
189
+ )
190
+ }
191
+
192
+ inner = ->(env) {
193
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
194
+ }
195
+ mw = Brute::Middleware::ToolCall.new(inner)
196
+ env = {
197
+ messages: Brute::Session.new,
198
+ tools: [big_tool],
199
+ events: [],
200
+ }
201
+ env[:messages].user("hello")
202
+ mw.call(env)
203
+
204
+ tool_msg = env[:messages].select { |m| m.role == :tool }.last
205
+ tool_msg.content.lines.size.should.be < 2100
206
+ tool_msg.content.should =~ /truncated/i
207
+ end
208
+
209
+ # --- Skip double-truncation ---
210
+
211
+ it "does not double-truncate already-truncated output" do
212
+ # A fake tool that returns output already containing the truncation marker
213
+ pre_truncated_tool = Class.new(RubyLLM::Tool) do
214
+ description "test tool"
215
+ param :input, type: "string", desc: "input"
216
+ def name; "pre_truncated_tool"; end
217
+ def execute(input:)
218
+ "some result\n[Output truncated: showing 100 of 5000 lines]"
219
+ end
220
+ end
221
+
222
+ call_id = "tc_2"
223
+ tool_calls = {
224
+ call_id => RubyLLM::ToolCall.new(
225
+ id: call_id,
226
+ name: "pre_truncated_tool",
227
+ arguments: { "input" => "go" },
228
+ )
229
+ }
230
+
231
+ inner = ->(env) {
232
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
233
+ }
234
+ mw = Brute::Middleware::ToolCall.new(inner)
235
+ env = {
236
+ messages: Brute::Session.new,
237
+ tools: [pre_truncated_tool],
238
+ events: [],
239
+ }
240
+ env[:messages].user("hello")
241
+ mw.call(env)
242
+
243
+ tool_msg = env[:messages].select { |m| m.role == :tool }.last
244
+ # Should contain exactly one truncation marker, not two
245
+ tool_msg.content.scan(/Output truncated/).size.should == 1
246
+ end
247
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Records tool calls the LLM requested as span events.
9
+ #
10
+ # Runs POST-call: after the LLM responds, inspects ctx.functions
11
+ # for any tool calls the model wants to make, and adds a span event
12
+ # for each one with the tool name, call ID, and arguments.
13
+ #
14
+ class OtelToolCalls
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ def call(env)
20
+ #response = @app.call(env)
21
+
22
+ #span = env[:span]
23
+ #if span
24
+ # functions = env[:pending_functions]
25
+ # if functions && !functions.empty?
26
+ # span.set_attribute("brute.tool_calls.count", functions.size)
27
+
28
+ # functions.each do |fn|
29
+ # attrs = {
30
+ # "tool.name" => fn.name.to_s,
31
+ # "tool.id" => fn.id.to_s,
32
+ # }
33
+ # args = fn.arguments
34
+ # attrs["tool.arguments"] = args.to_json if args
35
+ # span.add_event("tool_call", attributes: attrs)
36
+ # end
37
+ # end
38
+ #end
39
+
40
+ #response
41
+ @app.call(env)
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ test do
48
+ # not implemented
49
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Records tool results as span events.
9
+ #
10
+ # Tool results are now appended directly to env[:messages] as :tool
11
+ # role messages. This middleware can inspect the last messages to
12
+ # record them as span events.
13
+ #
14
+ class OtelToolResults
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ def call(env)
20
+ #span = env[:span]
21
+
22
+ #if span && (results = env[:tool_results])
23
+ # span.set_attribute("brute.tool_results.count", results.size)
24
+
25
+ # results.each do |name, value|
26
+ # error = value.is_a?(Hash) && value[:error]
27
+ # attrs = { "tool.name" => name.to_s }
28
+ # if error
29
+ # attrs["tool.status"] = "error"
30
+ # attrs["tool.error"] = value[:error].to_s
31
+ # else
32
+ # attrs["tool.status"] = "ok"
33
+ # end
34
+ # span.add_event("tool_result", attributes: attrs)
35
+ # end
36
+ #end
37
+
38
+ @app.call(env)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ test do
45
+ # not implemented
46
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Terminal middleware. Calls the LLM with the current conversation,
9
+ # appends the response to the session, and fires events along the way.
10
+ #
11
+ class LLMCall
12
+ def call(env)
13
+
14
+ available_tools = env[:tools].each_with_object({}) do |tool, hash|
15
+ instance = tool.is_a?(Class) ? tool.new : tool
16
+ hash[instance.name.to_sym] = instance
17
+ end
18
+
19
+ completion_options = {
20
+ model: RubyLLM.models.find(env[:model], env[:provider]),
21
+ tools: available_tools,
22
+ temperature: env.fetch(:temperature, 0.7),
23
+ }
24
+
25
+ complete(completion_options, env).then do |response|
26
+ env[:messages] << response
27
+ end
28
+
29
+ env
30
+ end
31
+
32
+ private
33
+
34
+ def complete(kwargs, env)
35
+ provider_client = RubyLLM::Provider.resolve(env[:provider]).new(Brute.config)
36
+
37
+ if env[:streaming] == true
38
+ provider_client.complete(env[:messages], **kwargs) do |chunk|
39
+ if chunk.content && !chunk.content.to_s.empty?
40
+ env[:events] << { type: :content, data: chunk.content.to_s }
41
+ end
42
+
43
+ if chunk.respond_to?(:thinking) && chunk.thinking&.respond_to?(:text) && chunk.thinking.text
44
+ env[:events] << { type: :reasoning, data: chunk.thinking.text }
45
+ end
46
+ end
47
+ else
48
+ provider_client.complete(env[:messages], **kwargs).then do |response|
49
+ if response.content.present?
50
+ env[:events] << { type: :content, data: response.content }
51
+ end
52
+ response
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ test do
61
+ # not implemented
62
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'brute'
5
+
6
+ module Brute
7
+ module Middleware
8
+ class EventHandler
9
+ def initialize(app, handler_class:, **opts)
10
+ @app = app
11
+ @handler_class = handler_class
12
+ @opts = opts
13
+ end
14
+
15
+ def call(env)
16
+ env[:events] = @handler_class.new(env[:events], **@opts)
17
+ @app.call(env)
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ test do
24
+ # not implemented
25
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ class UserQueue
9
+
10
+ # Useful for testing...
11
+ # App will keep looping till all inputs are drained.
12
+ #
13
+ def initialize(app, inputs: [])
14
+ @app = app
15
+ @inputs = inputs
16
+ end
17
+
18
+ def call(env)
19
+ if @inputs.any?
20
+ while inputs.any?
21
+ inputs.shift.then do |input|
22
+ @app.call(env)
23
+ end
24
+ end
25
+ else
26
+ @app.call
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ test do
34
+ # not implemented
35
+ end
@@ -4,79 +4,48 @@ require "bundler/setup"
4
4
  require "brute"
5
5
 
6
6
  module Brute
7
- # Rack-style middleware pipeline for LLM calls.
7
+ # Generic middleware machinery. Builds a chain of middleware around
8
+ # a terminal app, exposes `call(env)` to invoke it.
8
9
  #
9
- # Each middleware wraps the next, forming an onion model:
10
+ # Subclasses (Agent, Tool) override `call` to translate their public
11
+ # arguments into an env hash, then delegate to super.
10
12
  #
11
- # Tracing Retry → DoomLoop → Reasoning → [LLM Call] → Reasoning → DoomLoop → Retry → Tracing
12
- #
13
- # The innermost "app" is the actual LLM call. Each middleware can:
14
- # - Modify the env (context, params) BEFORE the call (pre-processing)
15
- # - Modify or inspect the response AFTER the call (post-processing)
16
- # - Short-circuit (return without calling inner app)
17
- # - Retry (call inner app multiple times)
18
- #
19
- # ## The env hash
20
- #
21
- # {
22
- # provider: LLM::Provider, # the LLM provider
23
- # model: String|nil, # model override
24
- # input: <prompt/results>, # what to pass to LLM
25
- # tools: [Tool, ...], # tool classes
26
- # messages: [LLM::Message], # conversation history (Brute-owned)
27
- # stream: AgentStream|nil, # streaming bridge
28
- # params: {}, # extra LLM call params
29
- # metadata: {}, # shared scratchpad for middleware state
30
- # callbacks: {}, # :on_content, :on_tool_call_start, :on_tool_result
31
- # tool_results: Array|nil, # tool results from previous iteration
32
- # streaming: Boolean, # whether streaming is active
33
- # should_exit: Hash|nil, # exit signal from middleware
34
- # pending_functions: [LLM::Function], # tool calls from last LLM response
35
- # }
36
- #
37
- # ## The response
38
- #
39
- # The return value of call(env) is the LLM::Message from context.talk().
40
- #
41
- # ## Building a pipeline
42
- #
43
- # pipeline = Brute::Pipeline.new do
44
- # use Brute::Middleware::Tracing, logger: logger
45
- # use Brute::Middleware::Retry, max_attempts: 3
46
- # use Brute::Middleware::SessionPersistence, session: session
47
- # run Brute::Middleware::LLMCall.new
13
+ # class MyPipeline < Brute::Pipeline
14
+ # def call(input)
15
+ # env = { input: input, output: nil }
16
+ # super(env)
17
+ # env[:output]
18
+ # end
48
19
  # end
49
20
  #
50
- # response = pipeline.call(env)
51
- #
52
21
  class Pipeline
53
22
  def initialize(&block)
54
23
  @middlewares = []
55
24
  @app = nil
56
- instance_eval(&block) if block
25
+ instance_eval(&block) if block_given?
57
26
  end
58
27
 
59
28
  # Register a middleware class.
60
29
  # The class must implement `initialize(app, *args, **kwargs)` and `call(env)`.
61
30
  def use(klass, *args, **kwargs, &block)
62
- @middlewares << [klass, args, kwargs, block]
63
- self
31
+ tap { @middlewares << [klass, args, kwargs, block] }
64
32
  end
65
33
 
66
34
  # Set the terminal app (innermost handler).
35
+ # Accepts an instance (anything responding to #call(env)) or a class.
67
36
  def run(app)
68
- @app = app
69
- self
37
+ tap { @app = app }
70
38
  end
71
39
 
72
- # Build the full middleware chain and call it.
40
+ # Invoke the chain. Subclasses typically override this to shape env
41
+ # and extract a return value.
73
42
  def call(env)
74
43
  build.call(env)
75
44
  end
76
45
 
77
46
  # Build the chain without calling it. Useful for inspection or caching.
78
47
  def build
79
- raise "Pipeline has no terminal app — call `run` first" unless @app
48
+ raise "Stack has no terminal app — call `run` first" unless @app
80
49
 
81
50
  @middlewares.reverse.inject(@app) do |inner, (klass, args, kwargs, block)|
82
51
  if block
@@ -86,75 +55,43 @@ module Brute
86
55
  end
87
56
  end
88
57
  end
58
+
59
+ # Default null sink for env[:events] — swallows anything pushed to it.
60
+ class NullSink
61
+ def <<(_event); self; end
62
+ end
89
63
  end
90
64
  end
91
65
 
92
66
  test do
93
- require_relative "../../spec/support/mock_provider"
94
- require_relative "../../spec/support/mock_response"
95
-
96
- def make_env(provider:, input:)
97
- { provider: provider, model: nil, input: input, tools: [], messages: [],
98
- stream: nil, params: {}, metadata: {}, callbacks: {}, tool_results: nil,
99
- streaming: false, should_exit: nil, pending_functions: [] }
100
- end
101
-
102
- it "full pipeline passes env through all middleware" do
103
- provider = MockProvider.new
104
- session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
105
- compactor = Object.new
106
- compactor.define_singleton_method(:should_compact?) { |_msgs, **_| false }
107
- log_output = StringIO.new
108
-
109
- pipeline = Brute::Pipeline.new
110
- pipeline.use(Brute::Middleware::Tracing, logger: Logger.new(log_output))
111
- pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 2)
112
- pipeline.use(Brute::Middleware::SessionPersistence, session: session)
113
- pipeline.use(Brute::Middleware::TokenTracking)
114
- pipeline.use(Brute::Middleware::CompactionCheck, compactor: compactor, system_prompt: "sys")
115
- pipeline.use(Brute::Middleware::ToolErrorTracking)
116
- pipeline.use(Brute::Middleware::DoomLoopDetection, threshold: 3)
117
- pipeline.use(Brute::Middleware::ToolUseGuard)
118
- pipeline.run(Brute::Middleware::LLMCall.new)
119
-
120
- env = make_env(provider: provider, input: "hello")
121
- result = pipeline.call(env)
122
- result.should.not.be.nil
123
- end
124
-
125
- it "pipeline populates timing metadata" do
126
- provider = MockProvider.new
127
- session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
67
+ it "builds and calls a chain" do
68
+ seen = []
69
+ inc = Class.new do
70
+ def initialize(app, label:); @app = app; @label = label; end
71
+ def call(env); env[:trace] << @label; @app.call(env); env[:trace] << "#{@label}-after"; end
72
+ end
128
73
 
129
- pipeline = Brute::Pipeline.new
130
- pipeline.use(Brute::Middleware::Tracing, logger: Logger.new(StringIO.new))
131
- pipeline.use(Brute::Middleware::SessionPersistence, session: session)
132
- pipeline.use(Brute::Middleware::TokenTracking)
133
- pipeline.run(Brute::Middleware::LLMCall.new)
74
+ pipeline = Brute::Pipeline.new do
75
+ use inc, label: "outer"
76
+ use inc, label: "inner"
77
+ run ->(env) { env[:trace] << "core" }
78
+ end
134
79
 
135
- env = make_env(provider: provider, input: "hello")
80
+ env = { trace: [] }
136
81
  pipeline.call(env)
137
- env[:metadata][:timing][:llm_call_count].should == 1
82
+ env[:trace].should == ["outer", "inner", "core", "inner-after", "outer-after"]
138
83
  end
139
84
 
140
- it "pipeline populates token metadata" do
141
- provider = MockProvider.new
142
- session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
143
-
144
- pipeline = Brute::Pipeline.new
145
- pipeline.use(Brute::Middleware::Tracing, logger: Logger.new(StringIO.new))
146
- pipeline.use(Brute::Middleware::SessionPersistence, session: session)
147
- pipeline.use(Brute::Middleware::TokenTracking)
148
- pipeline.run(Brute::Middleware::LLMCall.new)
149
-
150
- env = make_env(provider: provider, input: "hello")
151
- pipeline.call(env)
152
- env[:metadata][:tokens][:total_input].should.be > 0
85
+ it "raises when run was never called" do
86
+ lambda { Brute::Pipeline.new.call({}) }.should.raise(RuntimeError)
153
87
  end
154
88
 
155
- it "raises when no terminal app is set" do
156
- pipeline = Brute::Pipeline.new
157
- pipeline.use(Brute::Middleware::TokenTracking)
158
- lambda { pipeline.call({}) }.should.raise(RuntimeError)
89
+ it "accepts a callable as the terminal app" do
90
+ pipeline = Brute::Pipeline.new do
91
+ run ->(env) { env[:result] = 42 }
92
+ end
93
+ env = {}
94
+ pipeline.call(env)
95
+ env[:result].should == 42
159
96
  end
160
97
  end
@@ -8,10 +8,10 @@ module Brute
8
8
  module Skills
9
9
  def self.call(ctx)
10
10
  cwd = ctx[:cwd] || Dir.pwd
11
- skills = Skill.all(cwd: cwd)
11
+ skills = Brute::Skill.all(cwd: cwd)
12
12
  return nil if skills.empty?
13
13
 
14
- listing = Skill.fmt(skills)
14
+ listing = Brute::Skill.fmt(skills)
15
15
 
16
16
  <<~TXT
17
17
  Skills provide specialized instructions and workflows for specific tasks.