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
|
@@ -21,7 +21,7 @@ module Brute
|
|
|
21
21
|
# 5. Return a plain string instead of a Hash.
|
|
22
22
|
# 6. Align output cap with universal truncation (2000 lines / 50 KB).
|
|
23
23
|
#
|
|
24
|
-
class FSSearch <
|
|
24
|
+
class FSSearch < Brute::Tool
|
|
25
25
|
description "Search file contents using ripgrep (regex), or find files by glob pattern. " \
|
|
26
26
|
"Returns matching lines with file paths and line numbers."
|
|
27
27
|
|
|
@@ -62,7 +62,9 @@ module Brute
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
__END__
|
|
66
|
+
|
|
67
|
+
describe "brute/tools/fs_search" do
|
|
66
68
|
require "tmpdir"
|
|
67
69
|
|
|
68
70
|
it "searches the current directory without error" do
|
data/lib/brute/tools/fs_undo.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class FSUndo <
|
|
9
|
+
class FSUndo < Brute::Tool
|
|
10
10
|
description "Undo the last write or patch operation on a file, restoring it to " \
|
|
11
11
|
"its previous state."
|
|
12
12
|
|
|
@@ -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
|
@@ -7,7 +7,7 @@ require 'fileutils'
|
|
|
7
7
|
|
|
8
8
|
module Brute
|
|
9
9
|
module Tools
|
|
10
|
-
class FSWrite <
|
|
10
|
+
class FSWrite < Brute::Tool
|
|
11
11
|
description "Write content to a file. Creates parent directories if they don't exist. " \
|
|
12
12
|
'Use this for creating new files or completely replacing file contents.'
|
|
13
13
|
|
|
@@ -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
|
|
@@ -9,7 +9,7 @@ require "uri"
|
|
|
9
9
|
|
|
10
10
|
module Brute
|
|
11
11
|
module Tools
|
|
12
|
-
class NetFetch <
|
|
12
|
+
class NetFetch < Brute::Tool
|
|
13
13
|
description "Fetch content from a URL. Returns the response body as text."
|
|
14
14
|
|
|
15
15
|
param :url, type: 'string', desc: "The URL to fetch", required: true
|
data/lib/brute/tools/question.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class Question <
|
|
9
|
+
class Question < Brute::Tool
|
|
10
10
|
description "Ask the user questions during execution. Use this to gather preferences, " \
|
|
11
11
|
"clarify ambiguous instructions, get decisions on implementation choices, or " \
|
|
12
12
|
"offer choices about direction. Users can always select \"Other\" to provide " \
|
data/lib/brute/tools/shell.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Brute
|
|
|
22
22
|
# the LLM (defaults to 5 minutes).
|
|
23
23
|
# 5. Return a plain string instead of a Hash.
|
|
24
24
|
#
|
|
25
|
-
class Shell <
|
|
25
|
+
class Shell < Brute::Tool
|
|
26
26
|
description "Execute a shell command and return stdout, stderr, and exit code. " \
|
|
27
27
|
"Use for git operations, running tests, installing packages, etc."
|
|
28
28
|
|
|
@@ -60,7 +60,9 @@ module Brute
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
__END__
|
|
64
|
+
|
|
65
|
+
describe "brute/tools/shell" do
|
|
64
66
|
#it "runs a command without error" do
|
|
65
67
|
# result = Brute::Tools::Shell.new.call(command: "echo hello")
|
|
66
68
|
# result.strip.should =~ /hello/
|
|
@@ -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 < Brute::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,101 @@
|
|
|
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
|
+
# # The LLM call, written with your library of choice. Convert
|
|
27
|
+
# # env[:messages] to its format, call it, and append the response
|
|
28
|
+
# # back as Brute::Message values (the MessageTransport pattern —
|
|
29
|
+
# # see examples/ruby_llm.rb, examples/openai.rb, ...).
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
|
|
34
|
+
# main_agent = Brute.agent do
|
|
35
|
+
# use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
|
|
36
|
+
# run ->(env) { ... }
|
|
37
|
+
# end
|
|
38
|
+
# main_agent.start("delegate some research")
|
|
39
|
+
#
|
|
40
|
+
class SubAgent < Brute::Turn::AgentPipeline
|
|
41
|
+
DEFAULT_PARAMS = {
|
|
42
|
+
task: { type: "string", desc: "A clear, detailed description of the task", required: true },
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :sub_agent_name, :description, :params
|
|
46
|
+
|
|
47
|
+
def initialize(name:, description:, params: DEFAULT_PARAMS, &block)
|
|
48
|
+
@sub_agent_name = name.to_s
|
|
49
|
+
@description = description
|
|
50
|
+
@params = params
|
|
51
|
+
super(&block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Tool-shaped entry point. Builds a session from arguments, runs the
|
|
55
|
+
# agent loop, returns the last assistant message as a string.
|
|
56
|
+
def execute(arguments)
|
|
57
|
+
session = build_session(arguments)
|
|
58
|
+
start(session)
|
|
59
|
+
extract_result(session)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Lets ToolPipeline treat SubAgents the same as any other tool
|
|
63
|
+
# without checking respond_to? everywhere.
|
|
64
|
+
def name
|
|
65
|
+
@sub_agent_name
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def build_session(arguments)
|
|
71
|
+
task = arguments[:task] || arguments["task"]
|
|
72
|
+
Brute.log.tap { |s| s.user(task) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_result(session)
|
|
76
|
+
last = session.reverse_each.find do |m|
|
|
77
|
+
m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
|
|
78
|
+
end
|
|
79
|
+
last&.content || "(sub-agent completed but produced no text response)"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
__END__
|
|
86
|
+
|
|
87
|
+
describe "brute/tools/sub_agent" do
|
|
88
|
+
it "exposes a name matching the sub-agent identifier" do
|
|
89
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
90
|
+
run ->(env) { env[:messages].assistant("done") }
|
|
91
|
+
end
|
|
92
|
+
sa.name.should == "research"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "execute returns the last assistant message" do
|
|
96
|
+
sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
|
|
97
|
+
run ->(env) { env[:messages].assistant("result text") }
|
|
98
|
+
end
|
|
99
|
+
sa.execute(task: "do something").should == "result text"
|
|
100
|
+
end
|
|
101
|
+
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
|
|
@@ -6,14 +6,14 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class TodoRead <
|
|
9
|
+
class TodoRead < Brute::Tool
|
|
10
10
|
description "Read the current todo list to check task status and progress."
|
|
11
11
|
param :_placeholder, type: 'string', desc: "Unused, pass any value", required: false
|
|
12
12
|
|
|
13
13
|
def name; "todo_read"; end
|
|
14
14
|
|
|
15
15
|
def execute(_placeholder: nil)
|
|
16
|
-
{todos: Brute::Store
|
|
16
|
+
{todos: Brute::Tools::TodoList::Store.all}
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -6,7 +6,7 @@ require "brute/tools"
|
|
|
6
6
|
|
|
7
7
|
module Brute
|
|
8
8
|
module Tools
|
|
9
|
-
class TodoWrite <
|
|
9
|
+
class TodoWrite < Brute::Tool
|
|
10
10
|
description "Create or update the todo list. Send the complete list each time — " \
|
|
11
11
|
"this replaces the existing list entirely."
|
|
12
12
|
|
|
@@ -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,169 @@
|
|
|
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 ::Brute::Message then Brute.log(input)
|
|
61
|
+
when ::Hash then Brute.log(Brute::Message.new(**input.transform_keys(&:to_sym)))
|
|
62
|
+
when ::Array then input.extend(Brute::Messages)
|
|
63
|
+
else Brute.log(input)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
__END__
|
|
71
|
+
|
|
72
|
+
describe "brute/turn/agent_pipeline" do
|
|
73
|
+
require "brute/messages"
|
|
74
|
+
|
|
75
|
+
# A top-level stub middleware the eval'd scripts can reference.
|
|
76
|
+
AgentStubMW = Class.new do
|
|
77
|
+
def initialize(app); @app = app; end
|
|
78
|
+
def call(env); env[:metadata][:mw] = true; @app.call(env); end
|
|
79
|
+
end unless defined?(AgentStubMW)
|
|
80
|
+
|
|
81
|
+
it "starts a turn and returns the env with the log in :messages" do
|
|
82
|
+
agent = Brute.agent.run(->(env) { env[:messages].assistant("hello") })
|
|
83
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
84
|
+
|
|
85
|
+
env = agent.start("hi")
|
|
86
|
+
env[:messages].map(&:role).should == [:user, :assistant]
|
|
87
|
+
env[:messages].last.content.should == "hello"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "coerces a String input into a user message" do
|
|
91
|
+
captured = nil
|
|
92
|
+
Brute.agent.run(->(env) { captured = env[:messages].dup }).start("a question")
|
|
93
|
+
captured.first.role.should == :user
|
|
94
|
+
captured.first.content.should == "a question"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "shapes env with no config (that lives in the run proc)" do
|
|
98
|
+
captured = nil
|
|
99
|
+
Brute.agent.run(->(env) { captured = env }).start
|
|
100
|
+
captured[:current_iteration].should == 1
|
|
101
|
+
captured.key?(:provider).should.be.false
|
|
102
|
+
captured.key?(:tools).should.be.false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "is its own builder — .use and .run both return the AgentPipeline" do
|
|
106
|
+
agent = Brute.agent.use(AgentStubMW)
|
|
107
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
108
|
+
|
|
109
|
+
agent.run { |env| env[:messages].assistant("done") }.should.equal?(agent)
|
|
110
|
+
|
|
111
|
+
env = agent.start("go")
|
|
112
|
+
env[:metadata][:mw].should.be.true
|
|
113
|
+
env[:messages].last.content.should == "done"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "parses a ru-style string into a runnable Agent" do
|
|
117
|
+
agent = Brute::Turn::AgentPipeline.new_from_string('run ->(env) { env[:messages].assistant("from ru") }', "(test)")
|
|
118
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
|
|
119
|
+
agent.start("hi")[:messages].last.content.should == "from ru"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "use in a ru string wraps the terminal" do
|
|
123
|
+
script = <<~RUBY
|
|
124
|
+
use AgentStubMW
|
|
125
|
+
run ->(env) { env[:messages].assistant("ok") }
|
|
126
|
+
RUBY
|
|
127
|
+
Brute::Turn::AgentPipeline.new_from_string(script, "(test)").start("go")[:metadata][:mw].should.be.true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "builds inline from a block passed to Brute.agent" do
|
|
131
|
+
agent = Brute.agent do
|
|
132
|
+
use AgentStubMW
|
|
133
|
+
run ->(env) { env[:messages].assistant("done") }
|
|
134
|
+
end
|
|
135
|
+
agent.start("go")[:messages].last.content.should == "done"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "map chains and to_app taps generate_map to build a (retargeted) URLMap" do
|
|
139
|
+
agent = Brute.agent
|
|
140
|
+
.map("/weather", "Get the weather in the following location $ARGUMENTS")
|
|
141
|
+
.run(->(prompt) { prompt })
|
|
142
|
+
|
|
143
|
+
agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
|
|
144
|
+
agent.build.should.be.kind_of?(::Rack::URLMap) # to_app tapped generate_map (super)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "swaps a /command prompt for its template ($ARGUMENTS) and calls the app" do
|
|
148
|
+
got = nil
|
|
149
|
+
agent = Brute.agent
|
|
150
|
+
.map("/weather", "Get the weather in the following location $ARGUMENTS")
|
|
151
|
+
.map("/echo") { "you said: $ARGUMENTS" }
|
|
152
|
+
.run(->(prompt) { got = prompt })
|
|
153
|
+
|
|
154
|
+
agent.call("/weather London")
|
|
155
|
+
got.should == "Get the weather in the following location London"
|
|
156
|
+
agent.call("/echo hi there")
|
|
157
|
+
got.should == "you said: hi there"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "passes non-commands through untouched (and normalizes the slash)" do
|
|
161
|
+
got = nil
|
|
162
|
+
agent = Brute.agent.map("weather", "W $ARGUMENTS").run(->(prompt) { got = prompt })
|
|
163
|
+
|
|
164
|
+
agent.call("just a question")
|
|
165
|
+
got.should == "just a question"
|
|
166
|
+
agent.call("/weather NYC") # registered as "weather", normalized to "/weather"
|
|
167
|
+
got.should == "W NYC"
|
|
168
|
+
end
|
|
169
|
+
end
|