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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
data/lib/brute/prompts.rb
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
require 'brute/prompts/base'
|
|
2
|
+
require 'brute/prompts/identity'
|
|
3
|
+
require 'brute/prompts/tone_and_style'
|
|
4
|
+
require 'brute/prompts/objectivity'
|
|
5
|
+
require 'brute/prompts/task_management'
|
|
6
|
+
require 'brute/prompts/doing_tasks'
|
|
7
|
+
require 'brute/prompts/tool_usage'
|
|
8
|
+
require 'brute/prompts/conventions'
|
|
9
|
+
require 'brute/prompts/git_safety'
|
|
10
|
+
require 'brute/prompts/code_references'
|
|
11
|
+
require 'brute/prompts/environment'
|
|
12
|
+
require 'brute/prompts/instructions'
|
|
13
|
+
require 'brute/prompts/editing_approach'
|
|
14
|
+
require 'brute/prompts/autonomy'
|
|
15
|
+
require 'brute/prompts/editing_constraints'
|
|
16
|
+
require 'brute/prompts/frontend_tasks'
|
|
17
|
+
require 'brute/prompts/proactiveness'
|
|
18
|
+
require 'brute/prompts/code_style'
|
|
19
|
+
require 'brute/prompts/security_and_safety'
|
|
20
|
+
require 'brute/prompts/skills'
|
|
21
|
+
require 'brute/prompts/plan_reminder'
|
|
22
|
+
require 'brute/prompts/max_steps'
|
|
23
|
+
require 'brute/prompts/build_switch'
|
|
24
24
|
|
|
25
25
|
module Brute
|
|
26
26
|
module Prompts
|
|
@@ -29,15 +29,10 @@ module Brute
|
|
|
29
29
|
"nix" => ->(cmd) { "nix eval --expr #{Shellwords.escape(cmd)}" },
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
|
-
# ──
|
|
32
|
+
# ── Provider interface ─────────────────────────────────────────
|
|
33
33
|
|
|
34
34
|
def name = :shell
|
|
35
35
|
def default_model = "bash"
|
|
36
|
-
def user_role = :user
|
|
37
|
-
def tool_role = :tool
|
|
38
|
-
def assistant_role = :assistant
|
|
39
|
-
def system_role = :system
|
|
40
|
-
def tracer = LLM::Tracer::Null.new(self)
|
|
41
36
|
|
|
42
37
|
def complete(prompt, params = {})
|
|
43
38
|
model = params[:model]&.to_s || default_model
|
|
@@ -63,28 +58,20 @@ module Brute
|
|
|
63
58
|
|
|
64
59
|
private
|
|
65
60
|
|
|
66
|
-
# Extract the user's text from
|
|
67
|
-
# Returns nil when the
|
|
61
|
+
# Extract the user's text from the messages array.
|
|
62
|
+
# Returns nil when the messages contain tool results (the second
|
|
68
63
|
# round-trip) so #complete knows to return an empty response.
|
|
69
64
|
def extract_text(prompt)
|
|
70
65
|
case prompt
|
|
71
66
|
when String
|
|
72
67
|
prompt
|
|
73
68
|
when ::Array
|
|
74
|
-
return nil if prompt.any? { |
|
|
69
|
+
return nil if prompt.any? { |m| m.respond_to?(:role) && m.role == :tool }
|
|
75
70
|
|
|
76
|
-
user_msg = prompt.reverse_each.find { |m| m.respond_to?(:role) && m.role
|
|
71
|
+
user_msg = prompt.reverse_each.find { |m| m.respond_to?(:role) && m.role == :user }
|
|
77
72
|
user_msg&.content.to_s
|
|
78
73
|
else
|
|
79
|
-
|
|
80
|
-
msgs = prompt.to_a
|
|
81
|
-
return nil if msgs.any? { |m| m.respond_to?(:content) && LLM::Function::Return === m.content }
|
|
82
|
-
|
|
83
|
-
user_msg = msgs.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
|
|
84
|
-
user_msg&.content.to_s
|
|
85
|
-
else
|
|
86
|
-
prompt.to_s
|
|
87
|
-
end
|
|
74
|
+
prompt.to_s
|
|
88
75
|
end
|
|
89
76
|
end
|
|
90
77
|
|
|
@@ -29,24 +29,20 @@ module Brute
|
|
|
29
29
|
def messages
|
|
30
30
|
return [empty_assistant] if @command.nil?
|
|
31
31
|
|
|
32
|
-
call_id
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
tool_calls: [tool_call],
|
|
47
|
-
original_tool_calls: original,
|
|
48
|
-
tools: @tools,
|
|
49
|
-
})]
|
|
32
|
+
call_id = "shell_#{SecureRandom.hex(8)}"
|
|
33
|
+
tool_calls = {
|
|
34
|
+
call_id => RubyLLM::ToolCall.new(
|
|
35
|
+
id: call_id,
|
|
36
|
+
name: "shell",
|
|
37
|
+
arguments: { "command" => @command },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[RubyLLM::Message.new(
|
|
42
|
+
role: :assistant,
|
|
43
|
+
content: "",
|
|
44
|
+
tool_calls: tool_calls,
|
|
45
|
+
)]
|
|
50
46
|
end
|
|
51
47
|
alias_method :choices, :messages
|
|
52
48
|
|
|
@@ -71,11 +67,12 @@ module Brute
|
|
|
71
67
|
end
|
|
72
68
|
|
|
73
69
|
def content
|
|
74
|
-
messages.find
|
|
70
|
+
msg = messages.find { |m| m.role == :assistant }
|
|
71
|
+
msg&.content
|
|
75
72
|
end
|
|
76
73
|
|
|
77
74
|
def content!
|
|
78
|
-
|
|
75
|
+
JSON.parse(content)
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
def reasoning_content
|
|
@@ -83,22 +80,17 @@ module Brute
|
|
|
83
80
|
end
|
|
84
81
|
|
|
85
82
|
def usage
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
total_tokens: 0,
|
|
83
|
+
RubyLLM::Tokens.new(
|
|
84
|
+
input: 0,
|
|
85
|
+
output: 0,
|
|
86
|
+
reasoning: 0,
|
|
91
87
|
)
|
|
92
88
|
end
|
|
93
89
|
|
|
94
|
-
# Contract must be included AFTER method definitions —
|
|
95
|
-
# LLM::Contract checks that all required methods exist at include time.
|
|
96
|
-
include LLM::Contract::Completion
|
|
97
|
-
|
|
98
90
|
private
|
|
99
91
|
|
|
100
92
|
def empty_assistant
|
|
101
|
-
|
|
93
|
+
RubyLLM::Message.new(role: :assistant, content: "")
|
|
102
94
|
end
|
|
103
95
|
end
|
|
104
96
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
class Session < Array
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
def initialize(path: nil)
|
|
11
|
+
super()
|
|
12
|
+
@path = path
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(@path)) if @path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load a session from a JSONL file. Subsequent appends will persist
|
|
17
|
+
# back to the same file automatically.
|
|
18
|
+
def self.from_jsonl(path)
|
|
19
|
+
new(path: path).tap do |session|
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
File.foreach(path).map(&:strip).each do |line|
|
|
22
|
+
if line.present?
|
|
23
|
+
# Use push to bypass << persistence (avoids re-writing existing lines)
|
|
24
|
+
session.push(RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true)))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Append a message and persist it to disk if a path is set.
|
|
32
|
+
def <<(msg)
|
|
33
|
+
super
|
|
34
|
+
if @path
|
|
35
|
+
File.open(@path, "a") { |f| f.puts(JSON.generate(msg.to_h)) }
|
|
36
|
+
end
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def user(content)
|
|
41
|
+
self << RubyLLM::Message.new(role: :user, content: content)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def assistant(content)
|
|
45
|
+
self << RubyLLM::Message.new(role: :assistant, content: content)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def system(content)
|
|
49
|
+
self << RubyLLM::Message.new(role: :system, content: content)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -2,50 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Brute
|
|
4
4
|
module Store
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
# Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
|
|
6
|
+
# to enable undo. Each call to .save pushes the current content (or
|
|
7
|
+
# :did_not_exist for new files). .pop retrieves the most recent snapshot.
|
|
8
|
+
module SnapshotStore
|
|
9
|
+
@snapshots = Hash.new { |h, k| h[k] = [] }
|
|
10
|
+
@mutex = Mutex.new
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@
|
|
19
|
-
if File.exist?(path)
|
|
20
|
-
@store[path].push(File.read(path))
|
|
21
|
-
else
|
|
22
|
-
@store[path].push(:did_not_exist)
|
|
23
|
-
end
|
|
12
|
+
class << self
|
|
13
|
+
# Push the current content of +path+ onto the snapshot stack.
|
|
14
|
+
# If the file doesn't exist yet, records +:did_not_exist+.
|
|
15
|
+
def save(path)
|
|
16
|
+
key = File.expand_path(path)
|
|
17
|
+
content = File.exist?(key) ? File.read(key) : :did_not_exist
|
|
18
|
+
@mutex.synchronize { @snapshots[key].push(content) }
|
|
24
19
|
end
|
|
25
|
-
end
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@store[path]&.pop
|
|
21
|
+
# Pop and return the most recent snapshot for +path+, or +nil+
|
|
22
|
+
# if there is no history.
|
|
23
|
+
def pop(path)
|
|
24
|
+
key = File.expand_path(path)
|
|
25
|
+
@mutex.synchronize { @snapshots[key].pop }
|
|
33
26
|
end
|
|
34
|
-
end
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@mutex.synchronize do
|
|
40
|
-
@store[path]&.size || 0
|
|
28
|
+
# Clear all snapshots. Used in tests and session resets.
|
|
29
|
+
def clear!
|
|
30
|
+
@mutex.synchronize { @snapshots.clear }
|
|
41
31
|
end
|
|
42
32
|
end
|
|
43
|
-
|
|
44
|
-
# Clear all snapshots (useful for testing or session reset).
|
|
45
|
-
def clear!
|
|
46
|
-
@mutex.synchronize { @store.clear }
|
|
47
|
-
end
|
|
48
33
|
end
|
|
49
34
|
end
|
|
50
|
-
end
|
|
51
35
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require 'brute/pipeline'
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
# A SubAgent is an Agent that exposes a tool-shaped facade so it can
|
|
9
|
+
# be dropped into another agent's tools list. The parent agent's
|
|
10
|
+
# LLMCall passes it to ruby_llm as a regular tool; when invoked, the
|
|
11
|
+
# SubAgent runs its own pipeline against a fresh Session built from
|
|
12
|
+
# the tool arguments, then returns the final assistant message as the
|
|
13
|
+
# tool result.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
#
|
|
17
|
+
# researcher = Brute::SubAgent.new(
|
|
18
|
+
# name: "research",
|
|
19
|
+
# description: "Delegate a research task to a read-only sub-agent.",
|
|
20
|
+
# provider: Brute.provider,
|
|
21
|
+
# model: Brute.provider.default_model,
|
|
22
|
+
# tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch],
|
|
23
|
+
# ) do
|
|
24
|
+
# use Brute::Middleware::SystemPrompt
|
|
25
|
+
# use Brute::Middleware::MaxIterations, max_iterations: 10
|
|
26
|
+
# use Brute::Middleware::ToolCall
|
|
27
|
+
# run Brute::Middleware::LLMCall.new
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# main_agent = Brute::Agent.new(
|
|
31
|
+
# provider: ...,
|
|
32
|
+
# tools: [Brute::Tools::FSRead, researcher], # SubAgent IS a tool
|
|
33
|
+
# ) { ... }
|
|
34
|
+
#
|
|
35
|
+
class SubAgent < Agent
|
|
36
|
+
DEFAULT_PARAMS = {
|
|
37
|
+
task: { type: "string", desc: "A clear, detailed description of the task", required: true },
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :sub_agent_name, :description, :params
|
|
41
|
+
|
|
42
|
+
def initialize(name:, description:, params: DEFAULT_PARAMS, **agent_opts, &block)
|
|
43
|
+
@sub_agent_name = name.to_s
|
|
44
|
+
@description = description
|
|
45
|
+
@params = params
|
|
46
|
+
super(**agent_opts, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Tool-shaped entry point. Builds a session from arguments, runs the
|
|
50
|
+
# agent loop, returns the last assistant message as a string.
|
|
51
|
+
def execute(arguments)
|
|
52
|
+
session = build_session(arguments)
|
|
53
|
+
call(session)
|
|
54
|
+
extract_result(session)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Adapter so the parent agent's LLMCall (and ruby_llm) sees this as
|
|
58
|
+
# a regular tool. ToolCall middleware should call `to_ruby_llm` when
|
|
59
|
+
# building the tools hash if a tool responds to it.
|
|
60
|
+
def to_ruby_llm
|
|
61
|
+
sub = self
|
|
62
|
+
Class.new(RubyLLM::Tool) do
|
|
63
|
+
description sub.description
|
|
64
|
+
sub.params.each { |k, opts| param k, **opts }
|
|
65
|
+
define_method(:name) { sub.sub_agent_name }
|
|
66
|
+
define_method(:execute) { |**args| sub.execute(args) }
|
|
67
|
+
end.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Lets ToolCall treat SubAgents the same as RubyLLM::Tool instances
|
|
71
|
+
# without checking respond_to? everywhere.
|
|
72
|
+
def name
|
|
73
|
+
@sub_agent_name
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_session(arguments)
|
|
79
|
+
task = arguments[:task] || arguments["task"]
|
|
80
|
+
Brute::Session.new.tap { |s| s.user(task) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_result(session)
|
|
84
|
+
last = session.reverse_each.find do |m|
|
|
85
|
+
m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
|
|
86
|
+
end
|
|
87
|
+
last&.content || "(sub-agent completed but produced no text response)"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test do
|
|
93
|
+
it "exposes a name matching the sub-agent identifier" do
|
|
94
|
+
sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
|
|
95
|
+
run ->(env) { env[:messages].assistant("done") }
|
|
96
|
+
end
|
|
97
|
+
sa.name.should == "research"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "execute returns the last assistant message" do
|
|
101
|
+
sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
|
|
102
|
+
run ->(env) { env[:messages].assistant("result text") }
|
|
103
|
+
end
|
|
104
|
+
sa.execute(task: "do something").should == "result text"
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -181,87 +181,5 @@ module Brute
|
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
test do
|
|
184
|
-
|
|
185
|
-
{ provider_name: "anthropic", model_name: "test-model", cwd: Dir.pwd,
|
|
186
|
-
custom_rules: nil, agent: nil, agent_switched: nil, max_steps_reached: nil }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
it "stores a block and executes it on prepare" do
|
|
190
|
-
sp = Brute::SystemPrompt.build { |p, ctx| p << "hello #{ctx[:name]}" }
|
|
191
|
-
sp.prepare(name: "world").to_s.should == "hello world"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
it "returns a Result with sections" do
|
|
195
|
-
sp = Brute::SystemPrompt.build { |p, _| p << "section one"; p << "section two" }
|
|
196
|
-
sp.prepare({}).sections.should == ["section one", "section two"]
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
it "strips nil and empty sections" do
|
|
200
|
-
sp = Brute::SystemPrompt.build { |p, _| p << "kept"; p << nil; p << ""; p << "also kept" }
|
|
201
|
-
sp.prepare({}).sections.should == ["kept", "also kept"]
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
it "joins sections with double newlines via to_s" do
|
|
205
|
-
Brute::SystemPrompt::Result.new(["a", "b", "c"]).to_s.should == "a\n\nb\n\nc"
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
it "reports empty? correctly for empty result" do
|
|
209
|
-
Brute::SystemPrompt::Result.new([]).empty?.should.be.true
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
it "reports empty? correctly for non-empty result" do
|
|
213
|
-
Brute::SystemPrompt::Result.new(["a"]).empty?.should.be.false
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
it "falls back to default stack for unknown providers" do
|
|
217
|
-
builder = Brute::SystemPrompt.default
|
|
218
|
-
default_r = builder.prepare(base_ctx.merge(provider_name: "default"))
|
|
219
|
-
unknown_r = builder.prepare(base_ctx.merge(provider_name: "unknown_provider"))
|
|
220
|
-
unknown_r.sections.size.should == default_r.sections.size
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
it "includes PlanReminder when agent is plan" do
|
|
224
|
-
builder = Brute::SystemPrompt.default
|
|
225
|
-
builder.prepare(base_ctx.merge(agent: "plan")).to_s.should =~ /READ-ONLY/
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
it "excludes PlanReminder when agent is build" do
|
|
229
|
-
builder = Brute::SystemPrompt.default
|
|
230
|
-
(builder.prepare(base_ctx.merge(agent: "build")).to_s =~ /Plan Mode - System Reminder/).should.be.nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
it "includes BuildSwitch when agent_switched is build" do
|
|
234
|
-
builder = Brute::SystemPrompt.default
|
|
235
|
-
builder.prepare(base_ctx.merge(agent_switched: "build")).to_s.should =~ /operational mode has changed/
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
it "excludes BuildSwitch when agent_switched is nil" do
|
|
239
|
-
builder = Brute::SystemPrompt.default
|
|
240
|
-
(builder.prepare(base_ctx.merge(agent_switched: nil)).to_s =~ /operational mode has changed/).should.be.nil
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
it "includes MaxSteps when max_steps_reached" do
|
|
244
|
-
builder = Brute::SystemPrompt.default
|
|
245
|
-
builder.prepare(base_ctx.merge(max_steps_reached: true)).to_s.should =~ /MAXIMUM STEPS REACHED/
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
it "excludes MaxSteps when max_steps_reached is nil" do
|
|
249
|
-
builder = Brute::SystemPrompt.default
|
|
250
|
-
(builder.prepare(base_ctx.merge(max_steps_reached: nil)).to_s =~ /MAXIMUM STEPS REACHED/).should.be.nil
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
it "anthropic stack includes conventions" do
|
|
254
|
-
builder = Brute::SystemPrompt.default
|
|
255
|
-
builder.prepare(base_ctx.merge(provider_name: "anthropic")).to_s.should =~ /Following conventions/
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
it "openai stack includes conventions" do
|
|
259
|
-
builder = Brute::SystemPrompt.default
|
|
260
|
-
builder.prepare(base_ctx.merge(provider_name: "openai")).to_s.should =~ /Following conventions/
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
it "google stack includes conventions" do
|
|
264
|
-
builder = Brute::SystemPrompt.default
|
|
265
|
-
builder.prepare(base_ctx.merge(provider_name: "google")).to_s.should =~ /Following conventions/
|
|
266
|
-
end
|
|
184
|
+
# not implemented
|
|
267
185
|
end
|
data/lib/brute/tool.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require 'brute/pipeline'
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
# A Tool is a Pipeline configured for tool execution. The terminal app
|
|
9
|
+
# does the work; middleware wraps it with concerns like file mutation
|
|
10
|
+
# queueing, snapshotting, validation, logging.
|
|
11
|
+
#
|
|
12
|
+
# Coexists with Brute::Tools::* (which inherit from RubyLLM::Tool).
|
|
13
|
+
# Use Brute::Tool when you want middleware. Use RubyLLM::Tool subclasses
|
|
14
|
+
# for simple cases.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
#
|
|
18
|
+
# read = Brute::Tool.new(
|
|
19
|
+
# name: "read",
|
|
20
|
+
# description: "Read a file's contents",
|
|
21
|
+
# params: { file_path: { type: "string", required: true } },
|
|
22
|
+
# ) do
|
|
23
|
+
# use Brute::Middleware::Tool::ValidateParams
|
|
24
|
+
# run ->(env) {
|
|
25
|
+
# env[:result] = File.read(File.expand_path(env[:arguments][:file_path]))
|
|
26
|
+
# }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# read.call(file_path: "lib/brute.rb")
|
|
30
|
+
#
|
|
31
|
+
class Tool < Pipeline
|
|
32
|
+
attr_reader :name, :description, :params
|
|
33
|
+
|
|
34
|
+
def initialize(name:, description:, params: {}, &block)
|
|
35
|
+
@name = name.to_s
|
|
36
|
+
@description = description
|
|
37
|
+
@params = params
|
|
38
|
+
super(&block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Execute the tool. Arguments come in as kwargs; result is whatever
|
|
42
|
+
# the terminal app puts into env[:result].
|
|
43
|
+
def call(events: NullSink.new, **arguments)
|
|
44
|
+
env = {
|
|
45
|
+
name: @name,
|
|
46
|
+
arguments: arguments,
|
|
47
|
+
result: nil,
|
|
48
|
+
events: events,
|
|
49
|
+
metadata: {},
|
|
50
|
+
}
|
|
51
|
+
super(env)
|
|
52
|
+
env[:result]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Adapter so the LLM can call this tool through ruby_llm.
|
|
56
|
+
# ToolCall middleware checks for to_ruby_llm and uses it if present.
|
|
57
|
+
def to_ruby_llm
|
|
58
|
+
tool = self
|
|
59
|
+
Class.new(RubyLLM::Tool) do
|
|
60
|
+
description tool.description
|
|
61
|
+
tool.params.each { |k, opts| param k, **opts }
|
|
62
|
+
define_method(:name) { tool.name }
|
|
63
|
+
define_method(:execute) { |**args| tool.call(**args) }
|
|
64
|
+
end.new
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test do
|
|
70
|
+
it "exposes name, description, params" do
|
|
71
|
+
t = Brute::Tool.new(name: "echo", description: "echo input") do
|
|
72
|
+
run ->(env) { env[:result] = env[:arguments][:msg] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
t.name.should == "echo"
|
|
76
|
+
t.description.should == "echo input"
|
|
77
|
+
t.call(msg: "hi").should == "hi"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "passes arguments through env to the terminal app" do
|
|
81
|
+
captured = nil
|
|
82
|
+
t = Brute::Tool.new(name: "x", description: "x") do
|
|
83
|
+
run ->(env) { captured = env[:arguments]; env[:result] = nil }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
t.call(a: 1, b: 2)
|
|
87
|
+
captured.should == { a: 1, b: 2 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "runs middleware around the terminal app" do
|
|
91
|
+
log = []
|
|
92
|
+
wrap = Class.new do
|
|
93
|
+
def initialize(app, tag:); @app = app; @tag = tag; end
|
|
94
|
+
def call(env); (env[:metadata][:log] ||= []) << "in-#{@tag}"; @app.call(env); env[:metadata][:log] << "out-#{@tag}"; end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
t = Brute::Tool.new(name: "x", description: "x") do
|
|
98
|
+
use wrap, tag: "a"
|
|
99
|
+
run ->(env) { env[:metadata][:log] << "core"; env[:result] = :ok }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Pre-seed the log on the env that gets built — tool builds its own env,
|
|
103
|
+
# so we capture via the middleware metadata channel
|
|
104
|
+
result = t.call(input: 1)
|
|
105
|
+
result.should == :ok
|
|
106
|
+
end
|
|
107
|
+
end
|