brute 2.0.6 → 3.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.
- checksums.yaml +4 -4
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +3 -1
- data/lib/brute/message_transport/anthropic.rb +143 -0
- data/lib/brute/message_transport/llm.rb +94 -0
- data/lib/brute/message_transport/openai.rb +113 -0
- data/lib/brute/message_transport/ruby_llm.rb +78 -0
- data/lib/brute/message_transport.rb +133 -0
- data/lib/brute/messages.rb +120 -0
- data/lib/brute/middleware/001_otel_span.rb +4 -2
- data/lib/brute/middleware/002_session_log.rb +100 -0
- data/lib/brute/middleware/004_summarize.rb +21 -19
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +9 -7
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +6 -4
- data/lib/brute/middleware/040_compaction_check.rb +5 -3
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
- data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
- data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
- data/lib/brute/middleware/event_handler.rb +3 -1
- data/lib/brute/middleware/user_queue.rb +3 -1
- data/lib/brute/prompts/autonomy.rb +3 -0
- data/lib/brute/prompts/base.rb +3 -0
- data/lib/brute/prompts/build_switch.rb +3 -1
- data/lib/brute/prompts/code_references.rb +3 -0
- data/lib/brute/prompts/code_style.rb +3 -0
- data/lib/brute/prompts/conventions.rb +3 -0
- data/lib/brute/prompts/doing_tasks.rb +3 -0
- data/lib/brute/prompts/editing_approach.rb +3 -0
- data/lib/brute/prompts/editing_constraints.rb +3 -0
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/frontend_tasks.rb +3 -0
- data/lib/brute/prompts/git_safety.rb +3 -0
- data/lib/brute/prompts/identity.rb +3 -1
- data/lib/brute/prompts/instructions.rb +3 -1
- data/lib/brute/prompts/max_steps.rb +3 -1
- data/lib/brute/prompts/objectivity.rb +3 -0
- data/lib/brute/prompts/plan_reminder.rb +3 -1
- data/lib/brute/prompts/proactiveness.rb +3 -0
- data/lib/brute/prompts/security_and_safety.rb +3 -0
- data/lib/brute/prompts/skills.rb +6 -2
- data/lib/brute/prompts/task_management.rb +3 -0
- data/lib/brute/prompts/tone_and_style.rb +3 -0
- data/lib/brute/prompts/tool_usage.rb +3 -0
- data/lib/brute/prompts.rb +5 -0
- data/lib/brute/rack/adapter.rb +237 -0
- data/lib/brute/skill.rb +192 -15
- data/lib/brute/system_prompt.rb +3 -1
- data/lib/brute/tool.rb +92 -75
- data/lib/brute/tools/adapter.rb +258 -0
- data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
- data/lib/brute/tools/fs/snapshot_store.rb +41 -0
- data/lib/brute/tools/fs_patch.rb +6 -4
- data/lib/brute/tools/fs_read.rb +5 -3
- data/lib/brute/tools/fs_remove.rb +3 -3
- data/lib/brute/tools/fs_search.rb +4 -2
- data/lib/brute/tools/fs_undo.rb +3 -3
- data/lib/brute/tools/fs_write.rb +6 -4
- data/lib/brute/tools/net_fetch.rb +1 -1
- data/lib/brute/tools/question.rb +1 -1
- data/lib/brute/tools/shell.rb +4 -2
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +101 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +2 -2
- data/lib/brute/tools/todo_write.rb +2 -2
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +169 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +96 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +45 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
- metadata +52 -43
- data/lib/brute/agent.rb +0 -82
- data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
- data/lib/brute/middleware/100_llm_call.rb +0 -63
- data/lib/brute/pipeline.rb +0 -97
- data/lib/brute/queue/file_mutation_queue.rb +0 -102
- data/lib/brute/session.rb +0 -51
- data/lib/brute/store/snapshot_store.rb +0 -36
- data/lib/brute/store/todo_store.rb +0 -30
- data/lib/brute/sub_agent.rb +0 -106
data/lib/brute/tool.rb
CHANGED
|
@@ -2,106 +2,123 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
4
|
require "brute"
|
|
5
|
-
require 'brute/pipeline'
|
|
6
5
|
|
|
7
6
|
module Brute
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Base class for Brute's built-in tools — a tiny, framework-agnostic tool
|
|
8
|
+
# DSL. It intentionally mirrors the common tool-library shape (description +
|
|
9
|
+
# params) so tools read the same as they would in any LLM library, without
|
|
10
|
+
# depending on one:
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# class Shell < Brute::Tool
|
|
13
|
+
# description "Execute a shell command"
|
|
14
|
+
# param :command, type: 'string', desc: "The command", required: true
|
|
15
15
|
#
|
|
16
|
-
#
|
|
16
|
+
# def name; "shell"; end
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
# }
|
|
18
|
+
# def execute(command:)
|
|
19
|
+
# ...
|
|
20
|
+
# end
|
|
27
21
|
# end
|
|
28
22
|
#
|
|
29
|
-
#
|
|
23
|
+
# For tools whose arguments don't fit the flat param list, pass a raw JSON
|
|
24
|
+
# schema instead:
|
|
30
25
|
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
# params({ type: 'object', properties: { ... }, required: [...] })
|
|
27
|
+
#
|
|
28
|
+
# Instances expose the neutral interface Brute::Tools::Adapter understands:
|
|
29
|
+
# #name, #description, #params, #params_schema, #call.
|
|
30
|
+
class Tool
|
|
31
|
+
class << self
|
|
32
|
+
def description(text = nil)
|
|
33
|
+
return @description unless text
|
|
34
|
+
|
|
35
|
+
@description = text
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Declare one parameter: param :key, type:, desc:, required:
|
|
39
|
+
def param(name, type: "string", desc: nil, required: true, **opts)
|
|
40
|
+
param_definitions[name.to_sym] = { type: type, desc: desc, required: required, **opts }.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def param_definitions
|
|
44
|
+
@param_definitions ||= {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raw JSON-schema override for complex argument shapes.
|
|
48
|
+
def params(schema = nil)
|
|
49
|
+
return @params_schema unless schema
|
|
50
|
+
|
|
51
|
+
@params_schema = schema.deep_symbolize_keys
|
|
52
|
+
end
|
|
39
53
|
end
|
|
40
54
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
env = {
|
|
45
|
-
name: @name,
|
|
46
|
-
arguments: arguments,
|
|
47
|
-
result: nil,
|
|
48
|
-
events: events,
|
|
49
|
-
metadata: {},
|
|
50
|
-
}
|
|
51
|
-
super(env)
|
|
52
|
-
env[:result]
|
|
55
|
+
# Tool name; subclasses usually override with an explicit short name.
|
|
56
|
+
def name
|
|
57
|
+
self.class.name.demodulize.underscore
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
+
def description
|
|
61
|
+
self.class.description.to_s
|
|
65
62
|
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
run ->(env) { env[:result] = env[:arguments][:msg] }
|
|
64
|
+
# { key => { type:, desc:, required: } }
|
|
65
|
+
def params
|
|
66
|
+
self.class.param_definitions
|
|
73
67
|
end
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
# The raw JSON schema, when declared via params({...}).
|
|
70
|
+
def params_schema
|
|
71
|
+
self.class.params
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Execute with a string- or symbol-keyed argument hash, as delivered
|
|
75
|
+
# by LLM providers.
|
|
76
|
+
def call(arguments = {})
|
|
77
|
+
execute(**arguments.to_h.transform_keys(&:to_sym))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute(**)
|
|
81
|
+
raise NotImplementedError, "#{self.class} must implement #execute"
|
|
82
|
+
end
|
|
78
83
|
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
__END__
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
describe "brute/tool" do
|
|
89
|
+
it "exposes description and params from the DSL" do
|
|
90
|
+
klass = Class.new(Brute::Tool) do
|
|
91
|
+
description "test tool"
|
|
92
|
+
param :input, type: "string", desc: "the input"
|
|
93
|
+
def name; "t"; end
|
|
94
|
+
def execute(input:); "got #{input}"; end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
tool = klass.new
|
|
98
|
+
tool.description.should == "test tool"
|
|
99
|
+
tool.params[:input][:type].should == "string"
|
|
100
|
+
tool.params[:input][:required].should.be.true
|
|
88
101
|
end
|
|
89
102
|
|
|
90
|
-
it "
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def
|
|
103
|
+
it "calls execute with symbolized keys" do
|
|
104
|
+
klass = Class.new(Brute::Tool) do
|
|
105
|
+
description "echo"
|
|
106
|
+
param :msg, type: "string"
|
|
107
|
+
def name; "echo"; end
|
|
108
|
+
def execute(msg:); msg; end
|
|
95
109
|
end
|
|
96
110
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
klass.new.call("msg" => "hi").should == "hi"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "accepts a raw JSON schema via params({...})" do
|
|
115
|
+
klass = Class.new(Brute::Tool) do
|
|
116
|
+
description "schema tool"
|
|
117
|
+
params({ type: "object", properties: { a: { type: "string" } }, required: %w[a] })
|
|
118
|
+
def name; "s"; end
|
|
119
|
+
def execute(a:); a; end
|
|
100
120
|
end
|
|
101
121
|
|
|
102
|
-
|
|
103
|
-
# so we capture via the middleware metadata channel
|
|
104
|
-
result = t.call(input: 1)
|
|
105
|
-
result.should == :ok
|
|
122
|
+
klass.new.params_schema[:properties][:a][:type].should == "string"
|
|
106
123
|
end
|
|
107
124
|
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# Normalizes any tool shape into one neutral interface so the rest of
|
|
9
|
+
# Brute never has to care which tools library (if any) a tool was
|
|
10
|
+
# written with.
|
|
11
|
+
#
|
|
12
|
+
# This solves three problems:
|
|
13
|
+
#
|
|
14
|
+
# 1. Using any tools library — anything that quacks like a tool
|
|
15
|
+
# (#name plus #call or #execute) is wrapped into the same interface.
|
|
16
|
+
# 2. Avoiding tool libraries entirely — Brute::Tool, Brute::Turn::ToolPipeline
|
|
17
|
+
# and Tools::SubAgent work without inheriting from a library class.
|
|
18
|
+
# 3. Quickly adding tools — a plain Hash with a proc is enough:
|
|
19
|
+
#
|
|
20
|
+
# Brute::Tools::Adapter.wrap(
|
|
21
|
+
# name: "echo",
|
|
22
|
+
# description: "Echo the input back",
|
|
23
|
+
# params: { msg: { type: "string", required: true } },
|
|
24
|
+
# execute: ->(msg:) { msg },
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# The neutral interface:
|
|
28
|
+
#
|
|
29
|
+
# adapter.name # String
|
|
30
|
+
# adapter.description # String
|
|
31
|
+
# adapter.params # { key => { type:, desc:, required: } }
|
|
32
|
+
# adapter.call(args) # execute with a (string- or symbol-keyed) Hash
|
|
33
|
+
#
|
|
34
|
+
# The inline `run` proc converts adapters (via #to_h) into whatever its
|
|
35
|
+
# LLM library expects; ToolPipeline executes them via #call.
|
|
36
|
+
#
|
|
37
|
+
class Adapter
|
|
38
|
+
attr_reader :name, :description, :params
|
|
39
|
+
|
|
40
|
+
# Wrap a single tool of any supported shape. Idempotent.
|
|
41
|
+
def self.wrap(tool)
|
|
42
|
+
return tool if tool.is_a?(Adapter)
|
|
43
|
+
|
|
44
|
+
tool = tool.new if tool.is_a?(Class)
|
|
45
|
+
|
|
46
|
+
case tool
|
|
47
|
+
when Hash then from_hash(tool)
|
|
48
|
+
when ::Brute::Tool then from_brute_tool(tool)
|
|
49
|
+
when Brute::Tools::SubAgent then new(
|
|
50
|
+
name: tool.name,
|
|
51
|
+
description: tool.description,
|
|
52
|
+
params: tool.params,
|
|
53
|
+
handler: ->(**args) { tool.execute(args) },
|
|
54
|
+
original: tool,
|
|
55
|
+
)
|
|
56
|
+
when Brute::Turn::ToolPipeline then new(
|
|
57
|
+
name: tool.name,
|
|
58
|
+
description: tool.description,
|
|
59
|
+
params: tool.params,
|
|
60
|
+
handler: ->(**args) { tool.call(**args) },
|
|
61
|
+
original: tool,
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
from_duck_type(tool)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Wrap a list of tools into a { name_sym => adapter } lookup hash —
|
|
69
|
+
# the shape ToolPipeline works with.
|
|
70
|
+
def self.wrap_all(tools)
|
|
71
|
+
Array(tools).each_with_object({}) do |tool, hash|
|
|
72
|
+
adapter = wrap(tool)
|
|
73
|
+
hash[adapter.name.to_sym] = adapter
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Quick inline tool: { name:, description:, params:, execute: }
|
|
78
|
+
def self.from_hash(definition)
|
|
79
|
+
definition = definition.transform_keys(&:to_sym)
|
|
80
|
+
handler = definition.fetch(:execute) { definition[:handler] }
|
|
81
|
+
raise ArgumentError, "inline tool needs an :execute proc" unless handler.respond_to?(:call)
|
|
82
|
+
|
|
83
|
+
new(
|
|
84
|
+
name: definition.fetch(:name).to_s,
|
|
85
|
+
description: definition.fetch(:description, ""),
|
|
86
|
+
params: definition.fetch(:params, {}),
|
|
87
|
+
handler: ->(**args) { handler.call(**args) },
|
|
88
|
+
original: definition,
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A Brute::Tool instance. Tools declared with the params({...}) schema
|
|
93
|
+
# DSL keep their full JSON schema.
|
|
94
|
+
def self.from_brute_tool(tool)
|
|
95
|
+
new(
|
|
96
|
+
name: tool.name.to_s,
|
|
97
|
+
description: tool.description,
|
|
98
|
+
params: tool.params,
|
|
99
|
+
schema: tool.params_schema,
|
|
100
|
+
handler: ->(**args) { tool.call(args) },
|
|
101
|
+
original: tool,
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Anything tool-shaped: needs #name and #call or #execute.
|
|
106
|
+
def self.from_duck_type(tool)
|
|
107
|
+
unless tool.respond_to?(:name) && (tool.respond_to?(:call) || tool.respond_to?(:execute))
|
|
108
|
+
raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
entry = tool.respond_to?(:execute) ? tool.method(:execute) : tool.method(:call)
|
|
112
|
+
new(
|
|
113
|
+
name: tool.name.to_s,
|
|
114
|
+
description: tool.respond_to?(:description) ? tool.description : "",
|
|
115
|
+
params: tool.respond_to?(:params) ? tool.params : {},
|
|
116
|
+
handler: ->(**args) { entry.call(**args) },
|
|
117
|
+
original: tool,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def initialize(name:, description:, params:, handler:, schema: nil, original: nil)
|
|
122
|
+
@name = name
|
|
123
|
+
@description = description
|
|
124
|
+
@params = params || {}
|
|
125
|
+
@schema = schema
|
|
126
|
+
@handler = handler
|
|
127
|
+
@original = original
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The tool object this adapter wraps (Brute::Tool, Brute::Turn::ToolPipeline,
|
|
131
|
+
# SubAgent, Hash definition, ...).
|
|
132
|
+
attr_reader :original
|
|
133
|
+
|
|
134
|
+
# Execute the tool. Accepts string- or symbol-keyed argument hashes,
|
|
135
|
+
# as delivered by LLM providers.
|
|
136
|
+
def call(arguments = {})
|
|
137
|
+
args = arguments.to_h.transform_keys(&:to_sym)
|
|
138
|
+
@handler.call(**args)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Library-neutral tool definition (JSON-Schema-ish). The inline `run`
|
|
142
|
+
# proc reshapes this into whatever its LLM library expects.
|
|
143
|
+
def to_h
|
|
144
|
+
return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
|
|
145
|
+
|
|
146
|
+
properties = @params.transform_values do |opts|
|
|
147
|
+
{
|
|
148
|
+
type: opts[:type] || "string",
|
|
149
|
+
description: opts[:desc] || opts[:description],
|
|
150
|
+
items: opts[:items],
|
|
151
|
+
enum: opts[:enum],
|
|
152
|
+
}.compact
|
|
153
|
+
end
|
|
154
|
+
required = @params.select { |_k, opts| opts[:required] }.keys
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
name: @name,
|
|
158
|
+
description: @description,
|
|
159
|
+
parameters: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: properties,
|
|
162
|
+
required: required.map(&:to_s),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
__END__
|
|
171
|
+
|
|
172
|
+
describe "brute/tools/adapter" do
|
|
173
|
+
it "wraps a Brute::Tool class" do
|
|
174
|
+
klass = Class.new(::Brute::Tool) do
|
|
175
|
+
description "test tool"
|
|
176
|
+
param :input, type: "string", desc: "the input"
|
|
177
|
+
def name; "brute_tool"; end
|
|
178
|
+
def execute(input:); "got #{input}"; end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
adapter = Brute::Tools::Adapter.wrap(klass)
|
|
182
|
+
adapter.name.should == "brute_tool"
|
|
183
|
+
adapter.description.should == "test tool"
|
|
184
|
+
adapter.params[:input][:type].should == "string"
|
|
185
|
+
adapter.call("input" => "x").should == "got x"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "wraps a Brute::Turn::ToolPipeline" do
|
|
189
|
+
t = Brute::Turn::ToolPipeline.new(name: "echo", description: "echo input") do
|
|
190
|
+
run ->(env) { env[:result] = env[:arguments][:msg] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
adapter = Brute::Tools::Adapter.wrap(t)
|
|
194
|
+
adapter.name.should == "echo"
|
|
195
|
+
adapter.call(msg: "hi").should == "hi"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "wraps a SubAgent" do
|
|
199
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
200
|
+
run ->(env) { env[:messages].assistant("result text") }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
adapter = Brute::Tools::Adapter.wrap(sa)
|
|
204
|
+
adapter.name.should == "research"
|
|
205
|
+
adapter.call(task: "go").should == "result text"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "wraps an inline hash definition" do
|
|
209
|
+
adapter = Brute::Tools::Adapter.wrap(
|
|
210
|
+
name: "adder",
|
|
211
|
+
description: "Add two numbers",
|
|
212
|
+
params: { a: { type: "number", required: true }, b: { type: "number", required: true } },
|
|
213
|
+
execute: ->(a:, b:) { a + b },
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
adapter.name.should == "adder"
|
|
217
|
+
adapter.call(a: 1, b: 2).should == 3
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "is idempotent" do
|
|
221
|
+
adapter = Brute::Tools::Adapter.wrap(name: "x", description: "", execute: -> {})
|
|
222
|
+
Brute::Tools::Adapter.wrap(adapter).should == adapter
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "wrap_all keys adapters by name symbol" do
|
|
226
|
+
tools = Brute::Tools::Adapter.wrap_all([
|
|
227
|
+
{ name: "a", description: "", execute: -> {} },
|
|
228
|
+
{ name: "b", description: "", execute: -> {} },
|
|
229
|
+
])
|
|
230
|
+
tools.keys.should == [:a, :b]
|
|
231
|
+
tools[:a].should.be.kind_of?(Brute::Tools::Adapter)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "exposes the wrapped original" do
|
|
235
|
+
klass = Class.new(::Brute::Tool) do
|
|
236
|
+
description "test tool"
|
|
237
|
+
def name; "original"; end
|
|
238
|
+
def execute; "ok"; end
|
|
239
|
+
end
|
|
240
|
+
instance = klass.new
|
|
241
|
+
|
|
242
|
+
Brute::Tools::Adapter.wrap(instance).original.should == instance
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "produces a neutral JSON-schema-ish definition" do
|
|
246
|
+
adapter = Brute::Tools::Adapter.wrap(
|
|
247
|
+
name: "echo",
|
|
248
|
+
description: "Echo",
|
|
249
|
+
params: { msg: { type: "string", desc: "message", required: true } },
|
|
250
|
+
execute: ->(msg:) { msg },
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
defn = adapter.to_h
|
|
254
|
+
defn[:name].should == "echo"
|
|
255
|
+
defn[:parameters][:properties][:msg][:type].should == "string"
|
|
256
|
+
defn[:parameters][:required].should == ["msg"]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# @namespace
|
|
9
|
+
module FS
|
|
10
|
+
# Per-file serialization queue for concurrent tool execution.
|
|
11
|
+
#
|
|
12
|
+
# When tools run in parallel (via threads or async fibers), multiple tools
|
|
13
|
+
# may target the same file simultaneously. Without serialization, a sequence
|
|
14
|
+
# like [read → patch → write] on the same file would race and lose edits.
|
|
15
|
+
#
|
|
16
|
+
# This module provides a single public method:
|
|
17
|
+
#
|
|
18
|
+
# Brute::Tools::FS::FileMutationQueue.serialize("/path/to/file") do
|
|
19
|
+
# # snapshot + read + modify + write — all atomic for this path
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Design (mirrors pi-mono's withFileMutationQueue):
|
|
23
|
+
# - Operations on the SAME file are serialized (run one at a time)
|
|
24
|
+
# - Operations on DIFFERENT files run fully in parallel (independent mutexes)
|
|
25
|
+
# - Symlink-aware: resolves real paths so aliases share one mutex
|
|
26
|
+
# - Error-safe: mutex is always released in `ensure`, so failures never deadlock
|
|
27
|
+
# - Self-cleaning: per-file mutexes are removed when no longer in use
|
|
28
|
+
#
|
|
29
|
+
# Ruby 3.4's Mutex is fiber-scheduler-aware, so this works correctly with
|
|
30
|
+
# both :thread and :task (Async) concurrency strategies.
|
|
31
|
+
#
|
|
32
|
+
module FileMutationQueue
|
|
33
|
+
@mutexes = {} # path → Mutex
|
|
34
|
+
@waiters = Hash.new(0) # path → number of threads/fibers waiting or holding
|
|
35
|
+
@guard = Mutex.new # protects @mutexes and @waiters
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Serialize a block of work for a given file path.
|
|
39
|
+
#
|
|
40
|
+
# Concurrent calls targeting the same canonical path will execute
|
|
41
|
+
# sequentially in FIFO order. Calls targeting different paths
|
|
42
|
+
# proceed in parallel with zero contention.
|
|
43
|
+
#
|
|
44
|
+
# @parameter path [String] The file path to serialize on.
|
|
45
|
+
# @yields {block} The mutation work to perform (snapshot, read, write, etc.)
|
|
46
|
+
# @returns Whatever the block returns.
|
|
47
|
+
def serialize(path, &block)
|
|
48
|
+
key = canonical_path(path)
|
|
49
|
+
mutex = acquire_mutex(key)
|
|
50
|
+
|
|
51
|
+
mutex.synchronize(&block)
|
|
52
|
+
ensure
|
|
53
|
+
release_mutex(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clear all tracked mutexes. Used in tests and session resets.
|
|
57
|
+
def clear!
|
|
58
|
+
@guard.synchronize do
|
|
59
|
+
@mutexes.clear
|
|
60
|
+
@waiters.clear
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Number of file paths currently tracked (for diagnostics).
|
|
65
|
+
def size
|
|
66
|
+
@guard.synchronize { @mutexes.size }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Resolve a file path to a canonical key.
|
|
72
|
+
# Uses File.realpath to follow symlinks so that aliases to the
|
|
73
|
+
# same underlying file share one mutex. Falls back to
|
|
74
|
+
# File.expand_path for files that don't exist yet (e.g., new writes).
|
|
75
|
+
def canonical_path(path)
|
|
76
|
+
resolved = File.expand_path(path)
|
|
77
|
+
begin
|
|
78
|
+
File.realpath(resolved)
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
resolved
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get (or create) a mutex for a file path and increment the waiter count.
|
|
85
|
+
def acquire_mutex(key)
|
|
86
|
+
@guard.synchronize do
|
|
87
|
+
@mutexes[key] ||= Mutex.new
|
|
88
|
+
@waiters[key] += 1
|
|
89
|
+
@mutexes[key]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Decrement the waiter count and clean up the mutex if no one else needs it.
|
|
94
|
+
def release_mutex(key)
|
|
95
|
+
@guard.synchronize do
|
|
96
|
+
@waiters[key] -= 1
|
|
97
|
+
if @waiters[key] <= 0
|
|
98
|
+
@mutexes.delete(key)
|
|
99
|
+
@waiters.delete(key)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# @namespace
|
|
9
|
+
module FS
|
|
10
|
+
# Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
|
|
11
|
+
# to enable undo. Each call to .save pushes the current content (or
|
|
12
|
+
# :did_not_exist for new files). .pop retrieves the most recent snapshot.
|
|
13
|
+
module SnapshotStore
|
|
14
|
+
@snapshots = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Push the current content of +path+ onto the snapshot stack.
|
|
19
|
+
# If the file doesn't exist yet, records +:did_not_exist+.
|
|
20
|
+
def save(path)
|
|
21
|
+
key = File.expand_path(path)
|
|
22
|
+
content = File.exist?(key) ? File.read(key) : :did_not_exist
|
|
23
|
+
@mutex.synchronize { @snapshots[key].push(content) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Pop and return the most recent snapshot for +path+, or +nil+
|
|
27
|
+
# if there is no history.
|
|
28
|
+
def pop(path)
|
|
29
|
+
key = File.expand_path(path)
|
|
30
|
+
@mutex.synchronize { @snapshots[key].pop }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Clear all snapshots. Used in tests and session resets.
|
|
34
|
+
def clear!
|
|
35
|
+
@mutex.synchronize { @snapshots.clear }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class FSPatch <
|
|
9
|
+
class FSPatch < Brute::Tool
|
|
10
10
|
description 'Replace a specific string in a file. The old_string must match exactly ' \
|
|
11
11
|
'(including whitespace and indentation). Always read a file before patching it.'
|
|
12
12
|
|
|
@@ -19,13 +19,13 @@ module Brute
|
|
|
19
19
|
|
|
20
20
|
def execute(file_path:, old_string:, new_string:, replace_all: false)
|
|
21
21
|
path = File.expand_path(file_path)
|
|
22
|
-
Brute::
|
|
22
|
+
Brute::Tools::FS::FileMutationQueue.serialize(path) do
|
|
23
23
|
raise "File not found: #{path}" unless File.exist?(path)
|
|
24
24
|
|
|
25
25
|
original = File.read(path)
|
|
26
26
|
raise "old_string not found in #{path}" unless original.include?(old_string)
|
|
27
27
|
|
|
28
|
-
Brute::
|
|
28
|
+
Brute::Tools::FS::SnapshotStore.save(path)
|
|
29
29
|
|
|
30
30
|
updated = if replace_all
|
|
31
31
|
original.gsub(old_string, new_string)
|
|
@@ -43,7 +43,9 @@ module Brute
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
__END__
|
|
47
|
+
|
|
48
|
+
describe "brute/tools/fs_patch" do
|
|
47
49
|
require "tmpdir"
|
|
48
50
|
|
|
49
51
|
it "replaces old_string with new_string" do
|
data/lib/brute/tools/fs_read.rb
CHANGED
|
@@ -26,9 +26,9 @@ module Brute
|
|
|
26
26
|
# 7. File-not-found suggestions — on miss, scan the parent directory for
|
|
27
27
|
# similar names and suggest "Did you mean...?" candidates.
|
|
28
28
|
# 8. Return a plain string instead of a Hash — avoids the .to_s repr
|
|
29
|
-
# bloat when
|
|
29
|
+
# bloat when ToolPipeline coerces the result for the LLM message.
|
|
30
30
|
#
|
|
31
|
-
class FSRead <
|
|
31
|
+
class FSRead < Brute::Tool
|
|
32
32
|
description "Read the contents of a file. Returns file content with line numbers. " \
|
|
33
33
|
"Use start_line/end_line for partial reads of large files."
|
|
34
34
|
|
|
@@ -146,7 +146,9 @@ module Brute
|
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
__END__
|
|
150
|
+
|
|
151
|
+
describe "brute/tools/fs_read" do
|
|
150
152
|
require "tmpdir"
|
|
151
153
|
|
|
152
154
|
it "reads a file without error" do
|
|
@@ -7,7 +7,7 @@ require "fileutils"
|
|
|
7
7
|
|
|
8
8
|
module Brute
|
|
9
9
|
module Tools
|
|
10
|
-
class FSRemove <
|
|
10
|
+
class FSRemove < Brute::Tool
|
|
11
11
|
description "Remove a file or empty directory."
|
|
12
12
|
|
|
13
13
|
param :path, type: 'string', desc: "Path to the file or directory to remove", required: true
|
|
@@ -16,10 +16,10 @@ module Brute
|
|
|
16
16
|
|
|
17
17
|
def execute(path:)
|
|
18
18
|
target = File.expand_path(path)
|
|
19
|
-
Brute::
|
|
19
|
+
Brute::Tools::FS::FileMutationQueue.serialize(target) do
|
|
20
20
|
raise "Path not found: #{target}" unless File.exist?(target)
|
|
21
21
|
|
|
22
|
-
Brute::
|
|
22
|
+
Brute::Tools::FS::SnapshotStore.save(target) if File.file?(target)
|
|
23
23
|
|
|
24
24
|
if File.directory?(target)
|
|
25
25
|
Dir.rmdir(target)
|