brute 5.0.4 → 5.1.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/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +139 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +340 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/contrib/otel.rb +2 -2
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +249 -0
- data/lib/brute/eval/suite.rb +176 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/hooks.rb +11 -0
- data/lib/brute/middleware/000_base.rb +1 -1
- data/lib/brute/middleware/001_slash_commands.rb +88 -0
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +51 -135
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +352 -0
- data/lib/brute/middleware/070_default_tool_pipeline.rb +327 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +39 -305
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +148 -0
- data/lib/brute/tools/adapter.rb +4 -4
- data/lib/brute/tools/skill_load.rb +2 -2
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/turn/agent_pipeline.rb +117 -38
- data/lib/brute/turn/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +16 -1
- metadata +20 -1
data/lib/brute/eval.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
require_relative "eval/case"
|
|
7
|
+
require_relative "eval/suite"
|
|
8
|
+
require_relative "eval/transcript"
|
|
9
|
+
require_relative "eval/world"
|
|
10
|
+
|
|
11
|
+
module Brute
|
|
12
|
+
# Evaluating an agent, rather than testing a middleware.
|
|
13
|
+
#
|
|
14
|
+
# A spec asks whether one layer does what it says. An eval asks whether the
|
|
15
|
+
# whole assembled agent -- its system prompt, its tools, the model behind
|
|
16
|
+
# it -- behaves. That answer is not a boolean about code: it is a real turn,
|
|
17
|
+
# against a real model, graded on what the turn DID.
|
|
18
|
+
#
|
|
19
|
+
# CASES = [
|
|
20
|
+
# Brute::Eval::Case.new(
|
|
21
|
+
# "searches for what it cannot know",
|
|
22
|
+
# said: "what did the Bank of England do yesterday?",
|
|
23
|
+
# stubs: { "search" => RATE_DECISION },
|
|
24
|
+
# calls: { "search" => { "query" => /bank|rate/i } },
|
|
25
|
+
# mentions: %w[4.25],
|
|
26
|
+
# ),
|
|
27
|
+
# Brute::Eval::Case.new(
|
|
28
|
+
# "does not search for what it already knows",
|
|
29
|
+
# said: "how many minutes are there in an hour?",
|
|
30
|
+
# never: %w[search],
|
|
31
|
+
# budget: Brute::Eval::Budget.new(iterations: 2, tool_calls: 0),
|
|
32
|
+
# ),
|
|
33
|
+
# ]
|
|
34
|
+
#
|
|
35
|
+
# exit(Brute::Eval::Suite.new(agent: "agent.ru", cases: CASES).run)
|
|
36
|
+
#
|
|
37
|
+
# Everything the harness sees comes off the agent's own hooks -- the same
|
|
38
|
+
# registry any other subscriber uses -- so the agent under evaluation is the
|
|
39
|
+
# agent that ships: no eval-only middleware, no branch in agent.ru. The
|
|
40
|
+
# model and the tool schemas are real; the tools themselves answer from the
|
|
41
|
+
# case's stubs, installed on :before_tool, which answers a call without
|
|
42
|
+
# executing it.
|
|
43
|
+
#
|
|
44
|
+
# Where a case wakes up is the World's business, and a deployment that
|
|
45
|
+
# delivers what was said through an inbox, a queue or a room subclasses it.
|
|
46
|
+
module Eval
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/brute/hooks.rb
CHANGED
|
@@ -42,6 +42,15 @@ module Brute
|
|
|
42
42
|
# completion middleware then emits one of
|
|
43
43
|
# :faraday_error, :open_router_server_error or
|
|
44
44
|
# :standard_error with the exception as an extra
|
|
45
|
+
# :compact_duration → env, started, finished, the compactor: one
|
|
46
|
+
# strategy's attempt is this event's block, so
|
|
47
|
+
# the strategy that spends a model call is
|
|
48
|
+
# visible, and one that declined is still timed
|
|
49
|
+
# :compacted → env, {strategy:, before:, after:} — a
|
|
50
|
+
# compaction strategy rewrote env[:messages];
|
|
51
|
+
# what it replaced is already gone, so an
|
|
52
|
+
# application that keeps a transcript preserves
|
|
53
|
+
# it here
|
|
45
54
|
# :before_tool → env, call env {name:, arguments:, result:,
|
|
46
55
|
# denied:, events:, metadata:, turn_env:} —
|
|
47
56
|
# mutate :arguments to rewrite the call, or set
|
|
@@ -75,6 +84,8 @@ module Brute
|
|
|
75
84
|
FARADAY_ERROR_EVENT = :faraday_error
|
|
76
85
|
OPEN_ROUTER_SERVER_ERROR_EVENT = :open_router_server_error
|
|
77
86
|
STANDARD_ERROR_EVENT = :standard_error
|
|
87
|
+
COMPACT_DURATION_EVENT = :compact_duration
|
|
88
|
+
COMPACTED_EVENT = :compacted
|
|
78
89
|
BEFORE_TOOL_EVENT = :before_tool
|
|
79
90
|
APPROVE_TOOL_EVENT = :approve_tool
|
|
80
91
|
TOOL_DURATION_EVENT = :tool_duration
|
|
@@ -55,7 +55,7 @@ describe "brute/middleware/000_base" do
|
|
|
55
55
|
|
|
56
56
|
# Every middleware in the chain descends from it.
|
|
57
57
|
Brute::Middleware::SystemPrompt.ancestors.should.include Brute::Middleware::Base
|
|
58
|
-
Brute::Middleware::
|
|
58
|
+
Brute::Middleware::DefaultToolPipeline.ancestors.should.include Brute::Middleware::Base
|
|
59
59
|
Brute::Middleware::Loop::ToolResult.ancestors.should.include Brute::Middleware::Base
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
# The head of every agent chain, put there by the builder itself rather
|
|
9
|
+
# than by a `use` anyone writes. It switches on what was just said: the
|
|
10
|
+
# newest message, and only when it is a user message, is offered to each
|
|
11
|
+
# of env[:commands]'s checks in turn -- the commands registered with
|
|
12
|
+
# `AgentPipeline#map`, which `start` puts there. The first check that
|
|
13
|
+
# passes has its block run here, before the rest of the stack.
|
|
14
|
+
#
|
|
15
|
+
# Brute.agent
|
|
16
|
+
# .map("/compact") { |env| ... }
|
|
17
|
+
# .run(->(env) { ... })
|
|
18
|
+
#
|
|
19
|
+
# A command's block is a middleware, so what it leaves in env is what
|
|
20
|
+
# the rest of the chain works on.
|
|
21
|
+
class SlashCommands < Brute::Middleware::Base
|
|
22
|
+
def call(env)
|
|
23
|
+
matched(env)&.call(env)
|
|
24
|
+
@app.call(env)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The block of the first command whose check passes on what was just
|
|
30
|
+
# said -- and nothing at all unless the room said it, so not the
|
|
31
|
+
# assistant and not an empty log.
|
|
32
|
+
def matched(env)
|
|
33
|
+
message = Array(env[:messages]).last
|
|
34
|
+
|
|
35
|
+
if message.respond_to?(:role) && message.role == :user
|
|
36
|
+
_check, block = Array(env[:commands]).find { |check, _block| check.call(message.content.to_s) }
|
|
37
|
+
block
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
__END__
|
|
45
|
+
|
|
46
|
+
describe "brute/middleware/001_slash_commands" do
|
|
47
|
+
require "brute/messages"
|
|
48
|
+
|
|
49
|
+
it "runs the first of env[:commands] whose check matches what was just said" do
|
|
50
|
+
ran = []
|
|
51
|
+
commands = [
|
|
52
|
+
[->(said) { said.start_with?("/compact") }, ->(env) { ran << [:compact, env[:messages].last.content] }],
|
|
53
|
+
[->(said) { said.length > 20 }, ->(_env) { ran << :long }],
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
reached = []
|
|
57
|
+
inner = ->(env) { reached << env[:messages].last&.content; env }
|
|
58
|
+
middleware = Brute::Middleware::SlashCommands.new(inner)
|
|
59
|
+
|
|
60
|
+
def turn(commands, said, role = :user)
|
|
61
|
+
{ commands: commands, messages: Brute.log.tap { |log| log << Brute::Message.new(role: role, content: said) } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The matching command runs before the stack below it.
|
|
65
|
+
middleware.call(turn(commands, "/compact keep the notes"))
|
|
66
|
+
ran.should == [[:compact, "/compact keep the notes"]]
|
|
67
|
+
reached.should == ["/compact keep the notes"]
|
|
68
|
+
|
|
69
|
+
# Checks are tried in order, and only the first to pass runs.
|
|
70
|
+
ran.clear
|
|
71
|
+
middleware.call(turn(commands, "something else, at some length"))
|
|
72
|
+
ran.should == [:long]
|
|
73
|
+
|
|
74
|
+
# Nothing matches, nothing runs -- and the turn carries on regardless.
|
|
75
|
+
ran.clear
|
|
76
|
+
reached.clear
|
|
77
|
+
middleware.call(turn(commands, "short"))
|
|
78
|
+
middleware.call(turn(commands, "/compact", :assistant))
|
|
79
|
+
middleware.call({ commands: commands, messages: Brute.log })
|
|
80
|
+
ran.should.be.empty
|
|
81
|
+
reached.should == ["short", "/compact", nil]
|
|
82
|
+
|
|
83
|
+
# A turn carrying no commands at all still runs, and does nothing.
|
|
84
|
+
ran.clear
|
|
85
|
+
middleware.call({ messages: Brute.log.tap { |log| log.user("/compact") } })
|
|
86
|
+
ran.should.be.empty
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -16,7 +16,7 @@ module Brute
|
|
|
16
16
|
# use Brute::Middleware::Loop::ToolResult
|
|
17
17
|
# use Brute::Middleware::Checkpoint, path: "tmp/checkpoints.jsonl"
|
|
18
18
|
# use Brute::Middleware::MaxIterations
|
|
19
|
-
# use Brute::Middleware::
|
|
19
|
+
# use Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL
|
|
20
20
|
#
|
|
21
21
|
# The store is just a JSONL log of snapshots — one line per checkpoint,
|
|
22
22
|
# each carrying the full message log plus its own id and the id of the
|
|
@@ -2,150 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
4
|
require "brute"
|
|
5
|
+
require "gem_kit"
|
|
5
6
|
|
|
6
7
|
module Brute
|
|
7
8
|
module Middleware
|
|
8
|
-
#
|
|
9
|
-
#
|
|
9
|
+
# The old compaction trigger, kept working while it is deprecated.
|
|
10
|
+
# DefaultCompactionPipeline is the layer that does the job.
|
|
10
11
|
#
|
|
11
|
-
# It
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# It passes the turn through and compacts nothing, which is what it always
|
|
13
|
+
# did -- and it is deliberately not a subclass of its replacement, because
|
|
14
|
+
# inheriting would make a layer that did nothing suddenly start giving up
|
|
15
|
+
# context. A deprecation warns about a name; it does not change what the
|
|
16
|
+
# code under that name does.
|
|
14
17
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
18
|
+
# Whatever it was configured with is accepted and ignored, so existing
|
|
19
|
+
# `use` lines keep parsing until they are moved over:
|
|
20
|
+
#
|
|
21
|
+
# use Brute::Middleware::DefaultCompactionPipeline,
|
|
22
|
+
# window: 200_000,
|
|
23
|
+
# summariser: Brute::Completion::OpenRouter.new(config: { access_token: key })
|
|
17
24
|
#
|
|
18
25
|
class CompactionCheck < Brute::Middleware::Base
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@compactor = compactor
|
|
22
|
-
@compactor_opts = compactor_opts
|
|
23
|
-
@system_prompt = system_prompt
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def call(env)
|
|
27
|
-
#@compactor ||= Compactor.new(env[:provider], **@compactor_opts)
|
|
26
|
+
extend GemKit::Deprecate
|
|
27
|
+
superseded_by "Brute::Middleware::DefaultCompactionPipeline", "6.0"
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#if @compactor.should_compact?(messages, usage: usage)
|
|
33
|
-
# result = @compactor.compact(messages)
|
|
34
|
-
# if result
|
|
35
|
-
# summary_text, _recent = result
|
|
36
|
-
# env[:metadata][:compaction] = {
|
|
37
|
-
# messages_before: messages.size,
|
|
38
|
-
# timestamp: Time.now.iso8601,
|
|
39
|
-
# }
|
|
40
|
-
# # Replace the message history with the summary
|
|
41
|
-
# env[:messages] = [
|
|
42
|
-
# Brute::Message.new(role: :system, content: @system_prompt),
|
|
43
|
-
# Brute::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
|
|
44
|
-
# ]
|
|
45
|
-
# end
|
|
46
|
-
#end
|
|
47
|
-
|
|
48
|
-
@app.call(env)
|
|
29
|
+
def initialize(app, **_options)
|
|
30
|
+
@app = app
|
|
49
31
|
end
|
|
50
32
|
|
|
51
|
-
|
|
52
|
-
# thresholds, older messages are summarized into a condensed form and the
|
|
53
|
-
# original messages are dropped, keeping the context window manageable.
|
|
54
|
-
class Compactor
|
|
55
|
-
DEFAULTS = {
|
|
56
|
-
token_threshold: 100_000, # Compact when estimated tokens exceed this
|
|
57
|
-
message_threshold: 200, # Compact when message count exceeds this
|
|
58
|
-
retention_window: 6, # Minimum recent messages to always keep
|
|
59
|
-
summary_model: nil, # Model for summarization (uses agent's model if nil)
|
|
60
|
-
}.freeze
|
|
61
|
-
|
|
62
|
-
attr_reader :config
|
|
63
|
-
|
|
64
|
-
def initialize(provider, **opts)
|
|
65
|
-
@provider = provider
|
|
66
|
-
@config = DEFAULTS.merge(opts)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Check whether compaction should run based on current context state.
|
|
70
|
-
def should_compact?(messages, usage: nil)
|
|
71
|
-
return true if messages.size > @config[:message_threshold]
|
|
72
|
-
return true if usage && (usage[:total] || 0) > @config[:token_threshold]
|
|
73
|
-
false
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Compact the message history by summarizing older messages.
|
|
77
|
-
#
|
|
78
|
-
# Returns [summary_message, kept_messages] — the caller rebuilds
|
|
79
|
-
# the context from these.
|
|
80
|
-
def compact(messages)
|
|
81
|
-
total = messages.size
|
|
82
|
-
keep_count = [@config[:retention_window], total].min
|
|
83
|
-
return nil if total <= keep_count
|
|
84
|
-
|
|
85
|
-
old_messages = messages[0...(total - keep_count)]
|
|
86
|
-
recent_messages = messages[(total - keep_count)..]
|
|
87
|
-
|
|
88
|
-
summary_text = summarize(old_messages)
|
|
89
|
-
|
|
90
|
-
[summary_text, recent_messages]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
def summarize(messages)
|
|
96
|
-
# Build a condensed representation of the conversation for the summarizer
|
|
97
|
-
conversation_text = messages.map { |m|
|
|
98
|
-
role = if m.respond_to?(:role)
|
|
99
|
-
m.role.to_s
|
|
100
|
-
else
|
|
101
|
-
"unknown"
|
|
102
|
-
end
|
|
103
|
-
content = if m.respond_to?(:content)
|
|
104
|
-
m.content.to_s[0..1000]
|
|
105
|
-
else
|
|
106
|
-
m.to_s[0..1000]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Include tool call info for assistant messages
|
|
110
|
-
tool_info = ""
|
|
111
|
-
if m.respond_to?(:functions) && m.functions&.any?
|
|
112
|
-
calls = m.functions.map { |f| "#{f.name}(#{f.arguments.to_s[0..200]})" }
|
|
113
|
-
tool_info = " [tools: #{calls.join(", ")}]"
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
"#{role}:#{tool_info} #{content}"
|
|
117
|
-
}.join("\n---\n")
|
|
118
|
-
|
|
119
|
-
prompt = <<~PROMPT
|
|
120
|
-
Summarize this conversation history for context continuity. The summary will replace
|
|
121
|
-
these messages in the context window, so include everything the agent needs to continue
|
|
122
|
-
working effectively.
|
|
123
|
-
|
|
124
|
-
Structure your summary as:
|
|
125
|
-
## Goal
|
|
126
|
-
What the user asked for.
|
|
127
|
-
|
|
128
|
-
## Progress
|
|
129
|
-
- Files read, created, or modified (list paths)
|
|
130
|
-
- Commands executed and their outcomes
|
|
131
|
-
- Key decisions made
|
|
132
|
-
|
|
133
|
-
## Current State
|
|
134
|
-
Where things stand right now — what's done and what remains.
|
|
135
|
-
|
|
136
|
-
## Next Steps
|
|
137
|
-
What should happen next based on the conversation.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
CONVERSATION:
|
|
141
|
-
#{conversation_text}
|
|
142
|
-
PROMPT
|
|
143
|
-
|
|
144
|
-
model = @config[:summary_model] || "claude-sonnet-4-20250514"
|
|
145
|
-
res = @provider.complete(prompt, model: model)
|
|
146
|
-
res.content
|
|
147
|
-
end
|
|
148
|
-
end
|
|
33
|
+
def call(env) = @app.call(env)
|
|
149
34
|
end
|
|
150
35
|
end
|
|
151
36
|
end
|
|
@@ -153,5 +38,36 @@ end
|
|
|
153
38
|
__END__
|
|
154
39
|
|
|
155
40
|
describe "brute/middleware/040_compaction_check" do
|
|
156
|
-
|
|
41
|
+
it "passes the turn through as it always did, and says what to use instead" do
|
|
42
|
+
warned = []
|
|
43
|
+
original = GemKit::Deprecate.method(:warn)
|
|
44
|
+
GemKit::Deprecate.define_singleton_method(:warn) { |message| warned << message }
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
# Whatever it was configured with is taken and ignored, so an existing
|
|
48
|
+
# `use` line keeps parsing.
|
|
49
|
+
layer = Brute::Middleware::CompactionCheck.new(
|
|
50
|
+
->(env) { env[:messages].assistant("answered") },
|
|
51
|
+
system_prompt: "you are helpful",
|
|
52
|
+
token_threshold: 100
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
env = { messages: Brute.log }
|
|
56
|
+
layer.call(env)
|
|
57
|
+
env[:messages].last.content.should == "answered"
|
|
58
|
+
ensure
|
|
59
|
+
GemKit::Deprecate.define_singleton_method(:warn, original)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Not a subclass of its replacement: a layer that compacted nothing must
|
|
63
|
+
# not start compacting because its name was deprecated.
|
|
64
|
+
Brute::Middleware::CompactionCheck.ancestors
|
|
65
|
+
.should.not.include Brute::Middleware::DefaultCompactionPipeline
|
|
66
|
+
|
|
67
|
+
warned.first.should.include "Brute::Middleware::DefaultCompactionPipeline"
|
|
68
|
+
|
|
69
|
+
declared = GemKit::Deprecate.registry.find { |entry| entry.name == "Brute::Middleware::CompactionCheck" }
|
|
70
|
+
declared.replacement.should == "Brute::Middleware::DefaultCompactionPipeline"
|
|
71
|
+
declared.removed_in.should == Gem::Version.new("6.0")
|
|
72
|
+
end
|
|
157
73
|
end
|