brute 2.0.5 → 3.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/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/messages.rb +47 -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 +14 -12
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +8 -6
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +5 -3
- data/lib/brute/middleware/040_compaction_check.rb +3 -1
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
- 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/tools/adapter.rb +296 -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 +5 -3
- data/lib/brute/tools/fs_read.rb +4 -2
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_search.rb +3 -1
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +5 -3
- data/lib/brute/tools/shell.rb +3 -1
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +118 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +168 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +106 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +46 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
- data/lib/ruby_llm/message_transport.rb +117 -0
- metadata +32 -28
- 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 -52
- 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 +0 -107
|
@@ -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
|
@@ -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,7 +26,7 @@ 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
31
|
class FSRead < RubyLLM::Tool
|
|
32
32
|
description "Read the contents of a file. Returns file content with line numbers. " \
|
|
@@ -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
|
|
@@ -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)
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -16,8 +16,8 @@ module Brute
|
|
|
16
16
|
|
|
17
17
|
def execute(path:)
|
|
18
18
|
target = File.expand_path(path)
|
|
19
|
-
Brute::
|
|
20
|
-
snapshot = Brute::
|
|
19
|
+
Brute::Tools::FS::FileMutationQueue.serialize(target) do
|
|
20
|
+
snapshot = Brute::Tools::FS::SnapshotStore.pop(target)
|
|
21
21
|
raise "No undo history available for: #{target}" unless snapshot
|
|
22
22
|
|
|
23
23
|
if snapshot == :did_not_exist
|
data/lib/brute/tools/fs_write.rb
CHANGED
|
@@ -18,9 +18,9 @@ module Brute
|
|
|
18
18
|
|
|
19
19
|
def execute(file_path:, content:)
|
|
20
20
|
path = File.expand_path(file_path)
|
|
21
|
-
Brute::
|
|
21
|
+
Brute::Tools::FS::FileMutationQueue.serialize(path) do
|
|
22
22
|
old_content = File.exist?(path) ? File.read(path) : ''
|
|
23
|
-
Brute::
|
|
23
|
+
Brute::Tools::FS::SnapshotStore.save(path)
|
|
24
24
|
FileUtils.mkdir_p(File.dirname(path))
|
|
25
25
|
File.write(path, content)
|
|
26
26
|
diff = Brute::Diff.unified(old_content, content)
|
|
@@ -31,7 +31,9 @@ module Brute
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
__END__
|
|
35
|
+
|
|
36
|
+
describe "brute/tools/fs_write" do
|
|
35
37
|
require "tmpdir"
|
|
36
38
|
|
|
37
39
|
it "writes content to a new file" do
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require "bundler/setup"
|
|
7
|
+
require "brute"
|
|
8
|
+
require "brute/tools"
|
|
9
|
+
|
|
10
|
+
module Brute
|
|
11
|
+
module Tools
|
|
12
|
+
# The `skill` tool — stages 2 (activation) and 3 (execution) of the Agent
|
|
13
|
+
# Skills progressive-disclosure lifecycle (https://agentskills.io).
|
|
14
|
+
#
|
|
15
|
+
# Stage 1 (discovery) happens in the system prompt: Brute::Prompts::Skills
|
|
16
|
+
# lists each skill's name + description only (~100 tokens each). When a task
|
|
17
|
+
# matches, the model calls this tool with the skill name to pull the full
|
|
18
|
+
# SKILL.md body into the conversation (stage 2).
|
|
19
|
+
#
|
|
20
|
+
# The output also reports the skill's base directory and a capped listing of
|
|
21
|
+
# bundled files. That is stage 3: skills bundle scripts/, references/, and
|
|
22
|
+
# assets/ that the model runs or reads *through the agent's existing tools*
|
|
23
|
+
# (`shell`, `read`) by relative path. There is no separate skill runtime.
|
|
24
|
+
#
|
|
25
|
+
# The tool must scan the same directories as Prompts::Skills, or it would
|
|
26
|
+
# advertise skills it cannot load. Both default to Dir.pwd; agents that point
|
|
27
|
+
# the prompt at a custom root (e.g. ctx.merge(cwd: __dir__)) should build the
|
|
28
|
+
# tool with the matching cwd: Brute::Tools::SkillLoad.new(cwd: __dir__).
|
|
29
|
+
#
|
|
30
|
+
# Reference: opencode's tool/skill.ts.
|
|
31
|
+
class SkillLoad < RubyLLM::Tool
|
|
32
|
+
description "Load a specialized skill when the task at hand matches one of the " \
|
|
33
|
+
"available skills listed in the system context. This injects the skill's " \
|
|
34
|
+
"full instructions into the conversation, plus the skill's base directory " \
|
|
35
|
+
"and a listing of bundled files (scripts, references, assets) that you can " \
|
|
36
|
+
"run or read by relative path using your existing tools. The name must match " \
|
|
37
|
+
"one of the available skills."
|
|
38
|
+
|
|
39
|
+
param :name, type: "string", desc: "The name of the skill from the available skills list", required: true
|
|
40
|
+
|
|
41
|
+
FILE_LIMIT = 10
|
|
42
|
+
|
|
43
|
+
def name; "skill"; end
|
|
44
|
+
|
|
45
|
+
def initialize(cwd: Dir.pwd)
|
|
46
|
+
super()
|
|
47
|
+
@cwd = cwd
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execute(name:)
|
|
51
|
+
skill = Brute::Skill.get(name, cwd: @cwd)
|
|
52
|
+
return unknown_skill(name) unless skill
|
|
53
|
+
|
|
54
|
+
directory = File.dirname(skill.location)
|
|
55
|
+
files = bundled_files(directory)
|
|
56
|
+
|
|
57
|
+
render(skill, directory, files)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def unknown_skill(name)
|
|
63
|
+
available = Brute::Skill.all(cwd: @cwd).map(&:name)
|
|
64
|
+
listing = available.empty? ? "(none)" : available.join(", ")
|
|
65
|
+
"Error: unknown skill #{name.inspect}. Available skills: #{listing}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def bundled_files(directory)
|
|
69
|
+
Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
|
|
70
|
+
.select { |p| File.file?(p) }
|
|
71
|
+
.reject { |p| File.basename(p) == Brute::Skill::FILENAME }
|
|
72
|
+
.map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
|
|
73
|
+
.sort
|
|
74
|
+
.first(FILE_LIMIT)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render(skill, directory, files)
|
|
78
|
+
lines = [
|
|
79
|
+
"<skill name=\"#{skill.name}\">",
|
|
80
|
+
"# Skill: #{skill.name}",
|
|
81
|
+
"",
|
|
82
|
+
skill.content,
|
|
83
|
+
"",
|
|
84
|
+
"Base directory for this skill: #{directory}",
|
|
85
|
+
"Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
|
|
86
|
+
"this base directory. Use your existing tools (read, shell) to access them.",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
unless files.empty?
|
|
90
|
+
lines << ""
|
|
91
|
+
lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
|
|
92
|
+
files.each { |f| lines << " #{f}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
lines << "</skill>"
|
|
96
|
+
lines.join("\n")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
__END__
|
|
103
|
+
|
|
104
|
+
describe "brute/tools/skill_load" do
|
|
105
|
+
require "tmpdir"
|
|
106
|
+
|
|
107
|
+
def write_skill(root, name, body: "Do the thing.", frontmatter: nil)
|
|
108
|
+
dir = File.join(root, ".brute", "skills", name)
|
|
109
|
+
FileUtils.mkdir_p(dir)
|
|
110
|
+
frontmatter ||= "name: #{name}\ndescription: A skill that does #{name}"
|
|
111
|
+
File.write(File.join(dir, "SKILL.md"), "---\n#{frontmatter}\n---\n\n#{body}\n")
|
|
112
|
+
dir
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "returns the skill body and base directory" do
|
|
116
|
+
Dir.mktmpdir do |root|
|
|
117
|
+
dir = write_skill(root, "debugging", body: "Step 1. Reproduce.")
|
|
118
|
+
out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "debugging")
|
|
119
|
+
out.should =~ /Step 1\. Reproduce\./
|
|
120
|
+
out.should =~ /Base directory for this skill: #{Regexp.escape(dir)}/
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "lists bundled files but not SKILL.md itself" do
|
|
125
|
+
Dir.mktmpdir do |root|
|
|
126
|
+
dir = write_skill(root, "deploy")
|
|
127
|
+
FileUtils.mkdir_p(File.join(dir, "scripts"))
|
|
128
|
+
File.write(File.join(dir, "scripts", "run.sh"), "echo hi")
|
|
129
|
+
out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "deploy")
|
|
130
|
+
out.should =~ %r{scripts/run\.sh}
|
|
131
|
+
out.should.not =~ /SKILL\.md/
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "caps bundled files at FILE_LIMIT" do
|
|
136
|
+
Dir.mktmpdir do |root|
|
|
137
|
+
dir = write_skill(root, "many")
|
|
138
|
+
15.times { |i| File.write(File.join(dir, "f#{i}.txt"), "x") }
|
|
139
|
+
out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "many")
|
|
140
|
+
out.scan(/^ f\d+\.txt$/).size.should.be <= Brute::Tools::SkillLoad::FILE_LIMIT
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "returns a tool-error string (not an exception) for an unknown skill" do
|
|
145
|
+
Dir.mktmpdir do |root|
|
|
146
|
+
write_skill(root, "alpha")
|
|
147
|
+
out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "missing")
|
|
148
|
+
out.should =~ /unknown skill/
|
|
149
|
+
out.should =~ /alpha/
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "defaults cwd to Dir.pwd" do
|
|
154
|
+
Brute::Tools::SkillLoad.new.should.be.kind_of(Brute::Tools::SkillLoad)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require 'brute/turn/agent_pipeline'
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
module Tools
|
|
9
|
+
# A SubAgent is an Agent that exposes a tool-shaped facade so it can
|
|
10
|
+
# be dropped into another agent's tools list. The parent agent hands it
|
|
11
|
+
# to the LLM as a regular tool; when invoked, the SubAgent runs its own
|
|
12
|
+
# pipeline against a fresh Session built from the tool arguments, then
|
|
13
|
+
# returns the final assistant message as the tool result.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
#
|
|
17
|
+
# researcher = Brute::Tools::SubAgent.new(
|
|
18
|
+
# name: "research",
|
|
19
|
+
# description: "Delegate a research task to a read-only sub-agent.",
|
|
20
|
+
# ) do
|
|
21
|
+
# use Brute::Middleware::SystemPrompt
|
|
22
|
+
# use Brute::Middleware::Loop::ToolResult
|
|
23
|
+
# use Brute::Middleware::MaxIterations, max_iterations: 10
|
|
24
|
+
# use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
|
|
25
|
+
# run ->(env) do
|
|
26
|
+
# ctx = RubyLLM.context { |c| c.ollama_api_base = ENV["OLLAMA_API_BASE"] }
|
|
27
|
+
# model, provider = RubyLLM::Models.resolve(
|
|
28
|
+
# "llama3.2", provider: :ollama, assume_exists: true, config: ctx.config)
|
|
29
|
+
# response = provider.complete(env[:messages],
|
|
30
|
+
# tools: Brute.rubyllm_tools(env[:tools]),
|
|
31
|
+
# temperature: 0.7,
|
|
32
|
+
# model: model)
|
|
33
|
+
# RubyLLM::MessageTransport.new(response).wrap_each { |m| env[:messages] << m }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
|
|
38
|
+
# main_agent = Brute.agent do
|
|
39
|
+
# use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
|
|
40
|
+
# run ->(env) { ... }
|
|
41
|
+
# end
|
|
42
|
+
# main_agent.start("delegate some research")
|
|
43
|
+
#
|
|
44
|
+
class SubAgent < Brute::Turn::AgentPipeline
|
|
45
|
+
DEFAULT_PARAMS = {
|
|
46
|
+
task: { type: "string", desc: "A clear, detailed description of the task", required: true },
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :sub_agent_name, :description, :params
|
|
50
|
+
|
|
51
|
+
def initialize(name:, description:, params: DEFAULT_PARAMS, &block)
|
|
52
|
+
@sub_agent_name = name.to_s
|
|
53
|
+
@description = description
|
|
54
|
+
@params = params
|
|
55
|
+
super(&block)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Tool-shaped entry point. Builds a session from arguments, runs the
|
|
59
|
+
# agent loop, returns the last assistant message as a string.
|
|
60
|
+
def execute(arguments)
|
|
61
|
+
session = build_session(arguments)
|
|
62
|
+
start(session)
|
|
63
|
+
extract_result(session)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Adapter so the parent agent's completion middleware (and ruby_llm)
|
|
67
|
+
# sees this as a regular tool. ToolPipeline middleware should call
|
|
68
|
+
# `to_ruby_llm` when building the tools hash if a tool responds to it.
|
|
69
|
+
def to_ruby_llm
|
|
70
|
+
sub = self
|
|
71
|
+
Class.new(::RubyLLM::Tool) do
|
|
72
|
+
description sub.description
|
|
73
|
+
sub.params.each { |k, opts| param k, **opts }
|
|
74
|
+
define_method(:name) { sub.sub_agent_name }
|
|
75
|
+
define_method(:execute) { |**args| sub.execute(args) }
|
|
76
|
+
end.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Lets ToolPipeline treat SubAgents the same as RubyLLM::Tool instances
|
|
80
|
+
# without checking respond_to? everywhere.
|
|
81
|
+
def name
|
|
82
|
+
@sub_agent_name
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def build_session(arguments)
|
|
88
|
+
task = arguments[:task] || arguments["task"]
|
|
89
|
+
Brute.log.tap { |s| s.user(task) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def extract_result(session)
|
|
93
|
+
last = session.reverse_each.find do |m|
|
|
94
|
+
m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
|
|
95
|
+
end
|
|
96
|
+
last&.content || "(sub-agent completed but produced no text response)"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
__END__
|
|
103
|
+
|
|
104
|
+
describe "brute/tools/sub_agent" do
|
|
105
|
+
it "exposes a name matching the sub-agent identifier" do
|
|
106
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
107
|
+
run ->(env) { env[:messages].assistant("done") }
|
|
108
|
+
end
|
|
109
|
+
sa.name.should == "research"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "execute returns the last assistant message" do
|
|
113
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
114
|
+
run ->(env) { env[:messages].assistant("result text") }
|
|
115
|
+
end
|
|
116
|
+
sa.execute(task: "do something").should == "result text"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Tools
|
|
8
|
+
# @namespace
|
|
9
|
+
module TodoList
|
|
10
|
+
# In-memory todo list storage. The agent uses this to track multi-step
|
|
11
|
+
# tasks via the todo_read / todo_write tools. The list is replaced
|
|
12
|
+
# wholesale on each todo_write call.
|
|
13
|
+
module Store
|
|
14
|
+
@items = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Replace the entire todo list.
|
|
19
|
+
def replace(items)
|
|
20
|
+
@mutex.synchronize { @items = items.dup }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Return all current items.
|
|
24
|
+
def all
|
|
25
|
+
@mutex.synchronize { @items.dup }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clear all items.
|
|
29
|
+
def clear!
|
|
30
|
+
@mutex.synchronize { @items.clear }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -36,7 +36,7 @@ module Brute
|
|
|
36
36
|
t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
|
|
37
37
|
{id: t[:id], content: t[:content], status: t[:status]}
|
|
38
38
|
end
|
|
39
|
-
Brute::Store
|
|
39
|
+
Brute::Tools::TodoList::Store.replace(items)
|
|
40
40
|
{success: true, count: items.size}
|
|
41
41
|
end
|
|
42
42
|
end
|
data/lib/brute/tools.rb
CHANGED
data/lib/brute/truncation.rb
CHANGED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/messages"
|
|
6
|
+
require "brute/turn/pipeline"
|
|
7
|
+
|
|
8
|
+
module Brute
|
|
9
|
+
module Turn
|
|
10
|
+
|
|
11
|
+
# agent = Brute.agent # => AgentPipeline
|
|
12
|
+
# .use(Middleware::X) # => same AgentPipeline (.use returns self)
|
|
13
|
+
# .run ->(env) { ... } # => same AgentPipeline (.run returns self)
|
|
14
|
+
#
|
|
15
|
+
# agent.start("what changed?") # => runs the turn, returns env
|
|
16
|
+
#
|
|
17
|
+
class AgentPipeline < Pipeline
|
|
18
|
+
|
|
19
|
+
# Register a slash command:
|
|
20
|
+
#
|
|
21
|
+
# map "/weather", "Get the weather in the following location $ARGUMENTS"
|
|
22
|
+
# map("/weather") { "Get the weather in the following location $ARGUMENTS" }
|
|
23
|
+
#
|
|
24
|
+
def map(command, template = nil, &block)
|
|
25
|
+
command = command.to_s
|
|
26
|
+
command = command.start_with?("/") ? command : "/#{command}"
|
|
27
|
+
|
|
28
|
+
(@map ||= {})[command] = block || proc { template }
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate_map(default_app, mapping)
|
|
33
|
+
super.tap do |routes|
|
|
34
|
+
routes.define_singleton_method(:call) do |prompt|
|
|
35
|
+
name, args = prompt.to_s.strip.split(/\s+/, 2)
|
|
36
|
+
prompt = mapping[name].call.to_s.gsub("$ARGUMENTS", args.to_s) if mapping[name]
|
|
37
|
+
default_app.call(prompt)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(input = nil, events: NullSink.new)
|
|
43
|
+
env = {
|
|
44
|
+
messages: coerce_messages(input),
|
|
45
|
+
events: events,
|
|
46
|
+
metadata: {},
|
|
47
|
+
current_iteration: 1,
|
|
48
|
+
}
|
|
49
|
+
env.tap do
|
|
50
|
+
build.call(env)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def coerce_messages(input)
|
|
57
|
+
case input
|
|
58
|
+
when nil then Brute.log
|
|
59
|
+
when ::String then Brute.log.tap { |log| log.user(input) }
|
|
60
|
+
when ::RubyLLM::Message then Brute.log(input)
|
|
61
|
+
when ::Array then input.extend(Brute::Messages)
|
|
62
|
+
else Brute.log(input)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
__END__
|
|
70
|
+
|
|
71
|
+
describe "brute/turn/agent_pipeline" do
|
|
72
|
+
require "brute/messages"
|
|
73
|
+
|
|
74
|
+
# A top-level stub middleware the eval'd scripts can reference.
|
|
75
|
+
AgentStubMW = Class.new do
|
|
76
|
+
def initialize(app); @app = app; end
|
|
77
|
+
def call(env); env[:metadata][:mw] = true; @app.call(env); end
|
|
78
|
+
end unless defined?(AgentStubMW)
|
|
79
|
+
|
|
80
|
+
it "starts a turn and returns the env with the log in :messages" do
|
|
81
|
+
agent = Brute.agent.run(->(env) { env[:messages].assistant("hello") })
|
|
82
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
83
|
+
|
|
84
|
+
env = agent.start("hi")
|
|
85
|
+
env[:messages].map(&:role).should == [:user, :assistant]
|
|
86
|
+
env[:messages].last.content.should == "hello"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "coerces a String input into a user message" do
|
|
90
|
+
captured = nil
|
|
91
|
+
Brute.agent.run(->(env) { captured = env[:messages].dup }).start("a question")
|
|
92
|
+
captured.first.role.should == :user
|
|
93
|
+
captured.first.content.should == "a question"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "shapes env with no config (that lives in the run proc)" do
|
|
97
|
+
captured = nil
|
|
98
|
+
Brute.agent.run(->(env) { captured = env }).start
|
|
99
|
+
captured[:current_iteration].should == 1
|
|
100
|
+
captured.key?(:provider).should.be.false
|
|
101
|
+
captured.key?(:tools).should.be.false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "is its own builder — .use and .run both return the AgentPipeline" do
|
|
105
|
+
agent = Brute.agent.use(AgentStubMW)
|
|
106
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
107
|
+
|
|
108
|
+
agent.run { |env| env[:messages].assistant("done") }.should.equal?(agent)
|
|
109
|
+
|
|
110
|
+
env = agent.start("go")
|
|
111
|
+
env[:metadata][:mw].should.be.true
|
|
112
|
+
env[:messages].last.content.should == "done"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "parses a ru-style string into a runnable Agent" do
|
|
116
|
+
agent = Brute::Turn::AgentPipeline.new_from_string('run ->(env) { env[:messages].assistant("from ru") }', "(test)")
|
|
117
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
118
|
+
agent.start("hi")[:messages].last.content.should == "from ru"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "use in a ru string wraps the terminal" do
|
|
122
|
+
script = <<~RUBY
|
|
123
|
+
use AgentStubMW
|
|
124
|
+
run ->(env) { env[:messages].assistant("ok") }
|
|
125
|
+
RUBY
|
|
126
|
+
Brute::Turn::AgentPipeline.new_from_string(script, "(test)").start("go")[:metadata][:mw].should.be.true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "builds inline from a block passed to Brute.agent" do
|
|
130
|
+
agent = Brute.agent do
|
|
131
|
+
use AgentStubMW
|
|
132
|
+
run ->(env) { env[:messages].assistant("done") }
|
|
133
|
+
end
|
|
134
|
+
agent.start("go")[:messages].last.content.should == "done"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "map chains and to_app taps generate_map to build a (retargeted) URLMap" do
|
|
138
|
+
agent = Brute.agent
|
|
139
|
+
.map("/weather", "Get the weather in the following location $ARGUMENTS")
|
|
140
|
+
.run(->(prompt) { prompt })
|
|
141
|
+
|
|
142
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
|
|
143
|
+
agent.build.should.be.kind_of?(::Rack::URLMap) # to_app tapped generate_map (super)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "swaps a /command prompt for its template ($ARGUMENTS) and calls the app" do
|
|
147
|
+
got = nil
|
|
148
|
+
agent = Brute.agent
|
|
149
|
+
.map("/weather", "Get the weather in the following location $ARGUMENTS")
|
|
150
|
+
.map("/echo") { "you said: $ARGUMENTS" }
|
|
151
|
+
.run(->(prompt) { got = prompt })
|
|
152
|
+
|
|
153
|
+
agent.call("/weather London")
|
|
154
|
+
got.should == "Get the weather in the following location London"
|
|
155
|
+
agent.call("/echo hi there")
|
|
156
|
+
got.should == "you said: hi there"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "passes non-commands through untouched (and normalizes the slash)" do
|
|
160
|
+
got = nil
|
|
161
|
+
agent = Brute.agent.map("weather", "W $ARGUMENTS").run(->(prompt) { got = prompt })
|
|
162
|
+
|
|
163
|
+
agent.call("just a question")
|
|
164
|
+
got.should == "just a question"
|
|
165
|
+
agent.call("/weather NYC") # registered as "weather", normalized to "/weather"
|
|
166
|
+
got.should == "W NYC"
|
|
167
|
+
end
|
|
168
|
+
end
|