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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/messages.rb +47 -0
  6. data/lib/brute/middleware/001_otel_span.rb +4 -2
  7. data/lib/brute/middleware/002_session_log.rb +100 -0
  8. data/lib/brute/middleware/004_summarize.rb +14 -12
  9. data/lib/brute/middleware/006_loop.rb +158 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +8 -6
  11. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +5 -3
  13. data/lib/brute/middleware/040_compaction_check.rb +3 -1
  14. data/lib/brute/middleware/060_questions.rb +3 -1
  15. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
  16. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  17. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  18. data/lib/brute/middleware/event_handler.rb +3 -1
  19. data/lib/brute/middleware/user_queue.rb +3 -1
  20. data/lib/brute/prompts/autonomy.rb +3 -0
  21. data/lib/brute/prompts/base.rb +3 -0
  22. data/lib/brute/prompts/build_switch.rb +3 -1
  23. data/lib/brute/prompts/code_references.rb +3 -0
  24. data/lib/brute/prompts/code_style.rb +3 -0
  25. data/lib/brute/prompts/conventions.rb +3 -0
  26. data/lib/brute/prompts/doing_tasks.rb +3 -0
  27. data/lib/brute/prompts/editing_approach.rb +3 -0
  28. data/lib/brute/prompts/editing_constraints.rb +3 -0
  29. data/lib/brute/prompts/environment.rb +3 -1
  30. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  31. data/lib/brute/prompts/git_safety.rb +3 -0
  32. data/lib/brute/prompts/identity.rb +3 -1
  33. data/lib/brute/prompts/instructions.rb +3 -1
  34. data/lib/brute/prompts/max_steps.rb +3 -1
  35. data/lib/brute/prompts/objectivity.rb +3 -0
  36. data/lib/brute/prompts/plan_reminder.rb +3 -1
  37. data/lib/brute/prompts/proactiveness.rb +3 -0
  38. data/lib/brute/prompts/security_and_safety.rb +3 -0
  39. data/lib/brute/prompts/skills.rb +6 -2
  40. data/lib/brute/prompts/task_management.rb +3 -0
  41. data/lib/brute/prompts/tone_and_style.rb +3 -0
  42. data/lib/brute/prompts/tool_usage.rb +3 -0
  43. data/lib/brute/prompts.rb +5 -0
  44. data/lib/brute/rack/adapter.rb +237 -0
  45. data/lib/brute/skill.rb +192 -15
  46. data/lib/brute/system_prompt.rb +3 -1
  47. data/lib/brute/tools/adapter.rb +296 -0
  48. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  49. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  50. data/lib/brute/tools/fs_patch.rb +5 -3
  51. data/lib/brute/tools/fs_read.rb +4 -2
  52. data/lib/brute/tools/fs_remove.rb +2 -2
  53. data/lib/brute/tools/fs_search.rb +3 -1
  54. data/lib/brute/tools/fs_undo.rb +2 -2
  55. data/lib/brute/tools/fs_write.rb +5 -3
  56. data/lib/brute/tools/shell.rb +3 -1
  57. data/lib/brute/tools/skill_load.rb +156 -0
  58. data/lib/brute/tools/sub_agent.rb +118 -0
  59. data/lib/brute/tools/todo_list/store.rb +36 -0
  60. data/lib/brute/tools/todo_read.rb +1 -1
  61. data/lib/brute/tools/todo_write.rb +1 -1
  62. data/lib/brute/tools.rb +2 -1
  63. data/lib/brute/truncation.rb +3 -1
  64. data/lib/brute/turn/agent_pipeline.rb +168 -0
  65. data/lib/brute/turn/pipeline.rb +95 -0
  66. data/lib/brute/turn/tool_pipeline.rb +106 -0
  67. data/lib/brute/utils/diff.rb +3 -1
  68. data/lib/brute/version.rb +1 -1
  69. data/lib/brute.rb +46 -71
  70. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  71. data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
  72. data/lib/ruby_llm/message_transport.rb +117 -0
  73. metadata +34 -30
  74. data/lib/brute/agent.rb +0 -82
  75. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  76. data/lib/brute/middleware/100_llm_call.rb +0 -63
  77. data/lib/brute/pipeline.rb +0 -97
  78. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  79. data/lib/brute/session.rb +0 -51
  80. data/lib/brute/store/snapshot_store.rb +0 -36
  81. data/lib/brute/store/todo_store.rb +0 -30
  82. data/lib/brute/sub_agent.rb +0 -106
  83. data/lib/brute/tool.rb +0 -107
@@ -20,6 +20,8 @@ module Brute
20
20
  end
21
21
  end
22
22
 
23
- test do
23
+ __END__
24
+
25
+ describe "brute/middleware/event_handler" do
24
26
  # not implemented
25
27
  end
@@ -30,6 +30,8 @@ module Brute
30
30
  end
31
31
  end
32
32
 
33
- test do
33
+ __END__
34
+
35
+ describe "brute/middleware/user_queue" do
34
36
  # not implemented
35
37
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Autonomy
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  TEXT_DIR = File.expand_path("text", __dir__)
@@ -21,7 +21,9 @@ module Brute
21
21
  end
22
22
  end
23
23
 
24
- test do
24
+ __END__
25
+
26
+ describe "brute/prompts/build_switch" do
25
27
  it "returns a string" do
26
28
  Brute::Prompts::BuildSwitch.call({}).should.be.kind_of(String)
27
29
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module CodeReferences
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module CodeStyle
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Conventions
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module DoingTasks
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module EditingApproach
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module EditingConstraints
@@ -27,7 +27,9 @@ module Brute
27
27
  end
28
28
  end
29
29
 
30
- test do
30
+ __END__
31
+
32
+ describe "brute/prompts/environment" do
31
33
  require "tmpdir"
32
34
  require "fileutils"
33
35
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module FrontendTasks
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module GitSafety
@@ -13,7 +13,9 @@ module Brute
13
13
  end
14
14
  end
15
15
 
16
- test do
16
+ __END__
17
+
18
+ describe "brute/prompts/identity" do
17
19
  it "returns a string for anthropic" do
18
20
  Brute::Prompts::Identity.call(provider_name: "anthropic").should.be.kind_of(String)
19
21
  end
@@ -20,7 +20,9 @@ module Brute
20
20
  end
21
21
  end
22
22
 
23
- test do
23
+ __END__
24
+
25
+ describe "brute/prompts/instructions" do
24
26
  it "returns nil when custom_rules is nil" do
25
27
  Brute::Prompts::Instructions.call(custom_rules: nil).should.be.nil
26
28
  end
@@ -32,7 +32,9 @@ module Brute
32
32
  end
33
33
  end
34
34
 
35
- test do
35
+ __END__
36
+
37
+ describe "brute/prompts/max_steps" do
36
38
  it "returns a string" do
37
39
  Brute::Prompts::MaxSteps.call({}).should.be.kind_of(String)
38
40
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Objectivity
@@ -42,7 +42,9 @@ module Brute
42
42
  end
43
43
  end
44
44
 
45
- test do
45
+ __END__
46
+
47
+ describe "brute/prompts/plan_reminder" do
46
48
  it "returns a string" do
47
49
  Brute::Prompts::PlanReminder.call({}).should.be.kind_of(String)
48
50
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module Proactiveness
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module SecurityAndSafety
@@ -15,7 +15,9 @@ module Brute
15
15
 
16
16
  <<~TXT
17
17
  Skills provide specialized instructions and workflows for specific tasks.
18
- When a task matches a skill's description, load the skill to get detailed guidance.
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
- test do
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
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module TaskManagement
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module ToneAndStyle
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Prompts
5
8
  module ToolUsage
data/lib/brute/prompts.rb CHANGED
@@ -1,3 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
1
6
  require 'brute/prompts/base'
2
7
  require 'brute/prompts/identity'
3
8
  require 'brute/prompts/tone_and_style'
@@ -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