brute 2.0.6 → 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 +34 -30
- 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 +0 -107
data/lib/brute/prompts/base.rb
CHANGED
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -15,7 +15,9 @@ module Brute
|
|
|
15
15
|
|
|
16
16
|
<<~TXT
|
|
17
17
|
Skills provide specialized instructions and workflows for specific tasks.
|
|
18
|
-
|
|
18
|
+
Use the skill tool to load a skill when a task matches its description. The tool
|
|
19
|
+
returns the skill's full instructions plus a base directory whose bundled files
|
|
20
|
+
(scripts, references, assets) you can read or run by relative path.
|
|
19
21
|
|
|
20
22
|
#{listing}
|
|
21
23
|
TXT
|
|
@@ -24,7 +26,9 @@ module Brute
|
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
__END__
|
|
30
|
+
|
|
31
|
+
describe "brute/prompts/skills" do
|
|
28
32
|
require "tmpdir"
|
|
29
33
|
|
|
30
34
|
it "returns nil when no skills are found" do
|
data/lib/brute/prompts.rb
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
# The mirror image of protocol-rack. Where `Protocol::Rack::Adapter` wraps a
|
|
9
|
+
# *Rack app* so an HTTP server can drive it (env in, `[status, headers, body]`
|
|
10
|
+
# out), `Brute::Rack::Adapter` wraps a *Brute agent* so a Rack server can
|
|
11
|
+
# drive it. It is itself a Rack app — `call(env) -> [status, headers, body]` —
|
|
12
|
+
# so any AgentPipeline drops straight into a config.ru and serves over HTTP
|
|
13
|
+
# behind Falcon/Puma/etc:
|
|
14
|
+
#
|
|
15
|
+
# # config.ru
|
|
16
|
+
# agent = Brute::Turn::AgentPipeline.parse_file("examples/agents/brute.ru")
|
|
17
|
+
# run Brute::Rack::Adapter.for(agent)
|
|
18
|
+
#
|
|
19
|
+
# $ curl -d 'What files are here?' localhost:9292
|
|
20
|
+
# $ curl -H 'content-type: application/json' -d '{"prompt":"hi"}' localhost:9292
|
|
21
|
+
# $ curl 'localhost:9292/?prompt=hi'
|
|
22
|
+
#
|
|
23
|
+
# The whole job is two pure transforms — the two directions the request named:
|
|
24
|
+
#
|
|
25
|
+
# env -> prompt string (#prompt_from) the request half
|
|
26
|
+
# output -> [status, headers, body] (#response_for) the response half
|
|
27
|
+
#
|
|
28
|
+
# `call` just wires them together around one `agent.start`.
|
|
29
|
+
module Rack
|
|
30
|
+
class Adapter
|
|
31
|
+
# Body/JSON keys we accept a prompt under, in priority order. `prompt`
|
|
32
|
+
# is canonical; `message`/`input` are common aliases.
|
|
33
|
+
PROMPT_KEYS = %w[prompt message input].freeze
|
|
34
|
+
|
|
35
|
+
# Convenience factory so the config.ru reads `run Adapter.for(agent)`.
|
|
36
|
+
def self.for(agent) = new(agent)
|
|
37
|
+
|
|
38
|
+
# @parameter agent [#start] Anything with `start(prompt) -> env` — an
|
|
39
|
+
# AgentPipeline, a SubAgent, or any turn-shaped callable.
|
|
40
|
+
def initialize(agent)
|
|
41
|
+
raise ArgumentError, "agent must respond to #start" unless agent.respond_to?(:start)
|
|
42
|
+
|
|
43
|
+
@agent = agent
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Rack entry point. Extract the prompt, run one agent turn, render the
|
|
47
|
+
# assistant's reply back as an HTTP response. A missing prompt is a 400
|
|
48
|
+
# (client's fault); anything the turn raises is a 500.
|
|
49
|
+
def call(env)
|
|
50
|
+
prompt = prompt_from(env)
|
|
51
|
+
return response_for(env, 400, "No prompt provided.") if prompt.nil? || prompt.empty?
|
|
52
|
+
|
|
53
|
+
turn = @agent.start(prompt)
|
|
54
|
+
response_for(env, 200, output_of(turn))
|
|
55
|
+
rescue => error
|
|
56
|
+
response_for(env, 500, error.message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# env -> prompt string. Four ways in, most explicit first:
|
|
60
|
+
#
|
|
61
|
+
# 1. a `?prompt=` query param (never touches the body),
|
|
62
|
+
# 2. a JSON body — a known key of an object, or a bare JSON string,
|
|
63
|
+
# 3. a form-encoded `prompt=` field,
|
|
64
|
+
# 4. otherwise the raw request body IS the prompt.
|
|
65
|
+
#
|
|
66
|
+
# Returns nil when nothing usable is present.
|
|
67
|
+
def prompt_from(env)
|
|
68
|
+
request = ::Rack::Request.new(env)
|
|
69
|
+
|
|
70
|
+
if (query = request.GET["prompt"]) && !query.empty?
|
|
71
|
+
return query
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
body = read_body(request)
|
|
75
|
+
return nil if body.nil? || body.strip.empty?
|
|
76
|
+
|
|
77
|
+
if json?(request.media_type)
|
|
78
|
+
case data = parse_json(body)
|
|
79
|
+
when ::Hash then return PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
|
|
80
|
+
when ::String then return data
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
85
|
+
return field
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
body
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# output -> [status, headers, body]. Content-negotiated: JSON in (or an
|
|
92
|
+
# `Accept: application/json`) gets `{"response": "..."}` back; everything
|
|
93
|
+
# else gets `text/plain`. Errors ride the same path so a 500 body is
|
|
94
|
+
# shaped like a 200 body.
|
|
95
|
+
def response_for(env, status, output)
|
|
96
|
+
text = output.to_s
|
|
97
|
+
|
|
98
|
+
if wants_json?(env)
|
|
99
|
+
key = status == 200 ? :response : :error
|
|
100
|
+
[status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
|
|
101
|
+
else
|
|
102
|
+
[status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# The agent's answer is the last message it appended to the log.
|
|
109
|
+
def output_of(turn)
|
|
110
|
+
messages = turn.is_a?(::Hash) ? turn[:messages] : turn
|
|
111
|
+
messages&.last&.content.to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read_body(request)
|
|
115
|
+
input = request.body
|
|
116
|
+
input&.read
|
|
117
|
+
rescue
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def json?(media_type)
|
|
122
|
+
media_type.to_s.include?("json")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_json(body)
|
|
126
|
+
::JSON.parse(body)
|
|
127
|
+
rescue ::JSON::ParserError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Prefer JSON when the client posted JSON or explicitly accepts it.
|
|
132
|
+
def wants_json?(env)
|
|
133
|
+
json?(env["CONTENT_TYPE"]) || json?(env["HTTP_ACCEPT"])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
__END__
|
|
140
|
+
|
|
141
|
+
describe "brute/rack/adapter" do
|
|
142
|
+
require "stringio"
|
|
143
|
+
# Specs are eval'd standalone by the scampi runner, so make sure the pipeline
|
|
144
|
+
# is loaded here; otherwise `Brute.agent` may be undefined.
|
|
145
|
+
require "brute/turn/agent_pipeline"
|
|
146
|
+
|
|
147
|
+
# A tiny agent that echoes the prompt straight back through a real turn, and a
|
|
148
|
+
# (stateless, reusable) adapter wrapping it.
|
|
149
|
+
echo_agent = Brute.agent.run(->(env) { env[:messages].assistant("echo: #{env[:messages].last.content}") })
|
|
150
|
+
adapter = Brute::Rack::Adapter.new(echo_agent)
|
|
151
|
+
|
|
152
|
+
# Build a minimal Rack env by hand (no server needed).
|
|
153
|
+
rack_env = lambda do |method: "POST", path: "/", query: "", body: "", type: nil, accept: nil|
|
|
154
|
+
env = {
|
|
155
|
+
"REQUEST_METHOD" => method,
|
|
156
|
+
"PATH_INFO" => path,
|
|
157
|
+
"QUERY_STRING" => query,
|
|
158
|
+
"rack.input" => StringIO.new(body),
|
|
159
|
+
}
|
|
160
|
+
env["CONTENT_TYPE"] = type if type
|
|
161
|
+
env["HTTP_ACCEPT"] = accept if accept
|
|
162
|
+
env
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "is a Rack app: env in, [status, headers, body] out" do
|
|
166
|
+
status, headers, body = adapter.call(rack_env.call(body: "hello"))
|
|
167
|
+
status.should == 200
|
|
168
|
+
headers["content-type"].should == "text/plain; charset=utf-8"
|
|
169
|
+
body.first.should == "echo: hello"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "reads a raw text body as the prompt" do
|
|
173
|
+
adapter.prompt_from(rack_env.call(body: "what changed?")).should == "what changed?"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "reads a JSON body under a known key" do
|
|
177
|
+
env = rack_env.call(body: '{"prompt":"from json"}', type: "application/json")
|
|
178
|
+
adapter.prompt_from(env).should == "from json"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "accepts message/input JSON aliases" do
|
|
182
|
+
env = rack_env.call(body: '{"message":"aliased"}', type: "application/json")
|
|
183
|
+
adapter.prompt_from(env).should == "aliased"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "reads a bare JSON string body" do
|
|
187
|
+
env = rack_env.call(body: '"just a string"', type: "application/json")
|
|
188
|
+
adapter.prompt_from(env).should == "just a string"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "reads a form-encoded prompt field" do
|
|
192
|
+
env = rack_env.call(body: "prompt=formed&x=1", type: "application/x-www-form-urlencoded")
|
|
193
|
+
adapter.prompt_from(env).should == "formed"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "reads a ?prompt= query param without consuming the body" do
|
|
197
|
+
env = rack_env.call(method: "GET", query: "prompt=queried", body: "")
|
|
198
|
+
adapter.prompt_from(env).should == "queried"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "returns nil for an empty request" do
|
|
202
|
+
adapter.prompt_from(rack_env.call(body: " ")).should.be.nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "answers a missing prompt with 400" do
|
|
206
|
+
status, _headers, body = adapter.call(rack_env.call(body: ""))
|
|
207
|
+
status.should == 400
|
|
208
|
+
body.first.should == "No prompt provided."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "content-negotiates JSON responses" do
|
|
212
|
+
env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
|
|
213
|
+
status, headers, body = adapter.call(env)
|
|
214
|
+
status.should == 200
|
|
215
|
+
headers["content-type"].should == "application/json"
|
|
216
|
+
JSON.parse(body.first).should == { "response" => "echo: hi" }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "honors an Accept: application/json header" do
|
|
220
|
+
env = rack_env.call(body: "hi", accept: "application/json")
|
|
221
|
+
_status, headers, _body = adapter.call(env)
|
|
222
|
+
headers["content-type"].should == "application/json"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "renders a raised turn as a 500 (JSON error key when negotiated)" do
|
|
226
|
+
boom = Brute.agent.run(->(_env) { raise "kaboom" })
|
|
227
|
+
env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
|
|
228
|
+
status, _headers, body = Brute::Rack::Adapter.new(boom).call(env)
|
|
229
|
+
status.should == 500
|
|
230
|
+
JSON.parse(body.first).should == { "error" => "kaboom" }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it ".for is a factory and #start is required" do
|
|
234
|
+
Brute::Rack::Adapter.for(echo_agent).should.be.kind_of?(Brute::Rack::Adapter)
|
|
235
|
+
lambda { Brute::Rack::Adapter.new(Object.new) }.should.raise(ArgumentError)
|
|
236
|
+
end
|
|
237
|
+
end
|