brute 0.4.1 → 1.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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +14 -0
- data/lib/brute/diff.rb +18 -28
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/store/message_store.rb +362 -0
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
data/lib/brute/orchestrator.rb
DELETED
|
@@ -1,550 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async"
|
|
4
|
-
require "async/barrier"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
# The core agent loop. Drives the cycle of:
|
|
8
|
-
#
|
|
9
|
-
# prompt → LLM → tool calls → execute → send results → repeat
|
|
10
|
-
#
|
|
11
|
-
# All cross-cutting concerns (retry, compaction, doom loop detection,
|
|
12
|
-
# token tracking, session persistence, tracing, reasoning) are implemented
|
|
13
|
-
# as Rack-style middleware in the Pipeline. The orchestrator is now a
|
|
14
|
-
# thin loop that:
|
|
15
|
-
#
|
|
16
|
-
# 1. Sends input through the pipeline (which wraps the LLM call)
|
|
17
|
-
# 2. Executes any tool calls the LLM requested
|
|
18
|
-
# 3. Repeats until done or a limit is hit
|
|
19
|
-
#
|
|
20
|
-
# Tool execution is always deferred until after the LLM response (including
|
|
21
|
-
# streaming) completes. Tools then run concurrently with each other via
|
|
22
|
-
# Async::Barrier. on_tool_call_start fires once with the full batch before
|
|
23
|
-
# execution begins; on_tool_result fires per-tool as each finishes.
|
|
24
|
-
#
|
|
25
|
-
class Orchestrator
|
|
26
|
-
MAX_REQUESTS_PER_TURN = 100
|
|
27
|
-
|
|
28
|
-
attr_reader :context, :session, :pipeline, :env, :barrier, :message_store
|
|
29
|
-
|
|
30
|
-
def initialize(
|
|
31
|
-
provider:,
|
|
32
|
-
model: nil,
|
|
33
|
-
tools: Brute::TOOLS,
|
|
34
|
-
cwd: Dir.pwd,
|
|
35
|
-
session: nil,
|
|
36
|
-
compactor_opts: {},
|
|
37
|
-
reasoning: {},
|
|
38
|
-
agent_name: nil,
|
|
39
|
-
on_content: nil,
|
|
40
|
-
on_reasoning: nil,
|
|
41
|
-
on_tool_call_start: nil,
|
|
42
|
-
on_tool_result: nil,
|
|
43
|
-
on_question: nil,
|
|
44
|
-
logger: nil
|
|
45
|
-
)
|
|
46
|
-
@provider = provider
|
|
47
|
-
@model = model
|
|
48
|
-
@agent_name = agent_name
|
|
49
|
-
@tool_classes = tools
|
|
50
|
-
@cwd = cwd
|
|
51
|
-
@session = session || Session.new
|
|
52
|
-
@logger = logger || Logger.new($stderr, level: Logger::INFO)
|
|
53
|
-
@message_store = @session.message_store
|
|
54
|
-
|
|
55
|
-
# Build system prompt via deferred builder
|
|
56
|
-
@system_prompt_builder = SystemPrompt.default
|
|
57
|
-
@system_prompt = @system_prompt_builder.prepare(
|
|
58
|
-
provider_name: @provider&.name,
|
|
59
|
-
model_name: @model || @provider&.default_model,
|
|
60
|
-
cwd: @cwd,
|
|
61
|
-
custom_rules: load_custom_rules,
|
|
62
|
-
agent: @agent_name,
|
|
63
|
-
).to_s
|
|
64
|
-
|
|
65
|
-
# Initialize the LLM context (with streaming when callbacks provided)
|
|
66
|
-
@stream = if on_content || on_reasoning
|
|
67
|
-
AgentStream.new(
|
|
68
|
-
on_content: on_content,
|
|
69
|
-
on_reasoning: on_reasoning,
|
|
70
|
-
on_question: on_question,
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
ctx_opts = { tools: @tool_classes }
|
|
74
|
-
ctx_opts[:model] = @model if @model
|
|
75
|
-
ctx_opts[:stream] = @stream if @stream
|
|
76
|
-
@context = LLM::Context.new(@provider, **ctx_opts)
|
|
77
|
-
|
|
78
|
-
# Build the middleware pipeline
|
|
79
|
-
compactor = Compactor.new(provider, **compactor_opts)
|
|
80
|
-
@pipeline = build_pipeline(
|
|
81
|
-
compactor: compactor,
|
|
82
|
-
session: @session,
|
|
83
|
-
logger: @logger,
|
|
84
|
-
reasoning: reasoning,
|
|
85
|
-
message_store: @message_store,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
# The shared env hash — passed to every pipeline.call()
|
|
89
|
-
@env = {
|
|
90
|
-
context: @context,
|
|
91
|
-
provider: @provider,
|
|
92
|
-
tools: @tool_classes,
|
|
93
|
-
input: nil,
|
|
94
|
-
params: {},
|
|
95
|
-
metadata: {},
|
|
96
|
-
tool_results: nil,
|
|
97
|
-
streaming: !!@stream,
|
|
98
|
-
callbacks: {
|
|
99
|
-
on_content: on_content,
|
|
100
|
-
on_reasoning: on_reasoning,
|
|
101
|
-
on_tool_call_start: on_tool_call_start,
|
|
102
|
-
on_tool_result: on_tool_result,
|
|
103
|
-
on_question: on_question,
|
|
104
|
-
},
|
|
105
|
-
}
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Run a single user turn. Loops internally until the agent either
|
|
109
|
-
# completes (no more tool calls) or hits a limit.
|
|
110
|
-
#
|
|
111
|
-
# Returns the final assistant response.
|
|
112
|
-
def run(user_message)
|
|
113
|
-
unless @provider
|
|
114
|
-
raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: opencode_zen)"
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
@request_count = 0
|
|
118
|
-
|
|
119
|
-
# Build the initial prompt with system message on first turn
|
|
120
|
-
input = if first_turn?
|
|
121
|
-
@context.prompt do |p|
|
|
122
|
-
p.system @system_prompt
|
|
123
|
-
p.user user_message
|
|
124
|
-
end
|
|
125
|
-
else
|
|
126
|
-
user_message
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# --- First LLM call ---
|
|
130
|
-
@env[:input] = input
|
|
131
|
-
@env[:tool_results] = nil
|
|
132
|
-
last_response = @pipeline.call(@env)
|
|
133
|
-
sync_context!
|
|
134
|
-
|
|
135
|
-
# --- Agent loop ---
|
|
136
|
-
loop do
|
|
137
|
-
# Collect pending tools from either source:
|
|
138
|
-
# - Streaming: AgentStream deferred tools (collected during stream)
|
|
139
|
-
# - Non-streaming: ctx.functions (populated by llm.rb after response)
|
|
140
|
-
pending = collect_pending_tools
|
|
141
|
-
break if pending.empty?
|
|
142
|
-
|
|
143
|
-
# Fire on_tool_call_start ONCE with the full batch
|
|
144
|
-
on_start = @env.dig(:callbacks, :on_tool_call_start)
|
|
145
|
-
on_start&.call(pending.map { |tool, _| { name: tool.name, arguments: tool.arguments } })
|
|
146
|
-
|
|
147
|
-
# Separate errors (tool not found) from executable tools
|
|
148
|
-
errors = pending.select { |_, err| err }
|
|
149
|
-
executable = pending.reject { |_, err| err }.map(&:first)
|
|
150
|
-
|
|
151
|
-
# Execute tools concurrently, collect results
|
|
152
|
-
results = execute_tool_calls(executable)
|
|
153
|
-
|
|
154
|
-
# Append error results (tool not found, etc.)
|
|
155
|
-
errors.each do |_, err|
|
|
156
|
-
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
157
|
-
on_result&.call(err.name, result_value(err))
|
|
158
|
-
results << err
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Send results back through the pipeline
|
|
162
|
-
@env[:input] = results
|
|
163
|
-
@env[:tool_results] = extract_tool_result_pairs(results)
|
|
164
|
-
last_response = @pipeline.call(@env)
|
|
165
|
-
sync_context!
|
|
166
|
-
|
|
167
|
-
@request_count += 1
|
|
168
|
-
|
|
169
|
-
# Check limits
|
|
170
|
-
break if !has_pending_tools?
|
|
171
|
-
break if @request_count >= MAX_REQUESTS_PER_TURN
|
|
172
|
-
break if @env[:metadata][:tool_error_limit_reached]
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
last_response
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
private
|
|
179
|
-
|
|
180
|
-
# ------------------------------------------------------------------
|
|
181
|
-
# Pipeline construction
|
|
182
|
-
# ------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
def build_pipeline(compactor:, session:, logger:, reasoning:, message_store:)
|
|
185
|
-
sys_prompt = @system_prompt
|
|
186
|
-
tools = @tool_classes
|
|
187
|
-
stream = @stream
|
|
188
|
-
|
|
189
|
-
Pipeline.new do
|
|
190
|
-
# OTel span lifecycle (outermost — creates env[:span])
|
|
191
|
-
use Middleware::OTel::Span
|
|
192
|
-
|
|
193
|
-
# Timing and logging
|
|
194
|
-
use Middleware::Tracing, logger: logger
|
|
195
|
-
|
|
196
|
-
# OTel: record tool results being sent back (pre-call)
|
|
197
|
-
use Middleware::OTel::ToolResults
|
|
198
|
-
|
|
199
|
-
# Retry transient errors (wraps everything below)
|
|
200
|
-
use Middleware::Retry
|
|
201
|
-
|
|
202
|
-
# Save after each successful LLM call
|
|
203
|
-
use Middleware::SessionPersistence, session: session
|
|
204
|
-
|
|
205
|
-
# Record structured messages in OpenCode {info, parts} format
|
|
206
|
-
use Middleware::MessageTracking, store: message_store
|
|
207
|
-
|
|
208
|
-
# Track cumulative token usage
|
|
209
|
-
use Middleware::TokenTracking
|
|
210
|
-
|
|
211
|
-
# OTel: record token usage from response (post-call)
|
|
212
|
-
use Middleware::OTel::TokenUsage
|
|
213
|
-
|
|
214
|
-
# Check context size and compact if needed
|
|
215
|
-
use Middleware::CompactionCheck,
|
|
216
|
-
compactor: compactor,
|
|
217
|
-
system_prompt: sys_prompt,
|
|
218
|
-
tools: tools,
|
|
219
|
-
stream: stream
|
|
220
|
-
|
|
221
|
-
# Track per-tool errors
|
|
222
|
-
use Middleware::ToolErrorTracking
|
|
223
|
-
|
|
224
|
-
# Detect and break doom loops (pre-call)
|
|
225
|
-
use Middleware::DoomLoopDetection
|
|
226
|
-
|
|
227
|
-
# Handle reasoning params and model-switch normalization (pre-call)
|
|
228
|
-
use Middleware::ReasoningNormalizer, **reasoning unless reasoning.empty?
|
|
229
|
-
|
|
230
|
-
# Guard against tool-only responses dropping the assistant message
|
|
231
|
-
use Middleware::ToolUseGuard
|
|
232
|
-
|
|
233
|
-
# OTel: record tool calls the LLM requested (post-call, after ToolUseGuard)
|
|
234
|
-
use Middleware::OTel::ToolCalls
|
|
235
|
-
|
|
236
|
-
# Innermost: the actual LLM call
|
|
237
|
-
run Middleware::LLMCall.new
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# ------------------------------------------------------------------
|
|
242
|
-
# Pending tool collection
|
|
243
|
-
# ------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
# Check whether there are pending tools without consuming them.
|
|
246
|
-
def has_pending_tools?
|
|
247
|
-
return true if @stream&.pending_tools&.any?
|
|
248
|
-
return true if @context.functions.any?
|
|
249
|
-
false
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
# Collect pending tools from the stream (streaming) or context (non-streaming).
|
|
253
|
-
# Returns an array of [tool, error_or_nil] pairs.
|
|
254
|
-
# Clears the stream's deferred state after consumption.
|
|
255
|
-
def collect_pending_tools
|
|
256
|
-
if @stream&.pending_tools&.any?
|
|
257
|
-
tools = @stream.pending_tools.dup
|
|
258
|
-
@stream.clear_pending_tools!
|
|
259
|
-
tools
|
|
260
|
-
elsif @context.functions.any?
|
|
261
|
-
@context.functions.to_a.map { |fn| [fn, nil] }
|
|
262
|
-
else
|
|
263
|
-
[]
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# ------------------------------------------------------------------
|
|
268
|
-
# Tool execution
|
|
269
|
-
# ------------------------------------------------------------------
|
|
270
|
-
|
|
271
|
-
def execute_tool_calls(functions)
|
|
272
|
-
return [] if functions.empty?
|
|
273
|
-
|
|
274
|
-
# Questions block execution — they must complete before other tools
|
|
275
|
-
# run, since the LLM may need the answer to inform subsequent work.
|
|
276
|
-
# Execute any question tools first (sequentially), then dispatch
|
|
277
|
-
# the remaining tools concurrently.
|
|
278
|
-
questions, others = functions.partition { |fn| fn.name == "question" }
|
|
279
|
-
|
|
280
|
-
results = []
|
|
281
|
-
results.concat(execute_sequential(questions)) if questions.any?
|
|
282
|
-
if others.size <= 1
|
|
283
|
-
results.concat(execute_sequential(others))
|
|
284
|
-
else
|
|
285
|
-
results.concat(execute_parallel(others))
|
|
286
|
-
end
|
|
287
|
-
results
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Run a single tool call synchronously.
|
|
291
|
-
def execute_sequential(functions)
|
|
292
|
-
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
293
|
-
on_question = @env.dig(:callbacks, :on_question)
|
|
294
|
-
|
|
295
|
-
functions.map do |fn|
|
|
296
|
-
Thread.current[:on_question] = on_question
|
|
297
|
-
result = fn.call
|
|
298
|
-
on_result&.call(fn.name, result_value(result))
|
|
299
|
-
result
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Run all pending tool calls concurrently via Async::Barrier.
|
|
304
|
-
#
|
|
305
|
-
# Each tool runs in its own fiber. File-mutating tools are safe because
|
|
306
|
-
# they go through FileMutationQueue, whose Mutex is fiber-scheduler-aware
|
|
307
|
-
# in Ruby 3.4 — a fiber blocked on a per-file mutex yields to other
|
|
308
|
-
# fibers instead of blocking the thread.
|
|
309
|
-
#
|
|
310
|
-
# The barrier is stored in @barrier so abort! can cancel in-flight tools.
|
|
311
|
-
#
|
|
312
|
-
def execute_parallel(functions)
|
|
313
|
-
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
314
|
-
on_question = @env.dig(:callbacks, :on_question)
|
|
315
|
-
|
|
316
|
-
results = Array.new(functions.size)
|
|
317
|
-
|
|
318
|
-
Async do
|
|
319
|
-
@barrier = Async::Barrier.new
|
|
320
|
-
|
|
321
|
-
functions.each_with_index do |fn, i|
|
|
322
|
-
@barrier.async do
|
|
323
|
-
Thread.current[:on_question] = on_question
|
|
324
|
-
results[i] = fn.call
|
|
325
|
-
r = results[i]
|
|
326
|
-
on_result&.call(r.name, result_value(r))
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
@barrier.wait
|
|
331
|
-
ensure
|
|
332
|
-
@barrier&.stop
|
|
333
|
-
@barrier = nil
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
results
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
public
|
|
340
|
-
|
|
341
|
-
# Cancel any in-flight tool execution. Safe to call from a signal
|
|
342
|
-
# handler, another thread, or an interface layer (TUI, web, RPC).
|
|
343
|
-
#
|
|
344
|
-
# When called, Async::Stop is raised in each running fiber, unwinding
|
|
345
|
-
# through ensure blocks — so FileMutationQueue mutexes release cleanly
|
|
346
|
-
# and SnapshotStore stays consistent.
|
|
347
|
-
#
|
|
348
|
-
def abort!
|
|
349
|
-
@barrier&.stop
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
private
|
|
353
|
-
|
|
354
|
-
# ------------------------------------------------------------------
|
|
355
|
-
# Helpers
|
|
356
|
-
# ------------------------------------------------------------------
|
|
357
|
-
|
|
358
|
-
# After a pipeline call, the compaction middleware may have replaced
|
|
359
|
-
# the context. Sync our local reference.
|
|
360
|
-
def sync_context!
|
|
361
|
-
@context = @env[:context]
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
def first_turn?
|
|
365
|
-
@context.messages.to_a.empty?
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def result_value(result)
|
|
369
|
-
result.respond_to?(:value) ? result.value : result
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# Build [name, value] pairs from tool results for ToolErrorTracking.
|
|
373
|
-
def extract_tool_result_pairs(results)
|
|
374
|
-
results.filter_map do |r|
|
|
375
|
-
name = r.respond_to?(:name) ? r.name : "unknown"
|
|
376
|
-
val = result_value(r)
|
|
377
|
-
[name, val]
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# Load AGENTS.md or .brute/rules from the working directory.
|
|
382
|
-
def load_custom_rules
|
|
383
|
-
candidates = [
|
|
384
|
-
File.join(@cwd, "AGENTS.md"),
|
|
385
|
-
File.join(@cwd, ".brute", "rules.md"),
|
|
386
|
-
]
|
|
387
|
-
found = candidates.find { |p| File.exist?(p) }
|
|
388
|
-
found ? File.read(found) : nil
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
if __FILE__ == $0
|
|
394
|
-
require_relative "../../spec/spec_helper"
|
|
395
|
-
|
|
396
|
-
RSpec.describe Brute::Orchestrator, "system prompt" do
|
|
397
|
-
let(:provider) { MockProvider.new }
|
|
398
|
-
|
|
399
|
-
def build_orchestrator(agent_name: nil, cwd: Dir.pwd)
|
|
400
|
-
described_class.new(
|
|
401
|
-
provider: provider,
|
|
402
|
-
model: "test-model",
|
|
403
|
-
tools: [],
|
|
404
|
-
cwd: cwd,
|
|
405
|
-
agent_name: agent_name,
|
|
406
|
-
logger: Logger.new(File::NULL),
|
|
407
|
-
)
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def system_prompt_for(orchestrator)
|
|
411
|
-
orchestrator.instance_variable_get(:@system_prompt)
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# ── Build mode (default) ──
|
|
415
|
-
|
|
416
|
-
context "build mode (default, agent_name: nil)" do
|
|
417
|
-
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: nil)) }
|
|
418
|
-
|
|
419
|
-
it "does NOT contain PlanReminder" do
|
|
420
|
-
expect(prompt).not_to include("Plan Mode - System Reminder")
|
|
421
|
-
expect(prompt).not_to include("READ-ONLY")
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
it "does NOT contain BuildSwitch" do
|
|
425
|
-
expect(prompt).not_to include("operational mode has changed")
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
it "does NOT contain MaxSteps" do
|
|
429
|
-
expect(prompt).not_to include("MAXIMUM STEPS REACHED")
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
it "contains identity section" do
|
|
433
|
-
expect(prompt).to include("Brute")
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
it "contains environment section" do
|
|
437
|
-
expect(prompt).to include("<env>")
|
|
438
|
-
expect(prompt).to include("Working directory:")
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
context "build mode (explicit agent_name: 'build')" do
|
|
443
|
-
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "build")) }
|
|
444
|
-
|
|
445
|
-
it "does NOT contain PlanReminder" do
|
|
446
|
-
expect(prompt).not_to include("Plan Mode - System Reminder")
|
|
447
|
-
expect(prompt).not_to include("READ-ONLY")
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
it "contains identity section" do
|
|
451
|
-
expect(prompt).to include("Brute")
|
|
452
|
-
end
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
# ── Plan mode ──
|
|
456
|
-
|
|
457
|
-
context "plan mode (agent_name: 'plan')" do
|
|
458
|
-
subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "plan")) }
|
|
459
|
-
|
|
460
|
-
it "includes PlanReminder" do
|
|
461
|
-
expect(prompt).to include("Plan Mode - System Reminder")
|
|
462
|
-
expect(prompt).to include("<system-reminder>")
|
|
463
|
-
expect(prompt).to include("READ-ONLY")
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
it "includes the supersede warning" do
|
|
467
|
-
expect(prompt).to include("supersedes any other instructions")
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
it "does NOT include BuildSwitch" do
|
|
471
|
-
expect(prompt).not_to include("operational mode has changed")
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
it "still includes identity section" do
|
|
475
|
-
expect(prompt).to include("Brute")
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
it "still includes environment section" do
|
|
479
|
-
expect(prompt).to include("<env>")
|
|
480
|
-
end
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
# ── Switching from plan to build (simulating mid-session agent recreation) ──
|
|
484
|
-
|
|
485
|
-
context "switching from plan to build (new orchestrator, same session)" do
|
|
486
|
-
let(:session) { Brute::Session.new }
|
|
487
|
-
|
|
488
|
-
it "plan orchestrator has PlanReminder, build orchestrator does not" do
|
|
489
|
-
plan_orch = described_class.new(
|
|
490
|
-
provider: provider,
|
|
491
|
-
model: "test-model",
|
|
492
|
-
tools: [],
|
|
493
|
-
session: session,
|
|
494
|
-
agent_name: "plan",
|
|
495
|
-
logger: Logger.new(File::NULL),
|
|
496
|
-
)
|
|
497
|
-
plan_prompt = system_prompt_for(plan_orch)
|
|
498
|
-
expect(plan_prompt).to include("READ-ONLY")
|
|
499
|
-
|
|
500
|
-
build_orch = described_class.new(
|
|
501
|
-
provider: provider,
|
|
502
|
-
model: "test-model",
|
|
503
|
-
tools: [],
|
|
504
|
-
session: session,
|
|
505
|
-
agent_name: "build",
|
|
506
|
-
logger: Logger.new(File::NULL),
|
|
507
|
-
)
|
|
508
|
-
build_prompt = system_prompt_for(build_orch)
|
|
509
|
-
expect(build_prompt).not_to include("READ-ONLY")
|
|
510
|
-
expect(build_prompt).not_to include("Plan Mode")
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
it "build orchestrator does NOT contain BuildSwitch (agent_switched never set)" do
|
|
514
|
-
build_orch = described_class.new(
|
|
515
|
-
provider: provider,
|
|
516
|
-
model: "test-model",
|
|
517
|
-
tools: [],
|
|
518
|
-
session: session,
|
|
519
|
-
agent_name: "build",
|
|
520
|
-
logger: Logger.new(File::NULL),
|
|
521
|
-
)
|
|
522
|
-
build_prompt = system_prompt_for(build_orch)
|
|
523
|
-
# This documents the current behavior: BuildSwitch is dead code
|
|
524
|
-
expect(build_prompt).not_to include("operational mode has changed from plan to build")
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# ── Provider-specific stacks ──
|
|
529
|
-
|
|
530
|
-
context "provider-specific identity text" do
|
|
531
|
-
it "uses mock provider (falls back to default stack)" do
|
|
532
|
-
prompt = system_prompt_for(build_orchestrator)
|
|
533
|
-
# MockProvider.name returns :mock, which isn't a known stack, so falls back to "default"
|
|
534
|
-
expect(prompt).to be_a(String)
|
|
535
|
-
expect(prompt).not_to be_empty
|
|
536
|
-
end
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
# ── Working directory is embedded ──
|
|
540
|
-
|
|
541
|
-
context "cwd propagation" do
|
|
542
|
-
it "embeds the given cwd in the system prompt" do
|
|
543
|
-
Dir.mktmpdir do |dir|
|
|
544
|
-
prompt = system_prompt_for(build_orchestrator(cwd: dir))
|
|
545
|
-
expect(prompt).to include(dir)
|
|
546
|
-
end
|
|
547
|
-
end
|
|
548
|
-
end
|
|
549
|
-
end
|
|
550
|
-
end
|
data/lib/brute/session.rb
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "securerandom"
|
|
6
|
-
|
|
7
|
-
module Brute
|
|
8
|
-
# Manages session persistence. Each session is a conversation that can be
|
|
9
|
-
# saved to disk and resumed later.
|
|
10
|
-
#
|
|
11
|
-
# New directory-based layout (per-session directory):
|
|
12
|
-
#
|
|
13
|
-
# ~/.brute/sessions/{session-id}/
|
|
14
|
-
# session.meta.json # session metadata
|
|
15
|
-
# context.json # llm.rb context blob (for resumption)
|
|
16
|
-
# msg_0001.json # structured messages (OpenCode format)
|
|
17
|
-
# msg_0002.json
|
|
18
|
-
# ...
|
|
19
|
-
#
|
|
20
|
-
# Also supports the legacy flat layout for reading:
|
|
21
|
-
#
|
|
22
|
-
# ~/.brute/sessions/{session-id}.json
|
|
23
|
-
# ~/.brute/sessions/{session-id}.meta.json
|
|
24
|
-
#
|
|
25
|
-
class Session
|
|
26
|
-
attr_reader :id, :title, :path
|
|
27
|
-
|
|
28
|
-
def initialize(id: nil, dir: nil)
|
|
29
|
-
@id = id || SecureRandom.uuid
|
|
30
|
-
@base_dir = dir || File.join(Dir.home, ".brute", "sessions")
|
|
31
|
-
@session_dir = File.join(@base_dir, @id)
|
|
32
|
-
@path = File.join(@session_dir, "context.json")
|
|
33
|
-
@title = nil
|
|
34
|
-
@metadata = {}
|
|
35
|
-
FileUtils.mkdir_p(@session_dir)
|
|
36
|
-
|
|
37
|
-
# Check for legacy flat-file layout and migrate path if present
|
|
38
|
-
@legacy_path = File.join(@base_dir, "#{@id}.json")
|
|
39
|
-
@legacy_meta = File.join(@base_dir, "#{@id}.meta.json")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Returns a MessageStore for this session's structured messages.
|
|
43
|
-
def message_store
|
|
44
|
-
@message_store ||= MessageStore.new(session_id: @id, dir: @session_dir)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Save a context to this session.
|
|
48
|
-
def save(context, title: nil, metadata: {})
|
|
49
|
-
@title = title if title
|
|
50
|
-
@metadata.merge!(metadata)
|
|
51
|
-
|
|
52
|
-
# Use llm.rb's built-in serialization for context (used for resumption)
|
|
53
|
-
context.save(path: @path)
|
|
54
|
-
|
|
55
|
-
# Write metadata sidecar
|
|
56
|
-
save_meta
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Restore a context from this session.
|
|
60
|
-
# Returns true if restored successfully, false if no session file found.
|
|
61
|
-
def restore(context)
|
|
62
|
-
# Try new layout first, then legacy
|
|
63
|
-
ctx_path = if File.exist?(@path)
|
|
64
|
-
@path
|
|
65
|
-
elsif File.exist?(@legacy_path)
|
|
66
|
-
@legacy_path
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
return false unless ctx_path
|
|
70
|
-
|
|
71
|
-
context.restore(path: ctx_path)
|
|
72
|
-
|
|
73
|
-
# Load metadata
|
|
74
|
-
load_meta
|
|
75
|
-
|
|
76
|
-
true
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# List all saved sessions, newest first.
|
|
80
|
-
# Scans both new directory-based layout and legacy flat files.
|
|
81
|
-
def self.list(dir: nil)
|
|
82
|
-
dir ||= File.join(Dir.home, ".brute", "sessions")
|
|
83
|
-
return [] unless File.directory?(dir)
|
|
84
|
-
|
|
85
|
-
sessions = {}
|
|
86
|
-
|
|
87
|
-
# New layout: {id}/session.meta.json
|
|
88
|
-
Dir.glob(File.join(dir, "*", "session.meta.json")).each do |meta_path|
|
|
89
|
-
data = JSON.parse(File.read(meta_path), symbolize_names: true)
|
|
90
|
-
id = data[:id]
|
|
91
|
-
next unless id
|
|
92
|
-
sessions[id] = {
|
|
93
|
-
id: id,
|
|
94
|
-
title: data[:title],
|
|
95
|
-
saved_at: data[:saved_at],
|
|
96
|
-
path: File.join(File.dirname(meta_path), "context.json"),
|
|
97
|
-
}
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Legacy layout: {id}.meta.json (only if not already found)
|
|
101
|
-
Dir.glob(File.join(dir, "*.meta.json")).each do |meta_path|
|
|
102
|
-
# Skip files inside session subdirectories
|
|
103
|
-
next if meta_path.include?("/session.meta.json")
|
|
104
|
-
data = JSON.parse(File.read(meta_path), symbolize_names: true)
|
|
105
|
-
id = data[:id]
|
|
106
|
-
next unless id
|
|
107
|
-
next if sessions.key?(id) # new layout takes precedence
|
|
108
|
-
sessions[id] = {
|
|
109
|
-
id: id,
|
|
110
|
-
title: data[:title],
|
|
111
|
-
saved_at: data[:saved_at],
|
|
112
|
-
path: meta_path.sub(/\.meta\.json$/, ".json"),
|
|
113
|
-
}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
sessions.values.sort_by { |s| s[:saved_at] || "" }.reverse
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Delete a session from disk (both new and legacy layouts).
|
|
120
|
-
def delete
|
|
121
|
-
# New layout: remove the whole directory
|
|
122
|
-
FileUtils.rm_rf(@session_dir) if File.directory?(@session_dir)
|
|
123
|
-
|
|
124
|
-
# Legacy layout: remove flat files
|
|
125
|
-
File.delete(@legacy_path) if File.exist?(@legacy_path)
|
|
126
|
-
File.delete(@legacy_meta) if File.exist?(@legacy_meta)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
private
|
|
130
|
-
|
|
131
|
-
def meta_path
|
|
132
|
-
File.join(@session_dir, "session.meta.json")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def save_meta
|
|
136
|
-
data = {
|
|
137
|
-
id: @id,
|
|
138
|
-
title: @title,
|
|
139
|
-
saved_at: Time.now.iso8601,
|
|
140
|
-
metadata: @metadata,
|
|
141
|
-
}
|
|
142
|
-
FileUtils.mkdir_p(@session_dir)
|
|
143
|
-
File.write(meta_path, JSON.pretty_generate(data))
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def load_meta
|
|
147
|
-
# Try new layout first
|
|
148
|
-
path = if File.exist?(meta_path)
|
|
149
|
-
meta_path
|
|
150
|
-
elsif File.exist?(@legacy_meta)
|
|
151
|
-
@legacy_meta
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
return unless path
|
|
155
|
-
|
|
156
|
-
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
157
|
-
@title = data[:title]
|
|
158
|
-
@metadata = data[:metadata] || {}
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
end
|