brute 5.1.0 → 6.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/compaction/middleware/sliding_window.rb +1 -1
- data/lib/brute/compaction/middleware/strategy.rb +0 -2
- data/lib/brute/compaction/summarize.rb +30 -21
- data/lib/brute/completion/lang_chain.rb +34 -31
- data/lib/brute/completion/llmrb.rb +31 -29
- data/lib/brute/completion/open_router.rb +43 -40
- data/lib/brute/completion/ruby_llm.rb +28 -27
- data/lib/brute/contrib/otel.rb +81 -49
- data/lib/brute/eval/case.rb +7 -2
- data/lib/brute/eval/suite.rb +10 -5
- data/lib/brute/eval/transcript.rb +3 -3
- data/lib/brute/eval/world.rb +2 -2
- data/lib/brute/eval.rb +1 -1
- 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 +1 -1
- data/lib/brute/hooks.rb +88 -112
- data/lib/brute/message_transport/anthropic.rb +13 -3
- data/lib/brute/message_transport/llm.rb +8 -4
- data/lib/brute/message_transport/open_router.rb +30 -16
- data/lib/brute/message_transport/openai.rb +14 -7
- data/lib/brute/message_transport/ruby_llm.rb +21 -17
- data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
- data/lib/brute/messages.rb +15 -8
- data/lib/brute/middleware/000_base.rb +5 -5
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +26 -18
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
- data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
- data/lib/brute/prompt_template.rb +19 -15
- data/lib/brute/prompts/base.rb +19 -10
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/instructions.rb +9 -7
- data/lib/brute/prompts/skills.rb +5 -3
- data/lib/brute/rack/adapter.rb +40 -23
- data/lib/brute/skill.rb +136 -89
- data/lib/brute/system_prompt.rb +9 -9
- data/lib/brute/token_counter.rb +3 -1
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +53 -43
- data/lib/brute/tools/fs/snapshot_store.rb +5 -1
- data/lib/brute/tools/fs_patch.rb +16 -8
- data/lib/brute/tools/fs_read.rb +107 -80
- data/lib/brute/tools/fs_remove.rb +6 -2
- data/lib/brute/tools/fs_search.rb +14 -4
- data/lib/brute/tools/fs_undo.rb +6 -2
- data/lib/brute/tools/fs_write.rb +5 -1
- data/lib/brute/tools/net_fetch.rb +6 -2
- data/lib/brute/tools/question.rb +42 -39
- data/lib/brute/tools/shell.rb +20 -5
- data/lib/brute/tools/skill_load.rb +44 -39
- data/lib/brute/tools/todo_write.rb +19 -15
- data/lib/brute/truncation.rb +68 -43
- data/lib/brute/turn/agent_pipeline.rb +23 -13
- data/lib/brute/turn/compaction_pipeline.rb +4 -3
- data/lib/brute/turn/pipeline.rb +87 -96
- data/lib/brute/turn/tool_pipeline.rb +4 -3
- data/lib/brute/usage_detection/llmrb.rb +18 -14
- data/lib/brute/usage_detection/open_router.rb +20 -17
- data/lib/brute/usage_detection/ruby_llm.rb +18 -14
- data/lib/brute/usage_detection/usage.rb +10 -1
- data/lib/brute/utils/diff.rb +18 -10
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +10 -11
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +30 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -73
- data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
- data/lib/brute/middleware/event_handler.rb +0 -27
data/lib/brute/skill.rb
CHANGED
|
@@ -34,8 +34,16 @@ module Brute
|
|
|
34
34
|
# (https://agentskills.io/specification). A skill whose frontmatter violates
|
|
35
35
|
# a rule is skipped with a stderr warning naming the rule — never raised.
|
|
36
36
|
class Skill
|
|
37
|
-
attr_reader :name,
|
|
38
|
-
|
|
37
|
+
attr_reader :name,
|
|
38
|
+
:description,
|
|
39
|
+
:file_path,
|
|
40
|
+
:base_dir,
|
|
41
|
+
:content,
|
|
42
|
+
:source,
|
|
43
|
+
:license,
|
|
44
|
+
:compatibility,
|
|
45
|
+
:metadata,
|
|
46
|
+
:allowed_tools
|
|
39
47
|
|
|
40
48
|
FILENAME = "SKILL.md"
|
|
41
49
|
|
|
@@ -74,38 +82,42 @@ module Brute
|
|
|
74
82
|
def self.load(path, source: :path)
|
|
75
83
|
raw = File.read(path)
|
|
76
84
|
frontmatter, content = parse_frontmatter(path, raw)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# Unknown fields are a soft violation: warn and drop them rather than
|
|
85
|
-
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
86
|
-
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
87
|
-
# published skills carry, instead of silently dropping the whole skill.
|
|
88
|
-
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
89
|
-
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
|
|
90
|
-
|
|
91
|
-
errors = validate(frontmatter, dir_name)
|
|
92
|
-
unless errors.empty?
|
|
93
|
-
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
94
|
-
return nil
|
|
95
|
-
end
|
|
85
|
+
if frontmatter
|
|
86
|
+
dir_name = File.basename(File.dirname(path))
|
|
87
|
+
# Spec requires `name`; brute keeps the convenience of defaulting to the
|
|
88
|
+
# directory name when omitted (which trivially satisfies the dir-match rule).
|
|
89
|
+
unless frontmatter.key?("name")
|
|
90
|
+
frontmatter = { "name" => dir_name }.merge(frontmatter)
|
|
91
|
+
end
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
# Unknown fields are a soft violation: warn and drop them rather than
|
|
94
|
+
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
95
|
+
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
96
|
+
# published skills carry, instead of silently dropping the whole skill.
|
|
97
|
+
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
98
|
+
unless extra.empty?
|
|
99
|
+
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
errors = validate(frontmatter, dir_name)
|
|
103
|
+
if errors.empty?
|
|
104
|
+
new(
|
|
105
|
+
name: frontmatter["name"].to_s.strip,
|
|
106
|
+
description: frontmatter["description"].to_s.strip,
|
|
107
|
+
file_path: path,
|
|
108
|
+
content: content.to_s.strip,
|
|
109
|
+
source: source,
|
|
110
|
+
license: frontmatter["license"]&.to_s,
|
|
111
|
+
compatibility: frontmatter["compatibility"]&.to_s,
|
|
112
|
+
metadata: frontmatter["metadata"],
|
|
113
|
+
allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
|
|
114
|
+
disable_model_invocation: frontmatter["disable-model-invocation"] == true,
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
109
121
|
rescue => e
|
|
110
122
|
warn "Failed to load skill #{path}: #{e.message}"
|
|
111
123
|
nil
|
|
@@ -122,25 +134,28 @@ module Brute
|
|
|
122
134
|
|
|
123
135
|
add = lambda do |path, source|
|
|
124
136
|
skill = load(path, source: source)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
real = skill && realpath(path)
|
|
138
|
+
|
|
139
|
+
if skill && !seen_files[real]
|
|
140
|
+
if (winner = skills[skill.name])
|
|
141
|
+
warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
|
|
142
|
+
"already loaded from #{winner.file_path}"
|
|
143
|
+
else
|
|
144
|
+
seen_files[real] = true
|
|
145
|
+
skills[skill.name] = skill
|
|
146
|
+
end
|
|
134
147
|
end
|
|
135
|
-
|
|
136
|
-
seen_files[real] = true
|
|
137
|
-
skills[skill.name] = skill
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
project = File.join(cwd, ".brute", "skills")
|
|
141
151
|
glob(project) { |path| add.call(path, :project) }
|
|
142
152
|
|
|
143
|
-
global = File.join(
|
|
153
|
+
global = File.join(
|
|
154
|
+
Dir.home,
|
|
155
|
+
".config",
|
|
156
|
+
"brute",
|
|
157
|
+
"skills",
|
|
158
|
+
)
|
|
144
159
|
glob(global) { |path| add.call(path, :user) }
|
|
145
160
|
|
|
146
161
|
paths.each do |raw|
|
|
@@ -163,9 +178,11 @@ module Brute
|
|
|
163
178
|
end
|
|
164
179
|
|
|
165
180
|
def self.glob(dir, &block)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
if File.directory?(dir)
|
|
182
|
+
Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
|
|
183
|
+
else
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
169
186
|
end
|
|
170
187
|
|
|
171
188
|
def self.realpath(path)
|
|
@@ -177,70 +194,100 @@ module Brute
|
|
|
177
194
|
# Validate frontmatter against the spec. Returns an array of error strings
|
|
178
195
|
# (empty means valid), each naming the violated rule.
|
|
179
196
|
def self.validate(frontmatter, dir_name)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
[].tap do |errors|
|
|
198
|
+
errors.concat(validate_name(frontmatter["name"], dir_name))
|
|
199
|
+
errors.concat(validate_description(frontmatter["description"]))
|
|
200
|
+
|
|
201
|
+
if frontmatter.key?("compatibility")
|
|
202
|
+
compatibility = frontmatter["compatibility"]
|
|
203
|
+
if !compatibility.is_a?(String)
|
|
204
|
+
errors << "'compatibility' must be a string"
|
|
205
|
+
elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
|
|
206
|
+
errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
|
|
207
|
+
end
|
|
191
208
|
end
|
|
192
209
|
end
|
|
193
|
-
|
|
194
|
-
errors
|
|
195
210
|
end
|
|
196
211
|
|
|
197
212
|
def self.validate_name(name, dir_name)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
if name.is_a?(String) && !name.strip.empty?
|
|
214
|
+
name = name.strip.unicode_normalize(:nfkc)
|
|
215
|
+
errors = []
|
|
216
|
+
if name.length > MAX_NAME_LENGTH
|
|
217
|
+
errors << "'name' exceeds #{MAX_NAME_LENGTH} characters"
|
|
218
|
+
end
|
|
219
|
+
if name != name.downcase
|
|
220
|
+
errors << "'name' must be lowercase"
|
|
221
|
+
end
|
|
222
|
+
if name.start_with?("-") || name.end_with?("-")
|
|
223
|
+
errors << "'name' cannot start or end with a hyphen"
|
|
224
|
+
end
|
|
225
|
+
if name.include?("--")
|
|
226
|
+
errors << "'name' cannot contain consecutive hyphens"
|
|
227
|
+
end
|
|
228
|
+
unless name.match?(/\A[\p{Alnum}\-]+\z/)
|
|
229
|
+
errors << "'name' may only contain letters, digits, and hyphens"
|
|
230
|
+
end
|
|
231
|
+
if dir_name.unicode_normalize(:nfkc) != name
|
|
232
|
+
errors << "directory name '#{dir_name}' must match skill name '#{name}'"
|
|
233
|
+
end
|
|
234
|
+
errors
|
|
235
|
+
else
|
|
236
|
+
["missing required field 'name'"]
|
|
237
|
+
end
|
|
209
238
|
end
|
|
210
239
|
|
|
211
240
|
def self.validate_description(description)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
241
|
+
if description.is_a?(String) && !description.strip.empty?
|
|
242
|
+
if description.length > MAX_DESCRIPTION_LENGTH
|
|
243
|
+
["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"]
|
|
244
|
+
else
|
|
245
|
+
[]
|
|
246
|
+
end
|
|
247
|
+
else
|
|
248
|
+
["missing required field 'description'"]
|
|
249
|
+
end
|
|
216
250
|
end
|
|
217
251
|
|
|
218
252
|
# `allowed-tools` is an experimental, space-separated list of tool names.
|
|
219
253
|
def self.parse_allowed_tools(value)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
254
|
+
if value.nil?
|
|
255
|
+
nil
|
|
256
|
+
else
|
|
257
|
+
value.to_s.split(/\s+/).reject(&:empty?)
|
|
258
|
+
end
|
|
223
259
|
end
|
|
224
260
|
|
|
225
261
|
# Split YAML frontmatter from markdown body.
|
|
226
262
|
# Returns [hash, string] or [nil, nil].
|
|
227
263
|
def self.parse_frontmatter(path, raw)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
parts = nil
|
|
265
|
+
if raw.start_with?("---")
|
|
266
|
+
parts = raw.split(/^---\s*$/, 3)
|
|
267
|
+
end
|
|
232
268
|
|
|
233
|
-
frontmatter =
|
|
234
|
-
|
|
269
|
+
frontmatter = nil
|
|
270
|
+
if parts && parts.length >= 3
|
|
271
|
+
frontmatter = YAML.safe_load(parts[1])
|
|
272
|
+
end
|
|
235
273
|
|
|
236
|
-
|
|
274
|
+
if frontmatter.is_a?(Hash)
|
|
275
|
+
[frontmatter, parts[2]]
|
|
276
|
+
else
|
|
277
|
+
[nil, nil]
|
|
278
|
+
end
|
|
237
279
|
rescue => e
|
|
238
280
|
warn "Failed to parse frontmatter in #{path}: #{e.message}"
|
|
239
281
|
[nil, nil]
|
|
240
282
|
end
|
|
241
283
|
|
|
242
|
-
private_class_method :glob,
|
|
243
|
-
|
|
284
|
+
private_class_method :glob,
|
|
285
|
+
:realpath,
|
|
286
|
+
:validate,
|
|
287
|
+
:validate_name,
|
|
288
|
+
:validate_description,
|
|
289
|
+
:parse_allowed_tools,
|
|
290
|
+
:parse_frontmatter
|
|
244
291
|
end
|
|
245
292
|
end
|
|
246
293
|
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -34,11 +34,11 @@ module Brute
|
|
|
34
34
|
# upstream model family from the model name so we use the most
|
|
35
35
|
# appropriate prompt stack (e.g., anthropic stack for claude-*).
|
|
36
36
|
provider = ctx[:provider_name].to_s
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
if provider.start_with?("opencode")
|
|
38
|
+
stack_key = infer_stack_from_model(ctx[:model_name].to_s)
|
|
39
|
+
else
|
|
40
|
+
stack_key = provider
|
|
41
|
+
end
|
|
42
42
|
STACKS.fetch(stack_key, STACKS["default"]).each do |mod|
|
|
43
43
|
prompt << mod.call(ctx)
|
|
44
44
|
end
|
|
@@ -78,7 +78,7 @@ module Brute
|
|
|
78
78
|
],
|
|
79
79
|
|
|
80
80
|
# GPT-4 / o1 / o3 — pragmatic engineer persona, editing focus, autonomy
|
|
81
|
-
"openai"
|
|
81
|
+
"openai" => [
|
|
82
82
|
Prompts::Identity,
|
|
83
83
|
Prompts::EditingApproach,
|
|
84
84
|
Prompts::Autonomy,
|
|
@@ -94,7 +94,7 @@ module Brute
|
|
|
94
94
|
],
|
|
95
95
|
|
|
96
96
|
# Gemini — formal/structured, explicit workflows, security focus
|
|
97
|
-
"google"
|
|
97
|
+
"google" => [
|
|
98
98
|
Prompts::Identity,
|
|
99
99
|
Prompts::Conventions,
|
|
100
100
|
Prompts::DoingTasks,
|
|
@@ -109,7 +109,7 @@ module Brute
|
|
|
109
109
|
],
|
|
110
110
|
|
|
111
111
|
# Ollama — lean stack for local models with smaller context windows
|
|
112
|
-
"ollama"
|
|
112
|
+
"ollama" => [
|
|
113
113
|
Prompts::Identity,
|
|
114
114
|
Prompts::ToneAndStyle,
|
|
115
115
|
Prompts::Conventions,
|
|
@@ -121,7 +121,7 @@ module Brute
|
|
|
121
121
|
],
|
|
122
122
|
|
|
123
123
|
# Fallback — conservative, concise, fewer than 4 lines
|
|
124
|
-
"default"
|
|
124
|
+
"default" => [
|
|
125
125
|
Prompts::Identity,
|
|
126
126
|
Prompts::ToneAndStyle,
|
|
127
127
|
Prompts::Proactiveness,
|
data/lib/brute/token_counter.rb
CHANGED
|
@@ -37,7 +37,9 @@ module Brute
|
|
|
37
37
|
# the schemas, which are already inside it. Anything else double counts.
|
|
38
38
|
def self.estimate(env, counter: nil, tools: nil)
|
|
39
39
|
counter ||= env[:token_counter] || default
|
|
40
|
-
|
|
40
|
+
if tools.nil?
|
|
41
|
+
tools = env[:tools]
|
|
42
|
+
end
|
|
41
43
|
messages = env[:messages] || []
|
|
42
44
|
reported = env.dig(:metadata, :last_llm_usage)&.total.to_i
|
|
43
45
|
answered = messages.rindex { |message| message.role == :assistant }
|
data/lib/brute/tool.rb
CHANGED
|
@@ -30,9 +30,11 @@ module Brute
|
|
|
30
30
|
class Tool
|
|
31
31
|
class << self
|
|
32
32
|
def description(text = nil)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
if text
|
|
34
|
+
@description = text
|
|
35
|
+
else
|
|
36
|
+
@description
|
|
37
|
+
end
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Declare one parameter: param :key, type:, desc:, required:
|
|
@@ -46,9 +48,11 @@ module Brute
|
|
|
46
48
|
|
|
47
49
|
# Raw JSON-schema override for complex argument shapes.
|
|
48
50
|
def params(schema = nil)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if schema
|
|
52
|
+
@params_schema = schema.deep_symbolize_keys
|
|
53
|
+
else
|
|
54
|
+
@params_schema
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
data/lib/brute/tools/adapter.rb
CHANGED
|
@@ -39,29 +39,32 @@ module Brute
|
|
|
39
39
|
|
|
40
40
|
# Wrap a single tool of any supported shape. Idempotent.
|
|
41
41
|
def self.wrap(tool)
|
|
42
|
-
|
|
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
|
-
)
|
|
42
|
+
if tool.is_a?(Adapter)
|
|
43
|
+
tool
|
|
63
44
|
else
|
|
64
|
-
|
|
45
|
+
if tool.is_a?(Class)
|
|
46
|
+
tool = tool.new
|
|
47
|
+
end
|
|
48
|
+
case tool
|
|
49
|
+
when Hash then from_hash(tool)
|
|
50
|
+
when ::Brute::Tool then from_brute_tool(tool)
|
|
51
|
+
when Brute::Tools::SubAgent then new(
|
|
52
|
+
name: tool.name,
|
|
53
|
+
description: tool.description,
|
|
54
|
+
params: tool.params,
|
|
55
|
+
handler: ->(**args) { tool.execute(args) },
|
|
56
|
+
original: tool,
|
|
57
|
+
)
|
|
58
|
+
when Brute::Turn::ToolPipeline then new(
|
|
59
|
+
name: tool.name,
|
|
60
|
+
description: tool.description,
|
|
61
|
+
params: tool.params,
|
|
62
|
+
handler: ->(**args) { tool.call(**args) },
|
|
63
|
+
original: tool,
|
|
64
|
+
)
|
|
65
|
+
else
|
|
66
|
+
from_duck_type(tool)
|
|
67
|
+
end
|
|
65
68
|
end
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -78,7 +81,9 @@ module Brute
|
|
|
78
81
|
def self.from_hash(definition)
|
|
79
82
|
definition = definition.transform_keys(&:to_sym)
|
|
80
83
|
handler = definition.fetch(:execute) { definition[:handler] }
|
|
81
|
-
|
|
84
|
+
unless handler.respond_to?(:call)
|
|
85
|
+
raise ArgumentError, "inline tool needs an :execute proc"
|
|
86
|
+
end
|
|
82
87
|
|
|
83
88
|
new(
|
|
84
89
|
name: definition.fetch(:name).to_s,
|
|
@@ -108,7 +113,11 @@ module Brute
|
|
|
108
113
|
raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
|
|
109
114
|
end
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
if tool.respond_to?(:execute)
|
|
117
|
+
entry = tool.method(:execute)
|
|
118
|
+
else
|
|
119
|
+
entry = tool.method(:call)
|
|
120
|
+
end
|
|
112
121
|
new(
|
|
113
122
|
name: tool.name.to_s,
|
|
114
123
|
description: tool.respond_to?(:description) ? tool.description : "",
|
|
@@ -141,27 +150,28 @@ module Brute
|
|
|
141
150
|
# Library-neutral tool definition (JSON-Schema-ish). The inline `run`
|
|
142
151
|
# proc reshapes this into whatever its LLM library expects.
|
|
143
152
|
def to_h
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
if @schema
|
|
154
|
+
{ name: @name, description: @description, parameters: @schema.deep_symbolize_keys }
|
|
155
|
+
else
|
|
156
|
+
properties = @params.transform_values do |opts|
|
|
157
|
+
{
|
|
158
|
+
type: opts[:type] || "string",
|
|
159
|
+
description: opts[:desc] || opts[:description],
|
|
160
|
+
items: opts[:items],
|
|
161
|
+
enum: opts[:enum],
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
required = @params.select { |_k, opts| opts[:required] }.keys
|
|
147
165
|
{
|
|
148
|
-
|
|
149
|
-
description:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
name: @name,
|
|
167
|
+
description: @description,
|
|
168
|
+
parameters: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: properties,
|
|
171
|
+
required: required.map(&:to_s),
|
|
172
|
+
},
|
|
173
|
+
}
|
|
153
174
|
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
175
|
end
|
|
166
176
|
end
|
|
167
177
|
end
|
|
@@ -19,7 +19,11 @@ module Brute
|
|
|
19
19
|
# If the file doesn't exist yet, records +:did_not_exist+.
|
|
20
20
|
def save(path)
|
|
21
21
|
key = File.expand_path(path)
|
|
22
|
-
|
|
22
|
+
if File.exist?(key)
|
|
23
|
+
content = File.read(key)
|
|
24
|
+
else
|
|
25
|
+
content = :did_not_exist
|
|
26
|
+
end
|
|
23
27
|
@mutex.synchronize { @snapshots[key].push(content) }
|
|
24
28
|
end
|
|
25
29
|
|
data/lib/brute/tools/fs_patch.rb
CHANGED
|
@@ -20,22 +20,30 @@ module Brute
|
|
|
20
20
|
def execute(file_path:, old_string:, new_string:, replace_all: false)
|
|
21
21
|
path = File.expand_path(file_path)
|
|
22
22
|
Brute::Tools::FS::FileMutationQueue.serialize(path) do
|
|
23
|
-
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
raise "File not found: #{path}"
|
|
25
|
+
end
|
|
24
26
|
|
|
25
27
|
original = File.read(path)
|
|
26
|
-
|
|
28
|
+
unless original.include?(old_string)
|
|
29
|
+
raise "old_string not found in #{path}"
|
|
30
|
+
end
|
|
27
31
|
|
|
28
32
|
Brute::Tools::FS::SnapshotStore.save(path)
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
if replace_all
|
|
35
|
+
updated = original.gsub(old_string, new_string)
|
|
36
|
+
else
|
|
37
|
+
updated = original.sub(old_string, new_string)
|
|
38
|
+
end
|
|
35
39
|
|
|
36
40
|
File.write(path, updated)
|
|
37
41
|
diff = Brute::Diff.unified(original, updated)
|
|
38
|
-
|
|
42
|
+
if replace_all
|
|
43
|
+
count = original.scan(old_string).size
|
|
44
|
+
else
|
|
45
|
+
count = 1
|
|
46
|
+
end
|
|
39
47
|
{ success: true, file_path: path, replacements: count, diff: diff }
|
|
40
48
|
end
|
|
41
49
|
end
|