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/agent.rb
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
require 'brute/pipeline'
|
|
6
|
-
|
|
7
|
-
module Brute
|
|
8
|
-
DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant, hellbent on taking over the world."
|
|
9
|
-
|
|
10
|
-
# An agent is a Pipeline configured for LLM turns. It carries the
|
|
11
|
-
# provider/model/tools configuration and shapes env from a Session
|
|
12
|
-
# (the conversation message log).
|
|
13
|
-
#
|
|
14
|
-
# Usage:
|
|
15
|
-
#
|
|
16
|
-
# agent = Brute::Agent.new(
|
|
17
|
-
# provider: Brute.provider,
|
|
18
|
-
# model: "claude-sonnet-4-20250514",
|
|
19
|
-
# tools: Brute::Tools::ALL,
|
|
20
|
-
# ) do
|
|
21
|
-
# use Brute::Middleware::EventHandler, handler_class: TerminalOutput
|
|
22
|
-
# use Brute::Middleware::SystemPrompt
|
|
23
|
-
# use Brute::Middleware::MaxIterations
|
|
24
|
-
# use Brute::Middleware::Question
|
|
25
|
-
# use Brute::Middleware::ToolCall
|
|
26
|
-
# run Brute::Middleware::LLMCall.new
|
|
27
|
-
# end
|
|
28
|
-
#
|
|
29
|
-
# session = Brute::Session.new
|
|
30
|
-
# session.user("fix the failing tests")
|
|
31
|
-
# agent.call(session)
|
|
32
|
-
#
|
|
33
|
-
class Agent < Pipeline
|
|
34
|
-
attr_reader :provider, :model, :tools
|
|
35
|
-
|
|
36
|
-
def initialize(provider:, model: nil, tools: [], &block)
|
|
37
|
-
@provider = provider
|
|
38
|
-
@model = model
|
|
39
|
-
@tools = tools
|
|
40
|
-
super(&block)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Run one turn against the given session. The session is mutated
|
|
44
|
-
# in place (assistant + tool messages appended). Returns the env
|
|
45
|
-
# hash so callers can access metadata (timing, tokens, etc.).
|
|
46
|
-
def call(session, events: NullSink.new)
|
|
47
|
-
env = {
|
|
48
|
-
messages: session,
|
|
49
|
-
provider: @provider,
|
|
50
|
-
model: @model,
|
|
51
|
-
tools: @tools,
|
|
52
|
-
events: events,
|
|
53
|
-
metadata: {},
|
|
54
|
-
system_prompt: DEFAULT_SYSTEM_PROMPT,
|
|
55
|
-
current_iteration: 1,
|
|
56
|
-
}
|
|
57
|
-
super(env)
|
|
58
|
-
env
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
test do
|
|
64
|
-
it "runs a turn and returns the env with session in :messages" do
|
|
65
|
-
agent = Brute::Agent.new(provider: :stub) do
|
|
66
|
-
run ->(env) { env[:messages].assistant("hello") }
|
|
67
|
-
end
|
|
68
|
-
session = Brute::Session.new
|
|
69
|
-
session.user("hi")
|
|
70
|
-
env = agent.call(session)
|
|
71
|
-
env[:messages].should == session
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
it "passes provider/model/tools through env" do
|
|
75
|
-
captured = nil
|
|
76
|
-
capture = ->(env) { captured = env.slice(:provider, :model, :tools) }
|
|
77
|
-
|
|
78
|
-
agent = Brute::Agent.new(provider: :stub, model: "m", tools: [:a]) { run capture }
|
|
79
|
-
agent.call(Brute::Session.new)
|
|
80
|
-
captured.should == { provider: :stub, model: "m", tools: [:a] }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Re-invokes the inner stack whenever the last message is a :tool result.
|
|
9
|
-
#
|
|
10
|
-
# After the inner pipeline runs (LLMCall responds, ToolCall executes tools
|
|
11
|
-
# and appends :tool messages), this middleware checks if tool results are
|
|
12
|
-
# pending. If so, it increments the iteration counter and loops — sending
|
|
13
|
-
# the tool results back through MaxIterations → ToolCall → LLMCall so the
|
|
14
|
-
# LLM can see them.
|
|
15
|
-
#
|
|
16
|
-
# The loop breaks when:
|
|
17
|
-
# - The LLM responds with text only (no tool calls) — last message is :assistant
|
|
18
|
-
# - env[:should_exit] is set (e.g. by MaxIterations)
|
|
19
|
-
#
|
|
20
|
-
class ToolResultLoop
|
|
21
|
-
def initialize(app)
|
|
22
|
-
@app = app
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def call(env)
|
|
26
|
-
loop do
|
|
27
|
-
@app.call(env)
|
|
28
|
-
|
|
29
|
-
break if env[:should_exit]
|
|
30
|
-
break unless env[:messages].last&.role == :tool
|
|
31
|
-
|
|
32
|
-
env[:current_iteration] += 1
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
env
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
test do
|
|
42
|
-
require "brute/session"
|
|
43
|
-
|
|
44
|
-
it "loops until last message is not a tool result" do
|
|
45
|
-
call_count = 0
|
|
46
|
-
|
|
47
|
-
# Fake inner app: first call appends a :tool message, second call appends :assistant
|
|
48
|
-
inner = ->(env) do
|
|
49
|
-
call_count += 1
|
|
50
|
-
if call_count == 1
|
|
51
|
-
env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
|
|
52
|
-
else
|
|
53
|
-
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
mw = Brute::Middleware::ToolResultLoop.new(inner)
|
|
58
|
-
env = { messages: Brute::Session.new, current_iteration: 1 }
|
|
59
|
-
env[:messages].user("hi")
|
|
60
|
-
|
|
61
|
-
mw.call(env)
|
|
62
|
-
|
|
63
|
-
call_count.should == 2
|
|
64
|
-
env[:current_iteration].should == 2
|
|
65
|
-
env[:messages].last.role.should == :assistant
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
it "stops when should_exit is set" do
|
|
69
|
-
call_count = 0
|
|
70
|
-
|
|
71
|
-
inner = ->(env) do
|
|
72
|
-
call_count += 1
|
|
73
|
-
env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
|
|
74
|
-
env[:should_exit] = { reason: "max" } if call_count >= 2
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
mw = Brute::Middleware::ToolResultLoop.new(inner)
|
|
78
|
-
env = { messages: Brute::Session.new, current_iteration: 1 }
|
|
79
|
-
env[:messages].user("hi")
|
|
80
|
-
|
|
81
|
-
mw.call(env)
|
|
82
|
-
|
|
83
|
-
call_count.should == 2
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
it "does not loop when last message is assistant" do
|
|
87
|
-
call_count = 0
|
|
88
|
-
|
|
89
|
-
inner = ->(env) do
|
|
90
|
-
call_count += 1
|
|
91
|
-
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hello")
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
mw = Brute::Middleware::ToolResultLoop.new(inner)
|
|
95
|
-
env = { messages: Brute::Session.new, current_iteration: 1 }
|
|
96
|
-
env[:messages].user("hi")
|
|
97
|
-
|
|
98
|
-
mw.call(env)
|
|
99
|
-
|
|
100
|
-
call_count.should == 1
|
|
101
|
-
env[:current_iteration].should == 1
|
|
102
|
-
end
|
|
103
|
-
end
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Terminal middleware. Calls the LLM with the current conversation,
|
|
9
|
-
# appends the response to the session, and fires events along the way.
|
|
10
|
-
#
|
|
11
|
-
class LLMCall
|
|
12
|
-
def call(env)
|
|
13
|
-
|
|
14
|
-
available_tools = env[:tools].each_with_object({}) do |tool, hash|
|
|
15
|
-
instance = tool.is_a?(Class) ? tool.new : tool
|
|
16
|
-
instance = instance.to_ruby_llm if instance.respond_to?(:to_ruby_llm)
|
|
17
|
-
hash[instance.name.to_sym] = instance
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
completion_options = {
|
|
21
|
-
model: RubyLLM.models.find(env[:model], env[:provider]),
|
|
22
|
-
tools: available_tools,
|
|
23
|
-
temperature: env.fetch(:temperature, 0.7),
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
complete(completion_options, env).then do |response|
|
|
27
|
-
env[:messages] << response
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
env
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def complete(kwargs, env)
|
|
36
|
-
provider_client = RubyLLM::Provider.resolve(env[:provider]).new(Brute.config)
|
|
37
|
-
|
|
38
|
-
if env[:streaming] == true
|
|
39
|
-
provider_client.complete(env[:messages], **kwargs) do |chunk|
|
|
40
|
-
if chunk.content && !chunk.content.to_s.empty?
|
|
41
|
-
env[:events] << { type: :content, data: chunk.content.to_s }
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
if chunk.respond_to?(:thinking) && chunk.thinking&.respond_to?(:text) && chunk.thinking.text
|
|
45
|
-
env[:events] << { type: :reasoning, data: chunk.thinking.text }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
else
|
|
49
|
-
provider_client.complete(env[:messages], **kwargs).then do |response|
|
|
50
|
-
if response.content.present?
|
|
51
|
-
env[:events] << { type: :content, data: response.content }
|
|
52
|
-
end
|
|
53
|
-
response
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
test do
|
|
62
|
-
# not implemented
|
|
63
|
-
end
|
data/lib/brute/pipeline.rb
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
# Generic middleware machinery. Builds a chain of middleware around
|
|
8
|
-
# a terminal app, exposes `call(env)` to invoke it.
|
|
9
|
-
#
|
|
10
|
-
# Subclasses (Agent, Tool) override `call` to translate their public
|
|
11
|
-
# arguments into an env hash, then delegate to super.
|
|
12
|
-
#
|
|
13
|
-
# class MyPipeline < Brute::Pipeline
|
|
14
|
-
# def call(input)
|
|
15
|
-
# env = { input: input, output: nil }
|
|
16
|
-
# super(env)
|
|
17
|
-
# env[:output]
|
|
18
|
-
# end
|
|
19
|
-
# end
|
|
20
|
-
#
|
|
21
|
-
class Pipeline
|
|
22
|
-
def initialize(&block)
|
|
23
|
-
@middlewares = []
|
|
24
|
-
@app = nil
|
|
25
|
-
instance_eval(&block) if block_given?
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Register a middleware class.
|
|
29
|
-
# The class must implement `initialize(app, *args, **kwargs)` and `call(env)`.
|
|
30
|
-
def use(klass, *args, **kwargs, &block)
|
|
31
|
-
tap { @middlewares << [klass, args, kwargs, block] }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Set the terminal app (innermost handler).
|
|
35
|
-
# Accepts an instance (anything responding to #call(env)) or a class.
|
|
36
|
-
def run(app)
|
|
37
|
-
tap { @app = app }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Invoke the chain. Subclasses typically override this to shape env
|
|
41
|
-
# and extract a return value.
|
|
42
|
-
def call(env)
|
|
43
|
-
build.call(env)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Build the chain without calling it. Useful for inspection or caching.
|
|
47
|
-
def build
|
|
48
|
-
raise "Stack has no terminal app — call `run` first" unless @app
|
|
49
|
-
|
|
50
|
-
@middlewares.reverse.inject(@app) do |inner, (klass, args, kwargs, block)|
|
|
51
|
-
if block
|
|
52
|
-
klass.new(inner, *args, **kwargs, &block)
|
|
53
|
-
else
|
|
54
|
-
klass.new(inner, *args, **kwargs)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Default null sink for env[:events] — swallows anything pushed to it.
|
|
60
|
-
class NullSink
|
|
61
|
-
def <<(_event); self; end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
test do
|
|
67
|
-
it "builds and calls a chain" do
|
|
68
|
-
seen = []
|
|
69
|
-
inc = Class.new do
|
|
70
|
-
def initialize(app, label:); @app = app; @label = label; end
|
|
71
|
-
def call(env); env[:trace] << @label; @app.call(env); env[:trace] << "#{@label}-after"; end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
pipeline = Brute::Pipeline.new do
|
|
75
|
-
use inc, label: "outer"
|
|
76
|
-
use inc, label: "inner"
|
|
77
|
-
run ->(env) { env[:trace] << "core" }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
env = { trace: [] }
|
|
81
|
-
pipeline.call(env)
|
|
82
|
-
env[:trace].should == ["outer", "inner", "core", "inner-after", "outer-after"]
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
it "raises when run was never called" do
|
|
86
|
-
lambda { Brute::Pipeline.new.call({}) }.should.raise(RuntimeError)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
it "accepts a callable as the terminal app" do
|
|
90
|
-
pipeline = Brute::Pipeline.new do
|
|
91
|
-
run ->(env) { env[:result] = 42 }
|
|
92
|
-
end
|
|
93
|
-
env = {}
|
|
94
|
-
pipeline.call(env)
|
|
95
|
-
env[:result].should == 42
|
|
96
|
-
end
|
|
97
|
-
end
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Brute
|
|
4
|
-
# @namespace
|
|
5
|
-
module Queue
|
|
6
|
-
# Per-file serialization queue for concurrent tool execution.
|
|
7
|
-
#
|
|
8
|
-
# When tools run in parallel (via threads or async fibers), multiple tools
|
|
9
|
-
# may target the same file simultaneously. Without serialization, a sequence
|
|
10
|
-
# like [read → patch → write] on the same file would race and lose edits.
|
|
11
|
-
#
|
|
12
|
-
# This module provides a single public method:
|
|
13
|
-
#
|
|
14
|
-
# Brute::FileMutationQueue.serialize("/path/to/file") do
|
|
15
|
-
# # snapshot + read + modify + write — all atomic for this path
|
|
16
|
-
# end
|
|
17
|
-
#
|
|
18
|
-
# Design (mirrors pi-mono's withFileMutationQueue):
|
|
19
|
-
# - Operations on the SAME file are serialized (run one at a time)
|
|
20
|
-
# - Operations on DIFFERENT files run fully in parallel (independent mutexes)
|
|
21
|
-
# - Symlink-aware: resolves real paths so aliases share one mutex
|
|
22
|
-
# - Error-safe: mutex is always released in `ensure`, so failures never deadlock
|
|
23
|
-
# - Self-cleaning: per-file mutexes are removed when no longer in use
|
|
24
|
-
#
|
|
25
|
-
# Ruby 3.4's Mutex is fiber-scheduler-aware, so this works correctly with
|
|
26
|
-
# both :thread and :task (Async) concurrency strategies.
|
|
27
|
-
#
|
|
28
|
-
module FileMutationQueue
|
|
29
|
-
@mutexes = {} # path → Mutex
|
|
30
|
-
@waiters = Hash.new(0) # path → number of threads/fibers waiting or holding
|
|
31
|
-
@guard = Mutex.new # protects @mutexes and @waiters
|
|
32
|
-
|
|
33
|
-
class << self
|
|
34
|
-
# Serialize a block of work for a given file path.
|
|
35
|
-
#
|
|
36
|
-
# Concurrent calls targeting the same canonical path will execute
|
|
37
|
-
# sequentially in FIFO order. Calls targeting different paths
|
|
38
|
-
# proceed in parallel with zero contention.
|
|
39
|
-
#
|
|
40
|
-
# @parameter path [String] The file path to serialize on.
|
|
41
|
-
# @yields {block} The mutation work to perform (snapshot, read, write, etc.)
|
|
42
|
-
# @returns Whatever the block returns.
|
|
43
|
-
def serialize(path, &block)
|
|
44
|
-
key = canonical_path(path)
|
|
45
|
-
mutex = acquire_mutex(key)
|
|
46
|
-
|
|
47
|
-
mutex.synchronize(&block)
|
|
48
|
-
ensure
|
|
49
|
-
release_mutex(key)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Clear all tracked mutexes. Used in tests and session resets.
|
|
53
|
-
def clear!
|
|
54
|
-
@guard.synchronize do
|
|
55
|
-
@mutexes.clear
|
|
56
|
-
@waiters.clear
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Number of file paths currently tracked (for diagnostics).
|
|
61
|
-
def size
|
|
62
|
-
@guard.synchronize { @mutexes.size }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
# Resolve a file path to a canonical key.
|
|
68
|
-
# Uses File.realpath to follow symlinks so that aliases to the
|
|
69
|
-
# same underlying file share one mutex. Falls back to
|
|
70
|
-
# File.expand_path for files that don't exist yet (e.g., new writes).
|
|
71
|
-
def canonical_path(path)
|
|
72
|
-
resolved = File.expand_path(path)
|
|
73
|
-
begin
|
|
74
|
-
File.realpath(resolved)
|
|
75
|
-
rescue Errno::ENOENT
|
|
76
|
-
resolved
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Get (or create) a mutex for a file path and increment the waiter count.
|
|
81
|
-
def acquire_mutex(key)
|
|
82
|
-
@guard.synchronize do
|
|
83
|
-
@mutexes[key] ||= Mutex.new
|
|
84
|
-
@waiters[key] += 1
|
|
85
|
-
@mutexes[key]
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Decrement the waiter count and clean up the mutex if no one else needs it.
|
|
90
|
-
def release_mutex(key)
|
|
91
|
-
@guard.synchronize do
|
|
92
|
-
@waiters[key] -= 1
|
|
93
|
-
if @waiters[key] <= 0
|
|
94
|
-
@mutexes.delete(key)
|
|
95
|
-
@waiters.delete(key)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
data/lib/brute/session.rb
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
class Session < Array
|
|
8
|
-
attr_reader :path
|
|
9
|
-
|
|
10
|
-
def initialize(path: nil)
|
|
11
|
-
super()
|
|
12
|
-
@path = path
|
|
13
|
-
if @path
|
|
14
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
15
|
-
if File.exist?(@path)
|
|
16
|
-
File.foreach(@path) do |line|
|
|
17
|
-
line.strip!
|
|
18
|
-
# Use push to bypass append persistence (avoids re-writing existing lines)
|
|
19
|
-
push(RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true))) if line.present?
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# @deprecated Use Session.new(path:) instead.
|
|
26
|
-
def self.from_jsonl(path)
|
|
27
|
-
new(path: path)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Append a message and persist it to disk if a path is set.
|
|
31
|
-
def <<(msg)
|
|
32
|
-
super
|
|
33
|
-
if @path
|
|
34
|
-
File.open(@path, "a") { |f| f.puts(JSON.generate(msg.to_h)) }
|
|
35
|
-
end
|
|
36
|
-
self
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def user(content)
|
|
40
|
-
self << RubyLLM::Message.new(role: :user, content: content)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def assistant(content)
|
|
44
|
-
self << RubyLLM::Message.new(role: :assistant, content: content)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def system(content)
|
|
48
|
-
self << RubyLLM::Message.new(role: :system, content: content)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Brute
|
|
4
|
-
# @namespace
|
|
5
|
-
module Store
|
|
6
|
-
# Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
|
|
7
|
-
# to enable undo. Each call to .save pushes the current content (or
|
|
8
|
-
# :did_not_exist for new files). .pop retrieves the most recent snapshot.
|
|
9
|
-
module SnapshotStore
|
|
10
|
-
@snapshots = Hash.new { |h, k| h[k] = [] }
|
|
11
|
-
@mutex = Mutex.new
|
|
12
|
-
|
|
13
|
-
class << self
|
|
14
|
-
# Push the current content of +path+ onto the snapshot stack.
|
|
15
|
-
# If the file doesn't exist yet, records +:did_not_exist+.
|
|
16
|
-
def save(path)
|
|
17
|
-
key = File.expand_path(path)
|
|
18
|
-
content = File.exist?(key) ? File.read(key) : :did_not_exist
|
|
19
|
-
@mutex.synchronize { @snapshots[key].push(content) }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Pop and return the most recent snapshot for +path+, or +nil+
|
|
23
|
-
# if there is no history.
|
|
24
|
-
def pop(path)
|
|
25
|
-
key = File.expand_path(path)
|
|
26
|
-
@mutex.synchronize { @snapshots[key].pop }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Clear all snapshots. Used in tests and session resets.
|
|
30
|
-
def clear!
|
|
31
|
-
@mutex.synchronize { @snapshots.clear }
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Brute
|
|
4
|
-
# @namespace
|
|
5
|
-
module Store
|
|
6
|
-
# In-memory todo list storage. The agent uses this to track multi-step tasks.
|
|
7
|
-
# The list is replaced wholesale on each todo_write call.
|
|
8
|
-
module TodoStore
|
|
9
|
-
@items = []
|
|
10
|
-
@mutex = Mutex.new
|
|
11
|
-
|
|
12
|
-
class << self
|
|
13
|
-
# Replace the entire todo list.
|
|
14
|
-
def replace(items)
|
|
15
|
-
@mutex.synchronize { @items = items.dup }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Return all current items.
|
|
19
|
-
def all
|
|
20
|
-
@mutex.synchronize { @items.dup }
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Clear all items.
|
|
24
|
-
def clear!
|
|
25
|
-
@mutex.synchronize { @items.clear }
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
data/lib/brute/sub_agent.rb
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
require 'brute/pipeline'
|
|
6
|
-
|
|
7
|
-
module Brute
|
|
8
|
-
# A SubAgent is an Agent that exposes a tool-shaped facade so it can
|
|
9
|
-
# be dropped into another agent's tools list. The parent agent's
|
|
10
|
-
# LLMCall passes it to ruby_llm as a regular tool; when invoked, the
|
|
11
|
-
# SubAgent runs its own pipeline against a fresh Session built from
|
|
12
|
-
# the tool arguments, then returns the final assistant message as the
|
|
13
|
-
# tool result.
|
|
14
|
-
#
|
|
15
|
-
# Usage:
|
|
16
|
-
#
|
|
17
|
-
# researcher = Brute::SubAgent.new(
|
|
18
|
-
# name: "research",
|
|
19
|
-
# description: "Delegate a research task to a read-only sub-agent.",
|
|
20
|
-
# provider: Brute.provider,
|
|
21
|
-
# model: Brute.provider.default_model,
|
|
22
|
-
# tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch],
|
|
23
|
-
# ) do
|
|
24
|
-
# use Brute::Middleware::SystemPrompt
|
|
25
|
-
# use Brute::Middleware::MaxIterations, max_iterations: 10
|
|
26
|
-
# use Brute::Middleware::ToolCall
|
|
27
|
-
# run Brute::Middleware::LLMCall.new
|
|
28
|
-
# end
|
|
29
|
-
#
|
|
30
|
-
# main_agent = Brute::Agent.new(
|
|
31
|
-
# provider: ...,
|
|
32
|
-
# tools: [Brute::Tools::FSRead, researcher], # SubAgent IS a tool
|
|
33
|
-
# ) { ... }
|
|
34
|
-
#
|
|
35
|
-
class SubAgent < Agent
|
|
36
|
-
DEFAULT_PARAMS = {
|
|
37
|
-
task: { type: "string", desc: "A clear, detailed description of the task", required: true },
|
|
38
|
-
}.freeze
|
|
39
|
-
|
|
40
|
-
attr_reader :sub_agent_name, :description, :params
|
|
41
|
-
|
|
42
|
-
def initialize(name:, description:, params: DEFAULT_PARAMS, **agent_opts, &block)
|
|
43
|
-
@sub_agent_name = name.to_s
|
|
44
|
-
@description = description
|
|
45
|
-
@params = params
|
|
46
|
-
super(**agent_opts, &block)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Tool-shaped entry point. Builds a session from arguments, runs the
|
|
50
|
-
# agent loop, returns the last assistant message as a string.
|
|
51
|
-
def execute(arguments)
|
|
52
|
-
session = build_session(arguments)
|
|
53
|
-
call(session)
|
|
54
|
-
extract_result(session)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Adapter so the parent agent's LLMCall (and ruby_llm) sees this as
|
|
58
|
-
# a regular tool. ToolCall middleware should call `to_ruby_llm` when
|
|
59
|
-
# building the tools hash if a tool responds to it.
|
|
60
|
-
def to_ruby_llm
|
|
61
|
-
sub = self
|
|
62
|
-
Class.new(RubyLLM::Tool) do
|
|
63
|
-
description sub.description
|
|
64
|
-
sub.params.each { |k, opts| param k, **opts }
|
|
65
|
-
define_method(:name) { sub.sub_agent_name }
|
|
66
|
-
define_method(:execute) { |**args| sub.execute(args) }
|
|
67
|
-
end.new
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Lets ToolCall treat SubAgents the same as RubyLLM::Tool instances
|
|
71
|
-
# without checking respond_to? everywhere.
|
|
72
|
-
def name
|
|
73
|
-
@sub_agent_name
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
private
|
|
77
|
-
|
|
78
|
-
def build_session(arguments)
|
|
79
|
-
task = arguments[:task] || arguments["task"]
|
|
80
|
-
Brute::Session.new.tap { |s| s.user(task) }
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def extract_result(session)
|
|
84
|
-
last = session.reverse_each.find do |m|
|
|
85
|
-
m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
|
|
86
|
-
end
|
|
87
|
-
last&.content || "(sub-agent completed but produced no text response)"
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
test do
|
|
93
|
-
it "exposes a name matching the sub-agent identifier" do
|
|
94
|
-
sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
|
|
95
|
-
run ->(env) { env[:messages].assistant("done") }
|
|
96
|
-
end
|
|
97
|
-
sa.name.should == "research"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
it "execute returns the last assistant message" do
|
|
101
|
-
sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
|
|
102
|
-
run ->(env) { env[:messages].assistant("result text") }
|
|
103
|
-
end
|
|
104
|
-
sa.execute(task: "do something").should == "result text"
|
|
105
|
-
end
|
|
106
|
-
end
|