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.
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::ToolPipeline.ancestors.should.include Brute::Middleware::Base
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::ToolPipeline, tools: Brute::Tools::ALL
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
- # Checks context size after each LLM call and triggers compaction
9
- # when thresholds are exceeded.
9
+ # The old compaction trigger, kept working while it is deprecated.
10
+ # DefaultCompactionPipeline is the layer that does the job.
10
11
  #
11
- # It should add a compaction event to the logs with the context token
12
- # total listed... this way a model that supports extra context can
13
- # include the compaction as well as the previous messages...
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
- # Or an LLM that doesn't support it can just use the messages
16
- # that come after the compaction
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
- def initialize(app, compactor: nil, system_prompt:, **compactor_opts)
20
- @app = app
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
- #messages = env[:messages]
30
- #usage = env[:metadata].dig(:tokens, :last_call)
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
- # Context compaction service. When the conversation grows past configurable
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
- # not implemented
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