brute 5.0.5 → 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/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/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +15 -1
- metadata +19 -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
|
|
@@ -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
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
# Compacts the conversation once it fills too much of the model's window.
|
|
9
|
+
#
|
|
10
|
+
# This layer owns the *when*: before each call it estimates the size of the
|
|
11
|
+
# conversation, and once it reaches `compact_at` of the window it asks a
|
|
12
|
+
# compactor to bring it down to `compact_to`. What is given up, and in what
|
|
13
|
+
# order, is the compactor's business.
|
|
14
|
+
#
|
|
15
|
+
# use Brute::Middleware::Loop::ToolResult
|
|
16
|
+
# use Brute::Middleware::DefaultCompactionPipeline,
|
|
17
|
+
# window: 200_000,
|
|
18
|
+
# summariser: Brute::Completion::OpenRouter.new(config: { access_token: key })
|
|
19
|
+
# use Brute::Middleware::DefaultToolPipeline, tools: tools
|
|
20
|
+
#
|
|
21
|
+
# The compactor it builds is the usual ladder, cheapest first:
|
|
22
|
+
#
|
|
23
|
+
# use Brute::Compaction::Middleware::ToolResults rewrite older tool output
|
|
24
|
+
# use Brute::Compaction::Middleware::SlidingWindow drop the oldest turns, then steps
|
|
25
|
+
# run Brute::Compaction::Summarize summarise what is left, until it fits
|
|
26
|
+
#
|
|
27
|
+
# Pass `compactor:` to say something else. A Brute::Turn::CompactionPipeline
|
|
28
|
+
# is the usual thing to pass, but anything answering
|
|
29
|
+
# `#compact(messages, target:)` will do. Passing no summariser leaves the
|
|
30
|
+
# terminal doing nothing, which is how an agent says it would rather live
|
|
31
|
+
# with a full context than pay to shrink it.
|
|
32
|
+
#
|
|
33
|
+
# It belongs inside the tool loop rather than around it, so it runs before
|
|
34
|
+
# every call rather than once a turn -- a long run of tool results can fill
|
|
35
|
+
# the window without the turn ever ending.
|
|
36
|
+
#
|
|
37
|
+
# Sizing anchors on what the provider itself counted for the last call and
|
|
38
|
+
# measures locally only what has been appended since, so the estimate is
|
|
39
|
+
# exact for the bulk of the conversation and approximate only for its tail.
|
|
40
|
+
#
|
|
41
|
+
# Tool schemas ride in every request and are part of what fills a window,
|
|
42
|
+
# so they are counted too -- against the trigger when nothing has been
|
|
43
|
+
# reported yet, and off the target the compactor is given, because what
|
|
44
|
+
# the schemas occupy is not room the conversation may have. This layer
|
|
45
|
+
# sits above the tool pipeline, so on the very first call of a turn
|
|
46
|
+
# env[:tools] is not set yet: pass `tools:` to have them counted then.
|
|
47
|
+
#
|
|
48
|
+
# Compaction is lossy, and this layer keeps no record of what it gave up:
|
|
49
|
+
# it rewrites env[:messages] and says so with :compacted. An application
|
|
50
|
+
# that keeps a transcript preserves it from there.
|
|
51
|
+
#
|
|
52
|
+
# agent.on(:compacted) { |env, payload| archive(env, payload) }
|
|
53
|
+
#
|
|
54
|
+
class DefaultCompactionPipeline < Brute::Middleware::Base
|
|
55
|
+
def initialize(app, window:, summariser: nil, compactor: nil, keep_steps: 2,
|
|
56
|
+
compact_at: 0.7, compact_to: 0.4, token_counter: nil, tools: nil)
|
|
57
|
+
unless compact_to.positive? && compact_to < compact_at && compact_at <= 1
|
|
58
|
+
raise ArgumentError, "expected 0 < compact_to < compact_at <= 1, got #{compact_to} and #{compact_at}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@app = app
|
|
62
|
+
@compactor = compactor || self.class.compactor(summariser: summariser, keep_steps: keep_steps)
|
|
63
|
+
@window = window
|
|
64
|
+
@compact_at = compact_at
|
|
65
|
+
@compact_to = compact_to
|
|
66
|
+
@token_counter = token_counter || Brute::TokenCounter.default
|
|
67
|
+
@tools = tools
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def call(env)
|
|
71
|
+
env[:compactor] = @compactor
|
|
72
|
+
compact(env)
|
|
73
|
+
@app.call(env)
|
|
74
|
+
env
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The ladder this layer wires when it is not given one.
|
|
78
|
+
def self.compactor(summariser: nil, keep_steps: 2)
|
|
79
|
+
terminal = Declines.new
|
|
80
|
+
|
|
81
|
+
unless summariser.nil?
|
|
82
|
+
terminal = Brute::Compaction::Summarize.new(summariser, keep_steps: keep_steps)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Brute::Turn::CompactionPipeline.new do
|
|
86
|
+
use Brute::Compaction::Middleware::ToolResults, keep_steps: 1
|
|
87
|
+
use Brute::Compaction::Middleware::SlidingWindow, keep_steps: keep_steps
|
|
88
|
+
|
|
89
|
+
run terminal
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The floor of a pipeline given nothing to summarise with: what the free
|
|
94
|
+
# layers managed is what the turn gets.
|
|
95
|
+
class Declines
|
|
96
|
+
def call(env) = env
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def compact(env)
|
|
102
|
+
context = estimate(env)
|
|
103
|
+
|
|
104
|
+
if context >= @window * @compact_at
|
|
105
|
+
before = @token_counter.count(env[:messages])
|
|
106
|
+
compacted = attempt(env, target(env))
|
|
107
|
+
|
|
108
|
+
unless compacted.nil?
|
|
109
|
+
env[:messages].replace(compacted)
|
|
110
|
+
# The reported total describes a conversation that no longer
|
|
111
|
+
# exists, so the next estimate must not build on it. The call
|
|
112
|
+
# below refreshes it; one that fails leaves the estimate to be
|
|
113
|
+
# counted from scratch instead of anchored to a fiction.
|
|
114
|
+
env[:metadata]&.delete(:last_llm_usage)
|
|
115
|
+
emit(
|
|
116
|
+
COMPACTED_EVENT,
|
|
117
|
+
env,
|
|
118
|
+
{ context: context, before: before, after: @token_counter.count(compacted) },
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What the conversation alone may occupy: the target, less whatever
|
|
125
|
+
# the schemas are already taking out of it. A window too small to
|
|
126
|
+
# hold its own tool schemas is a configuration a compactor cannot
|
|
127
|
+
# fix, so the floor is 1 rather than a negative target.
|
|
128
|
+
def target(env)
|
|
129
|
+
[(@window * @compact_to).to_i - schemas(env), 1].max
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def schemas(env)
|
|
133
|
+
tools = @tools || env[:tools]
|
|
134
|
+
|
|
135
|
+
if tools.nil?
|
|
136
|
+
0
|
|
137
|
+
else
|
|
138
|
+
@token_counter.count([], tools: tools)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A compactor that raises is a compactor that declined. Compaction is
|
|
143
|
+
# an optimisation, and an agent that cannot shrink its context should
|
|
144
|
+
# carry on with the context it has rather than die of it.
|
|
145
|
+
def attempt(env, target)
|
|
146
|
+
compacted = nil
|
|
147
|
+
|
|
148
|
+
emit(COMPACT_DURATION_EVENT, env, env[:compactor]) do
|
|
149
|
+
compacted = env[:compactor].compact(
|
|
150
|
+
env[:messages],
|
|
151
|
+
target: target,
|
|
152
|
+
events: env[:events] || Brute::Turn::Pipeline::NullSink.new,
|
|
153
|
+
token_counter: @token_counter,
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
compacted
|
|
158
|
+
rescue => error
|
|
159
|
+
(env[:events] ||= []) << { type: :error, data: { error: error, message: error.message } }
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# What the provider counted for the last call already covers the
|
|
164
|
+
# system prompt, the tool schemas and the chat-template overhead, so
|
|
165
|
+
# only what has landed since it answered needs counting here.
|
|
166
|
+
def estimate(env)
|
|
167
|
+
Brute::TokenCounter.estimate(env, counter: @token_counter, tools: @tools || env[:tools])
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
__END__
|
|
174
|
+
|
|
175
|
+
describe "brute/middleware/040_default_compaction_pipeline" do
|
|
176
|
+
def conversation
|
|
177
|
+
Brute.log.tap do |log|
|
|
178
|
+
log.user("ask" + ("x" * 1_000))
|
|
179
|
+
log.assistant("answer" + ("y" * 1_000))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def usage(total) = { last_llm_usage: Brute::UsageDetection::Usage.new(total: total) }
|
|
184
|
+
|
|
185
|
+
def history(turns: 4, size: 6_000)
|
|
186
|
+
Brute.log.tap do |log|
|
|
187
|
+
log.system("instructions")
|
|
188
|
+
turns.times do |n|
|
|
189
|
+
log.user("question #{n}")
|
|
190
|
+
log << Brute::Message.new(role: :assistant, content: n.to_s * size)
|
|
191
|
+
end
|
|
192
|
+
log.user("the current task")
|
|
193
|
+
log << Brute::Message.new(role: :assistant, content: "z" * size)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def compactor(&block)
|
|
198
|
+
Object.new.tap do |double|
|
|
199
|
+
double.define_singleton_method(:compact) { |messages, target:, **| block.call(messages, target) }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "compacts only once the window fills, and says what the turn gave up" do
|
|
204
|
+
asked = []
|
|
205
|
+
events = []
|
|
206
|
+
shrinks = compactor { |messages, target| asked << target; messages.first(1) }
|
|
207
|
+
declines = compactor { |_messages, target| asked << target; nil }
|
|
208
|
+
raises = compactor { |_messages, _target| raise IOError, "the summariser is down" }
|
|
209
|
+
|
|
210
|
+
layer = lambda do |compactor|
|
|
211
|
+
Brute::Middleware::DefaultCompactionPipeline.new(
|
|
212
|
+
->(env) { env },
|
|
213
|
+
compactor: compactor,
|
|
214
|
+
window: 1_000,
|
|
215
|
+
compact_at: 0.7,
|
|
216
|
+
compact_to: 0.4,
|
|
217
|
+
).tap do |it|
|
|
218
|
+
it.define_singleton_method(:emit) { |event, _env, *extras, &work| work&.call; events << [event, *extras] }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
env = { messages: conversation, metadata: usage(900) }
|
|
223
|
+
layer.call(shrinks).call(env)
|
|
224
|
+
|
|
225
|
+
asked.should == [400]
|
|
226
|
+
env[:messages].map(&:role).should == [:user]
|
|
227
|
+
env[:compactor].equal?(shrinks).should.be.true
|
|
228
|
+
events.select { |e, _| e == :compacted }.should == [[:compacted, { context: 900, before: 514, after: 256 }]]
|
|
229
|
+
# The attempt is timed, so the compactor that spends a model call is visible.
|
|
230
|
+
events.select { |e, _| e == :compact_duration }.length.should == 1
|
|
231
|
+
|
|
232
|
+
# Sizing anchors on what the provider counted: under compact_at nothing is
|
|
233
|
+
# asked and nothing is said.
|
|
234
|
+
asked.clear
|
|
235
|
+
events.clear
|
|
236
|
+
layer.call(shrinks).call({ messages: conversation, metadata: usage(600) })
|
|
237
|
+
asked.should == []
|
|
238
|
+
events.should == []
|
|
239
|
+
|
|
240
|
+
# ...but what landed since it answered is counted on top of it, and that
|
|
241
|
+
# is what tips this one over.
|
|
242
|
+
tail = conversation.tap { |log| log.tool("z" * 400, tool_call_id: "tc1") }
|
|
243
|
+
layer.call(declines).call({ messages: tail, metadata: usage(600) })
|
|
244
|
+
asked.should == [400]
|
|
245
|
+
|
|
246
|
+
# With nothing reported at all, the whole conversation is counted here.
|
|
247
|
+
asked.clear
|
|
248
|
+
layer.call(declines).call({ messages: conversation })
|
|
249
|
+
asked.should == []
|
|
250
|
+
|
|
251
|
+
# A compactor that raises is a compactor that declined: reported, not
|
|
252
|
+
# fatal. An agent that cannot shrink its context carries on with it.
|
|
253
|
+
failing = { messages: conversation, events: [], metadata: usage(900) }
|
|
254
|
+
should.not.raise(IOError) { layer.call(raises).call(failing) }
|
|
255
|
+
failing[:messages].length.should == 2
|
|
256
|
+
failing[:events].map { |e| [e[:type], e[:data][:message]] }.should == [[:error, "the summariser is down"]]
|
|
257
|
+
|
|
258
|
+
# A target at or above the trigger would compact on every single step.
|
|
259
|
+
should.raise(ArgumentError) do
|
|
260
|
+
Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, compactor: shrinks, window: 10,
|
|
261
|
+
compact_at: 0.4, compact_to: 0.7)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "wires the usual ladder, cheapest first, and only pays when the free layers cannot" do
|
|
266
|
+
rounds = 0
|
|
267
|
+
summariser = lambda do |env|
|
|
268
|
+
rounds += 1
|
|
269
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "what happened")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
ladder = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: summariser, keep_steps: 1)
|
|
273
|
+
ladder.should.be.kind_of Brute::Turn::CompactionPipeline
|
|
274
|
+
|
|
275
|
+
events = []
|
|
276
|
+
sink = Object.new.tap { |it| it.define_singleton_method(:<<) { |e| events << e; it } }
|
|
277
|
+
|
|
278
|
+
messages = history
|
|
279
|
+
before = Brute::Compaction::Transcript.tokens(messages)
|
|
280
|
+
out = ladder.compact(messages, target: 1_500, events: sink)
|
|
281
|
+
|
|
282
|
+
Brute::Compaction::Transcript.tokens(out).should.be < before
|
|
283
|
+
events.select { |e| e[:type] == :compacted }.map { |e| e[:data][:strategy] }
|
|
284
|
+
.should.include "sliding_window"
|
|
285
|
+
|
|
286
|
+
# Given no summariser the terminal declines, and the free layers are all
|
|
287
|
+
# the agent gets -- a policy said out loud rather than configured.
|
|
288
|
+
free = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: nil, keep_steps: 1)
|
|
289
|
+
free.compact(history, target: 1_500).should.not.be.nil
|
|
290
|
+
|
|
291
|
+
# One layer, so it still owns the *when* as well as the what.
|
|
292
|
+
layer = Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, window: 1_000, summariser: summariser)
|
|
293
|
+
layer.define_singleton_method(:emit) { |_e, _env, *, &work| work&.call }
|
|
294
|
+
env = { messages: history, metadata: {} }
|
|
295
|
+
layer.call(env)
|
|
296
|
+
env[:messages].length.should.be < 11
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "counts the schemas it will send, and stops anchoring on a conversation that is gone" do
|
|
300
|
+
asked = []
|
|
301
|
+
shrinks = compactor { |messages, target| asked << target; messages.first(1) }
|
|
302
|
+
|
|
303
|
+
tool = Brute::Turn::ToolPipeline.new(name: "search", description: "search the web. " * 40) do
|
|
304
|
+
run ->(env) { env[:result] = "" }
|
|
305
|
+
end
|
|
306
|
+
schemas = Brute::TokenCounter.default.count([], tools: [tool])
|
|
307
|
+
|
|
308
|
+
layer = lambda do |**options|
|
|
309
|
+
Brute::Middleware::DefaultCompactionPipeline.new(
|
|
310
|
+
->(env) { env },
|
|
311
|
+
compactor: shrinks,
|
|
312
|
+
window: 1_000,
|
|
313
|
+
**options,
|
|
314
|
+
).tap { |it| it.define_singleton_method(:emit) { |_event, _env, *, &work| work&.call } }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# 650 tokens of conversation, nothing reported yet: under the 700 the
|
|
318
|
+
# window triggers at, so on its own it is left alone.
|
|
319
|
+
said = -> { Brute.log.tap { |log| log.user("x" * 2_578) } }
|
|
320
|
+
Brute::TokenCounter.default.count(said.call).should == 650
|
|
321
|
+
|
|
322
|
+
layer.call.call({ messages: said.call, metadata: {} })
|
|
323
|
+
asked.should == []
|
|
324
|
+
|
|
325
|
+
# The same conversation ships with tool schemas in every request, and
|
|
326
|
+
# counting what they occupy is what tips it over. What is left of the
|
|
327
|
+
# target after them is what the compactor is asked for -- the schemas are
|
|
328
|
+
# not room the conversation may have.
|
|
329
|
+
layer.call(tools: [tool]).call({ messages: said.call, metadata: {} })
|
|
330
|
+
asked.should == [400 - schemas]
|
|
331
|
+
|
|
332
|
+
# Reported usage already covers the schemas, so the warm path must not
|
|
333
|
+
# count them again -- 600 reported and nothing said since is 600, tools
|
|
334
|
+
# or no tools.
|
|
335
|
+
asked.clear
|
|
336
|
+
layer.call(tools: [tool]).call({ messages: conversation, metadata: usage(600) })
|
|
337
|
+
asked.should == []
|
|
338
|
+
|
|
339
|
+
# What the provider counted describes the conversation as it was. Once
|
|
340
|
+
# compaction has given part of it up that number is a fiction, so it goes
|
|
341
|
+
# -- the next estimate counts from scratch rather than building on it.
|
|
342
|
+
compacted = { messages: conversation, metadata: usage(900) }
|
|
343
|
+
layer.call.call(compacted)
|
|
344
|
+
compacted[:messages].length.should == 1
|
|
345
|
+
compacted[:metadata].key?(:last_llm_usage).should.be.false
|
|
346
|
+
|
|
347
|
+
# Nothing given up, nothing invalidated.
|
|
348
|
+
kept = { messages: conversation, metadata: usage(900) }
|
|
349
|
+
layer.call(compactor: compactor { |_messages, _target| nil }).call(kept)
|
|
350
|
+
kept[:metadata].key?(:last_llm_usage).should.be.true
|
|
351
|
+
end
|
|
352
|
+
end
|