brute 5.0.5 → 6.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/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +137 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +349 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/completion/lang_chain.rb +34 -31
- data/lib/brute/completion/llmrb.rb +31 -29
- data/lib/brute/completion/open_router.rb +43 -40
- data/lib/brute/completion/ruby_llm.rb +28 -27
- data/lib/brute/contrib/otel.rb +83 -51
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +254 -0
- data/lib/brute/eval/suite.rb +181 -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/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +1 -1
- data/lib/brute/hooks.rb +89 -102
- data/lib/brute/message_transport/anthropic.rb +13 -3
- data/lib/brute/message_transport/llm.rb +8 -4
- data/lib/brute/message_transport/open_router.rb +30 -16
- data/lib/brute/message_transport/openai.rb +14 -7
- data/lib/brute/message_transport/ruby_llm.rb +21 -17
- data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
- data/lib/brute/messages.rb +15 -8
- data/lib/brute/middleware/000_base.rb +6 -6
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +27 -19
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
- data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
- data/lib/brute/prompt_template.rb +19 -15
- data/lib/brute/prompts/base.rb +19 -10
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/instructions.rb +9 -7
- data/lib/brute/prompts/skills.rb +5 -3
- data/lib/brute/rack/adapter.rb +40 -23
- data/lib/brute/skill.rb +136 -89
- data/lib/brute/system_prompt.rb +9 -9
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +150 -0
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +57 -47
- data/lib/brute/tools/fs/snapshot_store.rb +5 -1
- data/lib/brute/tools/fs_patch.rb +16 -8
- data/lib/brute/tools/fs_read.rb +107 -80
- data/lib/brute/tools/fs_remove.rb +6 -2
- data/lib/brute/tools/fs_search.rb +14 -4
- data/lib/brute/tools/fs_undo.rb +6 -2
- data/lib/brute/tools/fs_write.rb +5 -1
- data/lib/brute/tools/net_fetch.rb +6 -2
- data/lib/brute/tools/question.rb +42 -39
- data/lib/brute/tools/shell.rb +20 -5
- data/lib/brute/tools/skill_load.rb +46 -41
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/tools/todo_write.rb +19 -15
- data/lib/brute/truncation.rb +68 -43
- data/lib/brute/turn/agent_pipeline.rb +23 -13
- data/lib/brute/turn/compaction_pipeline.rb +123 -0
- data/lib/brute/turn/pipeline.rb +87 -96
- data/lib/brute/turn/tool_pipeline.rb +4 -3
- data/lib/brute/usage_detection/llmrb.rb +18 -14
- data/lib/brute/usage_detection/open_router.rb +20 -17
- data/lib/brute/usage_detection/ruby_llm.rb +18 -14
- data/lib/brute/usage_detection/usage.rb +10 -1
- data/lib/brute/utils/diff.rb +18 -10
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +25 -12
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +48 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -157
- data/lib/brute/middleware/event_handler.rb +0 -27
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 :tool_start, 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/events/handler.rb
CHANGED
data/lib/brute/hooks.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
|
+
require "delegate"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
4
7
|
require "brute"
|
|
5
8
|
|
|
6
9
|
module Brute
|
|
@@ -9,76 +12,69 @@ module Brute
|
|
|
9
12
|
# Brute.agent
|
|
10
13
|
# .use(Brute::Middleware::MaxIterations)
|
|
11
14
|
# .run(->(env) { env[:messages].assistant("done") })
|
|
12
|
-
# .on(:
|
|
13
|
-
# .on(:
|
|
14
|
-
#
|
|
15
|
-
# Every subscriber is called with the turn env first, followed by whatever
|
|
16
|
-
# extras that event carries. A block that only wants the env can take one
|
|
17
|
-
# argument and ignore the rest.
|
|
18
|
-
#
|
|
19
|
-
# Emission points and payloads:
|
|
15
|
+
# .on(:llm_start) { |env| ... }
|
|
16
|
+
# .on(:tool_approve) { |_env, call| call[:name] != "exec" }
|
|
20
17
|
#
|
|
21
|
-
# :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
|
|
22
|
-
# fires from an ensure, so it also fires on
|
|
23
|
-
# error)
|
|
24
|
-
# :turn_duration → env, started, finished: the turn's work is
|
|
25
|
-
# this event's block
|
|
26
|
-
# :middleware_added → an empty env, then the middleware and every
|
|
27
|
-
# argument `use` was given (fires at build time,
|
|
28
|
-
# so only subscribers registered before the `use`
|
|
29
|
-
# see it)
|
|
30
|
-
# :enter → env, the middleware instance, before the
|
|
31
|
-
# layer does anything
|
|
32
|
-
# :duration → env, started, finished, the middleware
|
|
33
|
-
# instance: the layer's work is this event's
|
|
34
|
-
# block, so it reports how long that took
|
|
35
|
-
# :exit → env, the middleware instance, marking the
|
|
36
|
-
# layer done (from an ensure, so it fires on
|
|
37
|
-
# error too)
|
|
38
|
-
# :before_llm, :after_llm → the turn env, around every LLM call
|
|
39
|
-
# :llm_duration → env, started, finished: the provider call is
|
|
40
|
-
# this event's block
|
|
41
|
-
# :llm_failure → the turn env, when the LLM call raises; the
|
|
42
|
-
# completion middleware then emits one of
|
|
43
|
-
# :faraday_error, :open_router_server_error or
|
|
44
|
-
# :standard_error with the exception as an extra
|
|
45
|
-
# :before_tool → env, call env {name:, arguments:, result:,
|
|
46
|
-
# denied:, events:, metadata:, turn_env:} —
|
|
47
|
-
# mutate :arguments to rewrite the call, or set
|
|
48
|
-
# :result to answer it without executing
|
|
49
|
-
# :approve_tool → env, call env — set :denied to true to deny
|
|
50
|
-
# the call, or to a String to deny it with that
|
|
51
|
-
# message
|
|
52
|
-
# :tool_duration → env, started, finished, call env: the tool's
|
|
53
|
-
# own execution is this event's block, so a
|
|
54
|
-
# skipped or denied call never fires it
|
|
55
|
-
# :after_tool → env, call env — mutate :result
|
|
56
|
-
#
|
|
57
|
-
# Subscribers run inline (tool events may fire from parallel threads).
|
|
58
|
-
# Exceptions propagate to the caller — layers that want fail-open semantics
|
|
59
|
-
# rescue in their own subscriber.
|
|
60
|
-
# Include this in anything that emits or subscribes and the event names are
|
|
61
|
-
# first class there: ENTER_EVENT rather than Brute::Hooks::ENTER_EVENT.
|
|
62
|
-
# The registry itself is Hooks::Registry, and Brute::Hooks.new builds one.
|
|
63
18
|
module Hooks
|
|
64
19
|
TURN_START_EVENT = :turn_start
|
|
65
20
|
TURN_DURATION_EVENT = :turn_duration
|
|
66
21
|
TURN_END_EVENT = :turn_end
|
|
22
|
+
TURN_FAILURE_EVENT = :turn_failure
|
|
67
23
|
MIDDLEWARE_ADDED_EVENT = :middleware_added
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
24
|
+
MIDDLEWARE_START_EVENT = :middleware_start
|
|
25
|
+
MIDDLEWARE_DURATION_EVENT = :middleware_duration
|
|
26
|
+
MIDDLEWARE_END_EVENT = :middleware_end
|
|
27
|
+
MIDDLEWARE_FAILURE_EVENT = :middleware_failure
|
|
28
|
+
LLM_START_EVENT = :llm_start
|
|
72
29
|
LLM_DURATION_EVENT = :llm_duration
|
|
73
|
-
|
|
30
|
+
LLM_END_EVENT = :llm_end
|
|
74
31
|
LLM_FAILURE_EVENT = :llm_failure
|
|
75
32
|
FARADAY_ERROR_EVENT = :faraday_error
|
|
76
33
|
OPEN_ROUTER_SERVER_ERROR_EVENT = :open_router_server_error
|
|
77
34
|
STANDARD_ERROR_EVENT = :standard_error
|
|
78
|
-
|
|
79
|
-
|
|
35
|
+
COMPACT_START_EVENT = :compact_start
|
|
36
|
+
COMPACT_DURATION_EVENT = :compact_duration
|
|
37
|
+
COMPACT_END_EVENT = :compact_end
|
|
38
|
+
COMPACT_FAILURE_EVENT = :compact_failure
|
|
39
|
+
CONTENT_EVENT = :content
|
|
40
|
+
REASONING_EVENT = :reasoning
|
|
41
|
+
TOOL_CALLS_EVENT = :tool_calls
|
|
42
|
+
TOOL_START_EVENT = :tool_start
|
|
43
|
+
TOOL_APPROVE_EVENT = :tool_approve
|
|
80
44
|
TOOL_DURATION_EVENT = :tool_duration
|
|
81
|
-
|
|
45
|
+
TOOL_END_EVENT = :tool_end
|
|
46
|
+
TOOL_FAILURE_EVENT = :tool_failure
|
|
47
|
+
|
|
48
|
+
# The env, wrapped, for as long as one call lasts. Everything emitted
|
|
49
|
+
# through it carries that call's id, and a call opened inside another
|
|
50
|
+
# delegates to it -- so the wrapper is the parent and unwrapping ends the
|
|
51
|
+
# call. It is still the env: SimpleDelegator forwards the rest.
|
|
52
|
+
class Trace < SimpleDelegator
|
|
53
|
+
attr_reader :id, :hooks
|
|
54
|
+
|
|
55
|
+
def initialize(env, hooks: nil, id: SecureRandom.uuid)
|
|
56
|
+
super(env)
|
|
57
|
+
if hooks
|
|
58
|
+
@hooks = hooks
|
|
59
|
+
elsif env.is_a?(Trace)
|
|
60
|
+
@hooks = env.hooks
|
|
61
|
+
end
|
|
62
|
+
@id = id
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_trace = self
|
|
66
|
+
|
|
67
|
+
def emit(event, *extras, &work) = @hooks.emit(
|
|
68
|
+
event,
|
|
69
|
+
self,
|
|
70
|
+
*extras,
|
|
71
|
+
@id,
|
|
72
|
+
&work
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def emit_trace(&block) = self.class.new(self).tap(&block).then { |trace| trace.__getobj__ }
|
|
76
|
+
end
|
|
77
|
+
|
|
82
78
|
|
|
83
79
|
# The pub/sub registry a pipeline owns; `use` and `run` bind an emit to it.
|
|
84
80
|
class Registry
|
|
@@ -91,40 +87,33 @@ module Brute
|
|
|
91
87
|
self
|
|
92
88
|
end
|
|
93
89
|
|
|
94
|
-
# Fire an event. An emitter announces; it answers nothing, and what a
|
|
95
|
-
# subscriber's block happens to evaluate to is not a signal. A layer
|
|
96
|
-
# that wants to take part in a turn does it by mutating what it was
|
|
97
|
-
# handed, never by returning something.
|
|
98
|
-
#
|
|
99
|
-
# Given a block, the event is timed instead: the block is the work, and
|
|
100
|
-
# subscribers fire once it is done, called as
|
|
101
|
-
# `|env, started, finished, *extras|` rather than `|env, *extras|`.
|
|
102
|
-
# Both stamps are monotonic, so a clock adjustment mid-turn cannot
|
|
103
|
-
# produce a negative duration.
|
|
104
|
-
#
|
|
105
90
|
# The block is the work and nothing more: `emit` answers nothing in
|
|
106
91
|
# either form, so a caller that needs the work's value takes it inside
|
|
107
92
|
# the block.
|
|
108
93
|
#
|
|
109
94
|
# result = nil
|
|
110
|
-
# emit(
|
|
111
|
-
# # => .on(
|
|
95
|
+
# emit(MIDDLEWARE_DURATION_EVENT, env, self) { result = @app.call(env) }
|
|
96
|
+
# # => .on(MIDDLEWARE_DURATION_EVENT) { |env, started, finished, layer| ... }
|
|
112
97
|
#
|
|
113
98
|
# Subscribers fire from an ensure, so work that raises is still timed
|
|
114
99
|
# and still reported before the exception carries on up.
|
|
115
100
|
def emit(event, env, *extras, &block)
|
|
116
|
-
|
|
101
|
+
if block
|
|
102
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
block.call
|
|
106
|
+
ensure
|
|
107
|
+
finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
@subscribers[event.to_sym].each { |subscriber| subscriber.call(
|
|
109
|
+
env,
|
|
110
|
+
started,
|
|
111
|
+
finished,
|
|
112
|
+
*extras,
|
|
113
|
+
) }
|
|
114
|
+
end
|
|
115
|
+
else
|
|
117
116
|
@subscribers[event.to_sym].each { |subscriber| subscriber.call(env, *extras) }
|
|
118
|
-
return nil
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
|
-
|
|
123
|
-
begin
|
|
124
|
-
block.call
|
|
125
|
-
ensure
|
|
126
|
-
finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
127
|
-
@subscribers[event.to_sym].each { |subscriber| subscriber.call(env, started, finished, *extras) }
|
|
128
117
|
end
|
|
129
118
|
|
|
130
119
|
nil
|
|
@@ -132,8 +121,6 @@ module Brute
|
|
|
132
121
|
|
|
133
122
|
def any?(event) = @subscribers[event.to_sym].any?
|
|
134
123
|
end
|
|
135
|
-
|
|
136
|
-
def self.new(...) = Registry.new(...)
|
|
137
124
|
end
|
|
138
125
|
end
|
|
139
126
|
|
|
@@ -141,38 +128,38 @@ __END__
|
|
|
141
128
|
|
|
142
129
|
describe "brute/hooks" do
|
|
143
130
|
it "emits to subscribers in registration order" do
|
|
144
|
-
hooks = Brute::Hooks.new
|
|
131
|
+
hooks = Brute::Hooks::Registry.new
|
|
145
132
|
seen = []
|
|
146
|
-
hooks.on(:
|
|
147
|
-
hooks.on(:
|
|
148
|
-
hooks.emit(:
|
|
133
|
+
hooks.on(:llm_start) { |env| seen << "a#{env}" }
|
|
134
|
+
hooks.on(:llm_start) { |env| seen << "b#{env}" }
|
|
135
|
+
hooks.emit(:llm_start, 1)
|
|
149
136
|
seen.should == ["a1", "b1"]
|
|
150
137
|
end
|
|
151
138
|
|
|
152
139
|
it "answers nothing: a subscriber takes part by mutating, not by returning" do
|
|
153
|
-
hooks = Brute::Hooks.new
|
|
154
|
-
hooks.on(:
|
|
140
|
+
hooks = Brute::Hooks::Registry.new
|
|
141
|
+
hooks.on(:tool_approve) { |_env, call| call[:denied] = true }
|
|
155
142
|
|
|
156
143
|
call = {}
|
|
157
|
-
hooks.emit(:
|
|
144
|
+
hooks.emit(:tool_approve, {}, call).should.be.nil
|
|
158
145
|
call[:denied].should.be.true
|
|
159
146
|
end
|
|
160
147
|
|
|
161
148
|
it "hands every subscriber the env first and the extras after" do
|
|
162
|
-
hooks = Brute::Hooks.new
|
|
149
|
+
hooks = Brute::Hooks::Registry.new
|
|
163
150
|
seen = []
|
|
164
|
-
hooks.on(:
|
|
165
|
-
hooks.emit(:
|
|
151
|
+
hooks.on(:tool_end) { |env, call| seen << [env, call] }
|
|
152
|
+
hooks.emit(:tool_end, :turn, :call)
|
|
166
153
|
seen.should == [[:turn, :call]]
|
|
167
154
|
end
|
|
168
155
|
|
|
169
156
|
it "times a block, hands subscribers start and finish, and reports even when the work raises" do
|
|
170
157
|
seen = []
|
|
171
|
-
hooks = Brute::Hooks.new
|
|
172
|
-
hooks.on(:
|
|
158
|
+
hooks = Brute::Hooks::Registry.new
|
|
159
|
+
hooks.on(:middleware_end) { |env, started, finished, subject| seen << [env, started, finished, subject] }
|
|
173
160
|
|
|
174
161
|
ran = nil
|
|
175
|
-
hooks.emit(:
|
|
162
|
+
hooks.emit(:middleware_end, :env, :layer) { ran = :work_result }.should.be.nil
|
|
176
163
|
ran.should == :work_result
|
|
177
164
|
seen.size.should == 1
|
|
178
165
|
env, started, finished, subject = seen.first
|
|
@@ -181,19 +168,19 @@ describe "brute/hooks" do
|
|
|
181
168
|
(finished - started).should.be >= 0
|
|
182
169
|
|
|
183
170
|
# Work that raises is still timed and still reported.
|
|
184
|
-
should.raise(RuntimeError) { hooks.emit(:
|
|
171
|
+
should.raise(RuntimeError) { hooks.emit(:middleware_end, :env, :layer) { raise "boom" } }
|
|
185
172
|
seen.size.should == 2
|
|
186
173
|
|
|
187
174
|
# Without a block it stays a point event: no timing argument.
|
|
188
175
|
args = []
|
|
189
|
-
hooks2 = Brute::Hooks.new
|
|
190
|
-
hooks2.on(:
|
|
191
|
-
hooks2.emit(:
|
|
176
|
+
hooks2 = Brute::Hooks::Registry.new
|
|
177
|
+
hooks2.on(:middleware_end) { |*received| args << received }
|
|
178
|
+
hooks2.emit(:middleware_end, :env, :layer)
|
|
192
179
|
args.should == [[:env, :layer]]
|
|
193
180
|
end
|
|
194
181
|
|
|
195
182
|
it "answers any? and stays chainable" do
|
|
196
|
-
hooks = Brute::Hooks.new
|
|
183
|
+
hooks = Brute::Hooks::Registry.new
|
|
197
184
|
hooks.any?(:turn_start).should.be.false
|
|
198
185
|
hooks.on(:turn_start) { nil }.should.equal?(hooks)
|
|
199
186
|
hooks.any?(:turn_start).should.be.true
|
|
@@ -55,7 +55,9 @@ module Brute
|
|
|
55
55
|
when :assistant
|
|
56
56
|
if message.tool_call?
|
|
57
57
|
blocks = []
|
|
58
|
-
|
|
58
|
+
unless message.content.to_s.empty?
|
|
59
|
+
blocks << { type: "text", text: message.content }
|
|
60
|
+
end
|
|
59
61
|
blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
|
|
60
62
|
{ role: "assistant", content: blocks }
|
|
61
63
|
else
|
|
@@ -79,14 +81,22 @@ module Brute
|
|
|
79
81
|
|
|
80
82
|
text = blocks.select { |b| b.type == :text }.map(&:text).join
|
|
81
83
|
tool_calls = blocks.select { |b| b.type == :tool_use }.map do |b|
|
|
82
|
-
|
|
84
|
+
if b.input.respond_to?(:to_h)
|
|
85
|
+
arguments = b.input.to_h
|
|
86
|
+
else
|
|
87
|
+
arguments = b.input
|
|
88
|
+
end
|
|
83
89
|
Brute::ToolCall.new(id: b.id, name: b.name, arguments: arguments)
|
|
84
90
|
end
|
|
85
91
|
|
|
92
|
+
if tool_calls.empty?
|
|
93
|
+
tool_calls = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
86
96
|
Brute::Message.new(
|
|
87
97
|
role: :assistant,
|
|
88
98
|
content: text,
|
|
89
|
-
tool_calls:
|
|
99
|
+
tool_calls: tool_calls,
|
|
90
100
|
)
|
|
91
101
|
end
|
|
92
102
|
end
|
|
@@ -39,8 +39,11 @@ module Brute
|
|
|
39
39
|
wire = message.tool_calls.map do |tc|
|
|
40
40
|
{ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
|
|
41
41
|
end
|
|
42
|
-
::LLM::Message.new(
|
|
43
|
-
|
|
42
|
+
::LLM::Message.new(
|
|
43
|
+
:assistant,
|
|
44
|
+
message.content.to_s,
|
|
45
|
+
{ tool_calls: wire, original_tool_calls: wire },
|
|
46
|
+
)
|
|
44
47
|
else
|
|
45
48
|
::LLM::Message.new(:assistant, message.content)
|
|
46
49
|
end
|
|
@@ -53,8 +56,9 @@ module Brute
|
|
|
53
56
|
|
|
54
57
|
# LLM::Message (from an LLM::Response) -> Brute::Message.
|
|
55
58
|
def wrap(message)
|
|
56
|
-
tool_calls =
|
|
57
|
-
|
|
59
|
+
tool_calls = nil
|
|
60
|
+
if message.tool_call?
|
|
61
|
+
tool_calls = message.tool_calls.map do |tc|
|
|
58
62
|
Brute::ToolCall.new(id: tc.id, name: tc["name"], arguments: tc.arguments.to_h)
|
|
59
63
|
end
|
|
60
64
|
end
|
|
@@ -26,9 +26,11 @@ module Brute
|
|
|
26
26
|
# An OpenRouter::Response's messages (one per choice; in practice
|
|
27
27
|
# OpenRouter returns exactly one).
|
|
28
28
|
def messages
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if @result.respond_to?(:choices)
|
|
30
|
+
@result.choices.map { |choice| choice["message"] || choice[:message] }
|
|
31
|
+
else
|
|
32
|
+
super
|
|
33
|
+
end
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
private
|
|
@@ -36,14 +38,25 @@ module Brute
|
|
|
36
38
|
def wrap(message)
|
|
37
39
|
# Coerce string keys to symbol keys if necessary
|
|
38
40
|
hash = message.to_h.transform_keys(&:to_sym)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
if hash.key?(:role)
|
|
42
|
+
hash[:role] = hash[:role].to_sym
|
|
43
|
+
end
|
|
44
|
+
if hash[:tool_calls]
|
|
45
|
+
hash[:tool_calls] = hash[:tool_calls].map { |tc| wrap_tool_call(tc) }
|
|
46
|
+
end
|
|
41
47
|
|
|
42
48
|
case hash
|
|
43
49
|
in { role: (:system | :user | :assistant | :tool) }
|
|
44
50
|
# Slice away provider extras (refusal, reasoning, model, ...)
|
|
45
51
|
# that Brute::Message doesn't know.
|
|
46
|
-
Brute::Message.new(
|
|
52
|
+
Brute::Message.new(
|
|
53
|
+
**hash.slice(
|
|
54
|
+
:role,
|
|
55
|
+
:content,
|
|
56
|
+
:tool_calls,
|
|
57
|
+
:tool_call_id,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
47
60
|
else
|
|
48
61
|
raise "Unrecognised message format #{message.inspect}"
|
|
49
62
|
end
|
|
@@ -53,16 +66,17 @@ module Brute
|
|
|
53
66
|
# -> the flat { id:, name:, arguments: Hash } Brute::Message understands.
|
|
54
67
|
def wrap_tool_call(tool_call)
|
|
55
68
|
tc = tool_call.to_h.transform_keys(&:to_sym)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
if tc[:function]
|
|
70
|
+
function = tc[:function].to_h.transform_keys(&:to_sym)
|
|
71
|
+
arguments = function[:arguments].to_s
|
|
72
|
+
{
|
|
73
|
+
id: tc[:id],
|
|
74
|
+
name: function[:name],
|
|
75
|
+
arguments: JSON.parse(arguments.empty? ? "{}" : arguments),
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
tc
|
|
79
|
+
end
|
|
66
80
|
end
|
|
67
81
|
end
|
|
68
82
|
end
|
|
@@ -28,9 +28,14 @@ module Brute
|
|
|
28
28
|
{ role: "tool", tool_call_id: message.tool_call_id, content: message.content.to_s }
|
|
29
29
|
when :assistant
|
|
30
30
|
if message.tool_call?
|
|
31
|
+
content = message.content
|
|
32
|
+
if content.to_s.empty?
|
|
33
|
+
content = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
31
36
|
{
|
|
32
37
|
role: "assistant",
|
|
33
|
-
content:
|
|
38
|
+
content: content,
|
|
34
39
|
tool_calls: message.tool_calls.map { |tc|
|
|
35
40
|
{ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
|
|
36
41
|
},
|
|
@@ -45,9 +50,11 @@ module Brute
|
|
|
45
50
|
|
|
46
51
|
# A chat completion response's messages (one per choice).
|
|
47
52
|
def messages
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
if @result.respond_to?(:choices)
|
|
54
|
+
@result.choices.map(&:message)
|
|
55
|
+
else
|
|
56
|
+
super
|
|
57
|
+
end
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
private
|
|
@@ -56,10 +63,10 @@ module Brute
|
|
|
56
63
|
# arguments arrive as a JSON string; parse them into a Hash.
|
|
57
64
|
def wrap(message)
|
|
58
65
|
tool_calls = message.tool_calls&.map do |tc|
|
|
59
|
-
|
|
60
|
-
JSON.parse(tc.function.arguments.to_s)
|
|
66
|
+
begin
|
|
67
|
+
arguments = JSON.parse(tc.function.arguments.to_s)
|
|
61
68
|
rescue JSON::ParserError
|
|
62
|
-
{}
|
|
69
|
+
arguments = {}
|
|
63
70
|
end
|
|
64
71
|
Brute::ToolCall.new(id: tc.id, name: tc.function.name, arguments: arguments)
|
|
65
72
|
end
|
|
@@ -24,32 +24,36 @@ module Brute
|
|
|
24
24
|
role: message.role,
|
|
25
25
|
content: message.content,
|
|
26
26
|
tool_calls: tool_calls,
|
|
27
|
-
tool_call_id: message.tool_call_id
|
|
27
|
+
tool_call_id: message.tool_call_id,
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
# RubyLLM::Message -> Brute::Message.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
def wrap(message)
|
|
35
|
+
raw_calls = message.tool_calls
|
|
36
|
+
if raw_calls.respond_to?(:values)
|
|
37
|
+
calls_list = raw_calls.values
|
|
38
|
+
else
|
|
39
|
+
calls_list = raw_calls
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
tool_calls = calls_list&.map do |tc|
|
|
43
|
+
Brute::ToolCall.new(
|
|
44
|
+
id: tc.id,
|
|
45
|
+
name: tc.name,
|
|
46
|
+
arguments: tc.arguments,
|
|
47
|
+
)
|
|
48
|
+
end
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
Brute::Message.new(
|
|
51
|
+
role: message.role,
|
|
52
|
+
content: message.content&.to_s, # Preserves nil safely
|
|
53
|
+
tool_calls: tool_calls,
|
|
54
|
+
tool_call_id: message.tool_call_id,
|
|
43
55
|
)
|
|
44
56
|
end
|
|
45
|
-
|
|
46
|
-
Brute::Message.new(
|
|
47
|
-
role: message.role,
|
|
48
|
-
content: message.content&.to_s, # Preserves nil safely
|
|
49
|
-
tool_calls: tool_calls,
|
|
50
|
-
tool_call_id: message.tool_call_id
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
end
|