brute 0.1.9 → 0.3.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/agent_stream.rb +24 -31
- data/lib/brute/middleware/tool_use_guard.rb +1 -1
- data/lib/brute/orchestrator.rb +69 -22
- data/lib/brute/pipeline.rb +1 -1
- data/lib/brute/providers/models_dev.rb +111 -0
- data/lib/brute/providers/opencode_go.rb +38 -0
- data/lib/brute/providers/opencode_zen.rb +82 -0
- data/lib/brute/providers/shell.rb +108 -0
- data/lib/brute/providers/shell_response.rb +100 -0
- data/lib/brute/system_prompt.rb +25 -2
- data/lib/brute/tools/delegate.rb +25 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +24 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 434ac3760153b860523176d38105ee8618ec95025713a332745281cda1af4cb8
|
|
4
|
+
data.tar.gz: 3358b33334bf01bd79188c1fb488729997b090bb4617063bc2f61579358b63d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03a3b9866b7e32cc44b260bdd8655983f2776d9c14235bc98f04a26e57f949070b9a3bae87008fb940bd44e9eb671f373f5544759f5ced71026a2c55eac6df44
|
|
7
|
+
data.tar.gz: f3896062d7c20fb622463c4af6b122b8d1281a9ff1147d2b8d8a747ea372fc8631609449652229dd10a604570b866119005449673273ac029d43ab087d9f5b5f
|
data/lib/brute/agent_stream.rb
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Brute
|
|
4
|
-
# Bridges llm.rb's streaming callbacks to
|
|
4
|
+
# Bridges llm.rb's streaming callbacks to the host application.
|
|
5
5
|
#
|
|
6
6
|
# Text and reasoning chunks fire immediately as the LLM generates them.
|
|
7
|
-
# Tool calls
|
|
8
|
-
#
|
|
7
|
+
# Tool calls are collected but NOT executed — execution is deferred to the
|
|
8
|
+
# orchestrator after the stream completes. This ensures text is never
|
|
9
|
+
# concurrent with tool execution.
|
|
10
|
+
#
|
|
11
|
+
# After the stream finishes, the orchestrator reads +pending_tools+ to
|
|
12
|
+
# dispatch all tool calls concurrently, then fires +on_tool_call_start+
|
|
13
|
+
# once with the full batch.
|
|
9
14
|
#
|
|
10
15
|
class AgentStream < LLM::Stream
|
|
11
16
|
# Tool call metadata recorded during streaming, used by ToolUseGuard
|
|
12
17
|
# when ctx.functions is empty (nil-choice bug in llm.rb).
|
|
13
|
-
# Cleared by the guard after consumption to prevent stale data from
|
|
14
|
-
# causing duplicate synthetic assistant messages on subsequent calls.
|
|
15
18
|
attr_reader :pending_tool_calls
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
# Deferred tool/error pairs: [(LLM::Function, error_or_nil), ...]
|
|
21
|
+
# The orchestrator reads these after the stream completes.
|
|
22
|
+
attr_reader :pending_tools
|
|
20
23
|
|
|
21
|
-
def initialize(on_content: nil, on_reasoning: nil,
|
|
24
|
+
def initialize(on_content: nil, on_reasoning: nil, on_question: nil)
|
|
22
25
|
@on_content = on_content
|
|
23
26
|
@on_reasoning = on_reasoning
|
|
24
|
-
@on_tool_call = on_tool_call
|
|
25
|
-
@on_tool_result = on_tool_result
|
|
26
27
|
@on_question = on_question
|
|
27
28
|
@pending_tool_calls = []
|
|
29
|
+
@pending_tools = []
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
# The on_question callback, needed by the orchestrator to set
|
|
33
|
+
# thread/fiber-locals before tool execution.
|
|
34
|
+
attr_reader :on_question
|
|
35
|
+
|
|
30
36
|
def on_content(text)
|
|
31
37
|
@on_content&.call(text)
|
|
32
38
|
end
|
|
@@ -35,30 +41,17 @@ module Brute
|
|
|
35
41
|
@on_reasoning&.call(text)
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Called by llm.rb per tool as it arrives during streaming.
|
|
45
|
+
# Records only — no execution, no threads, no queue pushes.
|
|
38
46
|
def on_tool_call(tool, error)
|
|
39
47
|
@pending_tool_calls << { id: tool.id, name: tool.name, arguments: tool.arguments }
|
|
40
|
-
@
|
|
41
|
-
|
|
42
|
-
if error
|
|
43
|
-
queue << error
|
|
44
|
-
@on_tool_result&.call(tool.name, error.value)
|
|
45
|
-
else
|
|
46
|
-
queue << LLM::Function::Task.new(spawn_with_callback(tool))
|
|
47
|
-
end
|
|
48
|
+
@pending_tools << [tool, error]
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
on_question = @on_question
|
|
55
|
-
name = tool.name
|
|
56
|
-
Thread.new do
|
|
57
|
-
Thread.current[:on_question] = on_question
|
|
58
|
-
result = tool.call
|
|
59
|
-
on_result&.call(name, result.respond_to?(:value) ? result.value : result)
|
|
60
|
-
result
|
|
61
|
-
end
|
|
51
|
+
# Clear all deferred state after the orchestrator has consumed it.
|
|
52
|
+
def clear_pending!
|
|
53
|
+
@pending_tool_calls.clear
|
|
54
|
+
@pending_tools.clear
|
|
62
55
|
end
|
|
63
56
|
end
|
|
64
57
|
end
|
data/lib/brute/orchestrator.rb
CHANGED
|
@@ -17,6 +17,11 @@ module Brute
|
|
|
17
17
|
# 2. Executes any tool calls the LLM requested
|
|
18
18
|
# 3. Repeats until done or a limit is hit
|
|
19
19
|
#
|
|
20
|
+
# Tool execution is always deferred until after the LLM response (including
|
|
21
|
+
# streaming) completes. Tools then run concurrently with each other via
|
|
22
|
+
# Async::Barrier. on_tool_call_start fires once with the full batch before
|
|
23
|
+
# execution begins; on_tool_result fires per-tool as each finishes.
|
|
24
|
+
#
|
|
20
25
|
class Orchestrator
|
|
21
26
|
MAX_REQUESTS_PER_TURN = 100
|
|
22
27
|
|
|
@@ -33,7 +38,7 @@ module Brute
|
|
|
33
38
|
agent_name: nil,
|
|
34
39
|
on_content: nil,
|
|
35
40
|
on_reasoning: nil,
|
|
36
|
-
|
|
41
|
+
on_tool_call_start: nil,
|
|
37
42
|
on_tool_result: nil,
|
|
38
43
|
on_question: nil,
|
|
39
44
|
logger: nil
|
|
@@ -62,8 +67,6 @@ module Brute
|
|
|
62
67
|
AgentStream.new(
|
|
63
68
|
on_content: on_content,
|
|
64
69
|
on_reasoning: on_reasoning,
|
|
65
|
-
on_tool_call: on_tool_call,
|
|
66
|
-
on_tool_result: on_tool_result,
|
|
67
70
|
on_question: on_question,
|
|
68
71
|
)
|
|
69
72
|
end
|
|
@@ -95,7 +98,7 @@ module Brute
|
|
|
95
98
|
callbacks: {
|
|
96
99
|
on_content: on_content,
|
|
97
100
|
on_reasoning: on_reasoning,
|
|
98
|
-
|
|
101
|
+
on_tool_call_start: on_tool_call_start,
|
|
99
102
|
on_tool_result: on_tool_result,
|
|
100
103
|
on_question: on_question,
|
|
101
104
|
},
|
|
@@ -131,15 +134,28 @@ module Brute
|
|
|
131
134
|
|
|
132
135
|
# --- Agent loop ---
|
|
133
136
|
loop do
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
# Collect pending tools from either source:
|
|
138
|
+
# - Streaming: AgentStream deferred tools (collected during stream)
|
|
139
|
+
# - Non-streaming: ctx.functions (populated by llm.rb after response)
|
|
140
|
+
pending = collect_pending_tools
|
|
141
|
+
break if pending.empty?
|
|
142
|
+
|
|
143
|
+
# Fire on_tool_call_start ONCE with the full batch
|
|
144
|
+
on_start = @env.dig(:callbacks, :on_tool_call_start)
|
|
145
|
+
on_start&.call(pending.map { |tool, _| { name: tool.name, arguments: tool.arguments } })
|
|
146
|
+
|
|
147
|
+
# Separate errors (tool not found) from executable tools
|
|
148
|
+
errors = pending.select { |_, err| err }
|
|
149
|
+
executable = pending.reject { |_, err| err }.map(&:first)
|
|
150
|
+
|
|
151
|
+
# Execute tools concurrently, collect results
|
|
152
|
+
results = execute_tool_calls(executable)
|
|
153
|
+
|
|
154
|
+
# Append error results (tool not found, etc.)
|
|
155
|
+
errors.each do |_, err|
|
|
156
|
+
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
157
|
+
on_result&.call(err.name, result_value(err))
|
|
158
|
+
results << err
|
|
143
159
|
end
|
|
144
160
|
|
|
145
161
|
# Send results back through the pipeline
|
|
@@ -151,7 +167,7 @@ module Brute
|
|
|
151
167
|
@request_count += 1
|
|
152
168
|
|
|
153
169
|
# Check limits
|
|
154
|
-
break if
|
|
170
|
+
break if collect_pending_tools.empty?
|
|
155
171
|
break if @request_count >= MAX_REQUESTS_PER_TURN
|
|
156
172
|
break if @env[:metadata][:tool_error_limit_reached]
|
|
157
173
|
end
|
|
@@ -222,24 +238,55 @@ module Brute
|
|
|
222
238
|
end
|
|
223
239
|
end
|
|
224
240
|
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Pending tool collection
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
# Collect pending tools from the stream (streaming) or context (non-streaming).
|
|
246
|
+
# Returns an array of [tool, error_or_nil] pairs.
|
|
247
|
+
# Clears the stream's deferred state after consumption.
|
|
248
|
+
def collect_pending_tools
|
|
249
|
+
if @stream&.pending_tools&.any?
|
|
250
|
+
tools = @stream.pending_tools.dup
|
|
251
|
+
@stream.clear_pending!
|
|
252
|
+
tools
|
|
253
|
+
elsif @context.functions.any?
|
|
254
|
+
@context.functions.to_a.map { |fn| [fn, nil] }
|
|
255
|
+
else
|
|
256
|
+
[]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
225
260
|
# ------------------------------------------------------------------
|
|
226
261
|
# Tool execution
|
|
227
262
|
# ------------------------------------------------------------------
|
|
228
263
|
|
|
229
|
-
def execute_tool_calls
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
def execute_tool_calls(functions)
|
|
265
|
+
return [] if functions.empty?
|
|
266
|
+
|
|
267
|
+
# Questions block execution — they must complete before other tools
|
|
268
|
+
# run, since the LLM may need the answer to inform subsequent work.
|
|
269
|
+
# Execute any question tools first (sequentially), then dispatch
|
|
270
|
+
# the remaining tools concurrently.
|
|
271
|
+
questions, others = functions.partition { |fn| fn.name == "question" }
|
|
232
272
|
|
|
233
|
-
|
|
273
|
+
results = []
|
|
274
|
+
results.concat(execute_sequential(questions)) if questions.any?
|
|
275
|
+
if others.size <= 1
|
|
276
|
+
results.concat(execute_sequential(others))
|
|
277
|
+
else
|
|
278
|
+
results.concat(execute_parallel(others))
|
|
279
|
+
end
|
|
280
|
+
results
|
|
234
281
|
end
|
|
235
282
|
|
|
236
283
|
# Run a single tool call synchronously.
|
|
237
284
|
def execute_sequential(functions)
|
|
238
|
-
on_call = @env.dig(:callbacks, :on_tool_call)
|
|
239
285
|
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
286
|
+
on_question = @env.dig(:callbacks, :on_question)
|
|
240
287
|
|
|
241
288
|
functions.map do |fn|
|
|
242
|
-
|
|
289
|
+
Thread.current[:on_question] = on_question
|
|
243
290
|
result = fn.call
|
|
244
291
|
on_result&.call(fn.name, result_value(result))
|
|
245
292
|
result
|
|
@@ -256,8 +303,8 @@ module Brute
|
|
|
256
303
|
# The barrier is stored in @barrier so abort! can cancel in-flight tools.
|
|
257
304
|
#
|
|
258
305
|
def execute_parallel(functions)
|
|
259
|
-
on_call = @env.dig(:callbacks, :on_tool_call)
|
|
260
306
|
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
307
|
+
on_question = @env.dig(:callbacks, :on_question)
|
|
261
308
|
|
|
262
309
|
results = Array.new(functions.size)
|
|
263
310
|
|
|
@@ -266,7 +313,7 @@ module Brute
|
|
|
266
313
|
|
|
267
314
|
functions.each_with_index do |fn, i|
|
|
268
315
|
@barrier.async do
|
|
269
|
-
|
|
316
|
+
Thread.current[:on_question] = on_question
|
|
270
317
|
results[i] = fn.call
|
|
271
318
|
r = results[i]
|
|
272
319
|
on_result&.call(r.name, result_value(r))
|
data/lib/brute/pipeline.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Brute
|
|
|
22
22
|
# tools: [Tool, ...], # tool classes
|
|
23
23
|
# params: {}, # extra LLM call params (reasoning config, etc.)
|
|
24
24
|
# metadata: {}, # shared scratchpad for middleware state
|
|
25
|
-
# callbacks: {}, # :on_content, :
|
|
25
|
+
# callbacks: {}, # :on_content, :on_tool_call_start, :on_tool_result
|
|
26
26
|
# }
|
|
27
27
|
#
|
|
28
28
|
# ## The response
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Providers
|
|
8
|
+
# Fetches and caches model metadata from the models.dev catalog.
|
|
9
|
+
#
|
|
10
|
+
# Quacks like llm.rb's provider.models so that the REPL's model
|
|
11
|
+
# picker can call:
|
|
12
|
+
#
|
|
13
|
+
# provider.models.all.select(&:chat?)
|
|
14
|
+
#
|
|
15
|
+
# Models are fetched from https://models.dev/api.json and cached
|
|
16
|
+
# in-memory for the lifetime of the process (with a TTL).
|
|
17
|
+
#
|
|
18
|
+
class ModelsDev
|
|
19
|
+
CATALOG_URL = "https://models.dev/api.json"
|
|
20
|
+
CACHE_TTL = 3600 # 1 hour
|
|
21
|
+
|
|
22
|
+
ModelEntry = Struct.new(:id, :name, :chat?, :cost, :limit, :reasoning, :tool_call, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# @param provider [LLM::Provider] the provider instance (for delegating execute/headers)
|
|
25
|
+
# @param provider_id [String] the provider key in models.dev (e.g., "opencode", "opencode-go")
|
|
26
|
+
def initialize(provider:, provider_id: "opencode")
|
|
27
|
+
@provider = provider
|
|
28
|
+
@provider_id = provider_id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns all models for this provider from the models.dev catalog.
|
|
32
|
+
# @return [Array<ModelEntry>]
|
|
33
|
+
def all
|
|
34
|
+
entries = fetch_provider_models
|
|
35
|
+
entries.map do |id, model|
|
|
36
|
+
ModelEntry.new(
|
|
37
|
+
id: id,
|
|
38
|
+
name: model["name"] || id,
|
|
39
|
+
chat?: true,
|
|
40
|
+
cost: model["cost"],
|
|
41
|
+
limit: model["limit"],
|
|
42
|
+
reasoning: model["reasoning"] || false,
|
|
43
|
+
tool_call: model["tool_call"] || false
|
|
44
|
+
)
|
|
45
|
+
end.sort_by(&:id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fetch_provider_models
|
|
51
|
+
catalog = self.class.fetch_catalog
|
|
52
|
+
provider_data = catalog[@provider_id]
|
|
53
|
+
return {} unless provider_data
|
|
54
|
+
|
|
55
|
+
provider_data["models"] || {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Fetch the models.dev catalog, with in-memory caching.
|
|
60
|
+
# Thread-safe via a simple mutex.
|
|
61
|
+
def fetch_catalog
|
|
62
|
+
@mutex ||= Mutex.new
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
if @catalog && @fetched_at && (Time.now - @fetched_at < CACHE_TTL)
|
|
65
|
+
return @catalog
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@catalog = download_catalog
|
|
69
|
+
@fetched_at = Time.now
|
|
70
|
+
@catalog
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Force a cache refresh on next access.
|
|
75
|
+
def invalidate_cache!
|
|
76
|
+
@mutex&.synchronize do
|
|
77
|
+
@catalog = nil
|
|
78
|
+
@fetched_at = nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def download_catalog
|
|
85
|
+
uri = URI.parse(CATALOG_URL)
|
|
86
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
87
|
+
http.use_ssl = true
|
|
88
|
+
http.open_timeout = 10
|
|
89
|
+
http.read_timeout = 30
|
|
90
|
+
|
|
91
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
92
|
+
request["User-Agent"] = "brute/#{Brute::VERSION}"
|
|
93
|
+
request["Accept"] = "application/json"
|
|
94
|
+
|
|
95
|
+
response = http.request(request)
|
|
96
|
+
|
|
97
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
98
|
+
raise "Failed to fetch models.dev catalog: HTTP #{response.code}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
JSON.parse(response.body)
|
|
102
|
+
rescue => e
|
|
103
|
+
# Return empty catalog on failure so the provider still works
|
|
104
|
+
# with default_model, just without a model list.
|
|
105
|
+
warn "[brute] Warning: Could not fetch models.dev catalog: #{e.message}"
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
##
|
|
5
|
+
# OpenAI-compatible provider for the OpenCode Go API gateway.
|
|
6
|
+
#
|
|
7
|
+
# OpenCode Go is the low-cost subscription plan with a restricted
|
|
8
|
+
# (lite) model list. Same gateway as Zen, different endpoint path.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# llm = LLM::OpencodeGo.new(key: ENV["OPENCODE_API_KEY"])
|
|
12
|
+
# ctx = LLM::Context.new(llm)
|
|
13
|
+
# ctx.talk "Hello from brute"
|
|
14
|
+
#
|
|
15
|
+
class OpencodeGo < OpencodeZen
|
|
16
|
+
##
|
|
17
|
+
# @return [Symbol]
|
|
18
|
+
def name
|
|
19
|
+
:opencode_go
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# Returns models from the models.dev catalog.
|
|
24
|
+
# Note: The Go gateway only accepts lite-tier models, but models.dev
|
|
25
|
+
# doesn't distinguish between Zen and Go tiers. We show the full
|
|
26
|
+
# catalog; the gateway returns an error for unsupported models.
|
|
27
|
+
# @return [Brute::Providers::ModelsDev]
|
|
28
|
+
def models
|
|
29
|
+
Brute::Providers::ModelsDev.new(provider: self, provider_id: "opencode")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def completions_path
|
|
35
|
+
"/zen/go/v1/chat/completions"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ensure the OpenAI provider is loaded (llm.rb lazy-loads providers).
|
|
4
|
+
unless defined?(LLM::OpenAI)
|
|
5
|
+
require "llm/providers/openai"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module LLM
|
|
9
|
+
##
|
|
10
|
+
# OpenAI-compatible provider for the OpenCode Zen API gateway.
|
|
11
|
+
#
|
|
12
|
+
# OpenCode Zen is a curated model gateway at opencode.ai that proxies
|
|
13
|
+
# requests to upstream LLM providers (Anthropic, OpenAI, Google, etc.).
|
|
14
|
+
# All models are accessed via the OpenAI-compatible chat completions
|
|
15
|
+
# endpoint; the gateway handles format conversion internally.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# llm = LLM::OpencodeZen.new(key: ENV["OPENCODE_API_KEY"])
|
|
19
|
+
# ctx = LLM::Context.new(llm)
|
|
20
|
+
# ctx.talk "Hello from brute"
|
|
21
|
+
#
|
|
22
|
+
# @example Anonymous access (free models only)
|
|
23
|
+
# llm = LLM::OpencodeZen.new(key: "public")
|
|
24
|
+
# ctx = LLM::Context.new(llm)
|
|
25
|
+
# ctx.talk "Hello"
|
|
26
|
+
#
|
|
27
|
+
class OpencodeZen < OpenAI
|
|
28
|
+
HOST = "opencode.ai"
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @param key [String] OpenCode API key, or "public" for anonymous access
|
|
32
|
+
# @param (see LLM::Provider#initialize)
|
|
33
|
+
def initialize(key: "public", **)
|
|
34
|
+
super(host: HOST, key: key, **)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [Symbol]
|
|
39
|
+
def name
|
|
40
|
+
:opencode_zen
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Returns the default model (Claude Sonnet 4, the most common Zen model).
|
|
45
|
+
# @return [String]
|
|
46
|
+
def default_model
|
|
47
|
+
"claude-sonnet-4-20250514"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# Returns models from the models.dev catalog for the opencode provider.
|
|
52
|
+
# @return [Brute::Providers::ModelsDev]
|
|
53
|
+
def models
|
|
54
|
+
Brute::Providers::ModelsDev.new(provider: self, provider_id: "opencode")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# -- Unsupported sub-APIs --
|
|
58
|
+
|
|
59
|
+
def responses = raise(NotImplementedError, "Use chat completions via the Zen gateway")
|
|
60
|
+
def images = raise(NotImplementedError, "Not supported via Zen gateway")
|
|
61
|
+
def audio = raise(NotImplementedError, "Not supported via Zen gateway")
|
|
62
|
+
def files = raise(NotImplementedError, "Not supported via Zen gateway")
|
|
63
|
+
def moderations = raise(NotImplementedError, "Not supported via Zen gateway")
|
|
64
|
+
def vector_stores = raise(NotImplementedError, "Not supported via Zen gateway")
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def completions_path
|
|
69
|
+
"/zen/v1/chat/completions"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def headers
|
|
73
|
+
lock do
|
|
74
|
+
(@headers || {}).merge(
|
|
75
|
+
"Content-Type" => "application/json",
|
|
76
|
+
"Authorization" => "Bearer #{@key}",
|
|
77
|
+
"x-opencode-client" => "brute"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Brute
|
|
6
|
+
module Providers
|
|
7
|
+
# A pseudo-LLM provider that executes user input as code via the
|
|
8
|
+
# existing Brute::Tools::Shell tool.
|
|
9
|
+
#
|
|
10
|
+
# Models correspond to interpreters:
|
|
11
|
+
#
|
|
12
|
+
# bash - pass-through (default)
|
|
13
|
+
# ruby - ruby -e '...'
|
|
14
|
+
# python - python3 -c '...'
|
|
15
|
+
# nix - nix eval --expr '...'
|
|
16
|
+
#
|
|
17
|
+
# The provider's #complete method returns a synthetic response
|
|
18
|
+
# containing a single "shell" tool call. The orchestrator executes
|
|
19
|
+
# it through the normal pipeline — all middleware (message tracking,
|
|
20
|
+
# session persistence, token tracking, etc.) fires as usual.
|
|
21
|
+
#
|
|
22
|
+
class Shell
|
|
23
|
+
MODELS = %w[bash ruby python nix].freeze
|
|
24
|
+
|
|
25
|
+
INTERPRETERS = {
|
|
26
|
+
"bash" => ->(cmd) { cmd },
|
|
27
|
+
"ruby" => ->(cmd) { "ruby -e #{Shellwords.escape(cmd)}" },
|
|
28
|
+
"python" => ->(cmd) { "python3 -c #{Shellwords.escape(cmd)}" },
|
|
29
|
+
"nix" => ->(cmd) { "nix eval --expr #{Shellwords.escape(cmd)}" },
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# ── LLM::Provider duck-type interface ──────────────────────────
|
|
33
|
+
|
|
34
|
+
def name = :shell
|
|
35
|
+
def default_model = "bash"
|
|
36
|
+
def user_role = :user
|
|
37
|
+
def tool_role = :tool
|
|
38
|
+
def assistant_role = :assistant
|
|
39
|
+
def system_role = :system
|
|
40
|
+
def tracer = LLM::Tracer::Null.new(self)
|
|
41
|
+
|
|
42
|
+
def complete(prompt, params = {})
|
|
43
|
+
model = params[:model]&.to_s || default_model
|
|
44
|
+
text = extract_text(prompt)
|
|
45
|
+
tools = params[:tools] || []
|
|
46
|
+
|
|
47
|
+
# nil text means we received tool results (second call) —
|
|
48
|
+
# return an empty assistant response so the orchestrator exits.
|
|
49
|
+
return ShellResponse.new(model: model, tools: tools) if text.nil?
|
|
50
|
+
|
|
51
|
+
wrap = INTERPRETERS.fetch(model, INTERPRETERS["bash"])
|
|
52
|
+
command = wrap.call(text)
|
|
53
|
+
|
|
54
|
+
ShellResponse.new(command: command, model: model, tools: tools)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# For the REPL model picker: provider.models.all.select(&:chat?)
|
|
58
|
+
def models
|
|
59
|
+
ModelList.new(MODELS)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ── Internals ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Extract the user's text from whatever prompt format ctx.talk sends.
|
|
67
|
+
# Returns nil when the prompt contains tool results (the second
|
|
68
|
+
# round-trip) so #complete knows to return an empty response.
|
|
69
|
+
def extract_text(prompt)
|
|
70
|
+
case prompt
|
|
71
|
+
when String
|
|
72
|
+
prompt
|
|
73
|
+
when ::Array
|
|
74
|
+
return nil if prompt.any? { |p| LLM::Function::Return === p }
|
|
75
|
+
|
|
76
|
+
user_msg = prompt.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
|
|
77
|
+
user_msg&.content.to_s
|
|
78
|
+
else
|
|
79
|
+
if prompt.respond_to?(:to_a)
|
|
80
|
+
msgs = prompt.to_a
|
|
81
|
+
return nil if msgs.any? { |m| m.respond_to?(:content) && LLM::Function::Return === m.content }
|
|
82
|
+
|
|
83
|
+
user_msg = msgs.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
|
|
84
|
+
user_msg&.content.to_s
|
|
85
|
+
else
|
|
86
|
+
prompt.to_s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── ModelList ──────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# Minimal object that quacks like provider.models so the REPL's
|
|
94
|
+
# fetch_models can call provider.models.all.select(&:chat?).
|
|
95
|
+
class ModelList
|
|
96
|
+
ModelEntry = Struct.new(:id, :chat?, keyword_init: true)
|
|
97
|
+
|
|
98
|
+
def initialize(names)
|
|
99
|
+
@entries = names.map { |n| ModelEntry.new(id: n, chat?: true) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def all
|
|
103
|
+
@entries
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Brute
|
|
6
|
+
module Providers
|
|
7
|
+
# Synthetic completion response returned by Brute::Providers::Shell.
|
|
8
|
+
#
|
|
9
|
+
# When +command+ is present, the response contains a single assistant
|
|
10
|
+
# message with a "shell" tool call. The orchestrator picks it up and
|
|
11
|
+
# executes Brute::Tools::Shell through the normal pipeline.
|
|
12
|
+
#
|
|
13
|
+
# When +command+ is nil (tool results round-trip), the response
|
|
14
|
+
# contains an empty assistant message with no tool calls, causing
|
|
15
|
+
# the orchestrator loop to exit.
|
|
16
|
+
#
|
|
17
|
+
class ShellResponse
|
|
18
|
+
def initialize(command: nil, model: "bash", tools: [])
|
|
19
|
+
@command = command
|
|
20
|
+
@model_name = model
|
|
21
|
+
@tools = tools || []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def messages
|
|
25
|
+
return [empty_assistant] if @command.nil?
|
|
26
|
+
|
|
27
|
+
call_id = "shell_#{SecureRandom.hex(8)}"
|
|
28
|
+
tool_call = LLM::Object.from(
|
|
29
|
+
id: call_id,
|
|
30
|
+
name: "shell",
|
|
31
|
+
arguments: { "command" => @command },
|
|
32
|
+
)
|
|
33
|
+
original = [{
|
|
34
|
+
"type" => "tool_use",
|
|
35
|
+
"id" => call_id,
|
|
36
|
+
"name" => "shell",
|
|
37
|
+
"input" => { "command" => @command },
|
|
38
|
+
}]
|
|
39
|
+
|
|
40
|
+
[LLM::Message.new(:assistant, "", {
|
|
41
|
+
tool_calls: [tool_call],
|
|
42
|
+
original_tool_calls: original,
|
|
43
|
+
tools: @tools,
|
|
44
|
+
})]
|
|
45
|
+
end
|
|
46
|
+
alias_method :choices, :messages
|
|
47
|
+
|
|
48
|
+
def model
|
|
49
|
+
@model_name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def input_tokens
|
|
53
|
+
0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def output_tokens
|
|
57
|
+
0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reasoning_tokens
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def total_tokens
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def content
|
|
69
|
+
messages.find(&:assistant?)&.content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def content!
|
|
73
|
+
LLM.json.load(content)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reasoning_content
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def usage
|
|
81
|
+
LLM::Usage.new(
|
|
82
|
+
input_tokens: 0,
|
|
83
|
+
output_tokens: 0,
|
|
84
|
+
reasoning_tokens: 0,
|
|
85
|
+
total_tokens: 0,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Contract must be included AFTER method definitions —
|
|
90
|
+
# LLM::Contract checks that all required methods exist at include time.
|
|
91
|
+
include LLM::Contract::Completion
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def empty_assistant
|
|
96
|
+
LLM::Message.new(:assistant, "")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/brute/system_prompt.rb
CHANGED
|
@@ -26,9 +26,17 @@ module Brute
|
|
|
26
26
|
# prepare-time, then appends conditional sections based on runtime state.
|
|
27
27
|
def self.default
|
|
28
28
|
build do |prompt, ctx|
|
|
29
|
-
# Provider-specific base stack
|
|
29
|
+
# Provider-specific base stack.
|
|
30
|
+
# For gateway providers (opencode_zen, opencode_go), infer the
|
|
31
|
+
# upstream model family from the model name so we use the most
|
|
32
|
+
# appropriate prompt stack (e.g., anthropic stack for claude-*).
|
|
30
33
|
provider = ctx[:provider_name].to_s
|
|
31
|
-
|
|
34
|
+
stack_key = if provider.start_with?("opencode")
|
|
35
|
+
infer_stack_from_model(ctx[:model_name].to_s)
|
|
36
|
+
else
|
|
37
|
+
provider
|
|
38
|
+
end
|
|
39
|
+
STACKS.fetch(stack_key, STACKS["default"]).each do |mod|
|
|
32
40
|
prompt << mod.call(ctx)
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -114,6 +122,21 @@ module Brute
|
|
|
114
122
|
],
|
|
115
123
|
}.freeze
|
|
116
124
|
|
|
125
|
+
# Infer the best prompt stack from a model name.
|
|
126
|
+
# Used for gateway providers that route to multiple upstream model families.
|
|
127
|
+
def self.infer_stack_from_model(model_name)
|
|
128
|
+
case model_name
|
|
129
|
+
when /\bclaude\b/i, /\bbig.?pickle\b/i
|
|
130
|
+
"anthropic"
|
|
131
|
+
when /\bgpt\b/i, /\bo[134]\b/i, /\bcodex\b/i
|
|
132
|
+
"openai"
|
|
133
|
+
when /\bgemini\b/i, /\bgemma\b/i
|
|
134
|
+
"google"
|
|
135
|
+
else
|
|
136
|
+
"default"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
117
140
|
def initialize(block)
|
|
118
141
|
@block = block
|
|
119
142
|
end
|
data/lib/brute/tools/delegate.rb
CHANGED
|
@@ -28,7 +28,31 @@ module Brute
|
|
|
28
28
|
rounds += 1
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
{result: res
|
|
31
|
+
{result: extract_content(res, sub)}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Safely extract text content from the sub-agent response.
|
|
37
|
+
#
|
|
38
|
+
# When the LLM returns only tool calls (no text content block),
|
|
39
|
+
# res.content raises NoMethodError because the response adapter's
|
|
40
|
+
# choices array is empty (it only maps over text blocks), or
|
|
41
|
+
# returns nil when the response has no text. Fall back to the
|
|
42
|
+
# last assistant text in the conversation history.
|
|
43
|
+
def extract_content(res, context)
|
|
44
|
+
text = begin
|
|
45
|
+
res.content
|
|
46
|
+
rescue NoMethodError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
return text if text.is_a?(::String) && !text.empty?
|
|
50
|
+
|
|
51
|
+
last_assistant = context.messages.to_a
|
|
52
|
+
.select(&:assistant?)
|
|
53
|
+
.reverse
|
|
54
|
+
.find { |m| m.content.is_a?(::String) && !m.content.empty? }
|
|
55
|
+
last_assistant&.content || "(sub-agent completed but produced no text response)"
|
|
32
56
|
end
|
|
33
57
|
end
|
|
34
58
|
end
|
data/lib/brute/version.rb
CHANGED
data/lib/brute.rb
CHANGED
|
@@ -90,6 +90,13 @@ require_relative 'brute/tools/todo_read'
|
|
|
90
90
|
require_relative 'brute/tools/delegate'
|
|
91
91
|
require_relative 'brute/tools/question'
|
|
92
92
|
|
|
93
|
+
# Providers
|
|
94
|
+
require_relative 'brute/providers/shell_response'
|
|
95
|
+
require_relative 'brute/providers/shell'
|
|
96
|
+
require_relative 'brute/providers/models_dev'
|
|
97
|
+
require_relative 'brute/providers/opencode_zen'
|
|
98
|
+
require_relative 'brute/providers/opencode_go'
|
|
99
|
+
|
|
93
100
|
# Orchestrator (depends on tools, middleware, and infrastructure)
|
|
94
101
|
require_relative 'brute/orchestrator'
|
|
95
102
|
|
|
@@ -139,10 +146,14 @@ module Brute
|
|
|
139
146
|
'google' => ->(key) { LLM.google(key: key) },
|
|
140
147
|
'deepseek' => ->(key) { LLM.deepseek(key: key) },
|
|
141
148
|
'ollama' => ->(key) { LLM.ollama(key: key) },
|
|
142
|
-
'xai' => ->(key) { LLM.xai(key: key) }
|
|
149
|
+
'xai' => ->(key) { LLM.xai(key: key) },
|
|
150
|
+
'opencode_zen' => ->(key) { LLM::OpencodeZen.new(key: key) },
|
|
151
|
+
'opencode_go' => ->(key) { LLM::OpencodeGo.new(key: key) },
|
|
152
|
+
'shell' => ->(_key) { Providers::Shell.new },
|
|
143
153
|
}.freeze
|
|
144
154
|
|
|
145
155
|
# List provider names that have API keys configured in the environment.
|
|
156
|
+
# The shell provider is always available (no key needed).
|
|
146
157
|
def self.configured_providers
|
|
147
158
|
PROVIDERS.keys.select { |name| api_key_for(name) }
|
|
148
159
|
end
|
|
@@ -161,6 +172,14 @@ module Brute
|
|
|
161
172
|
|
|
162
173
|
# Look up the API key for a given provider name.
|
|
163
174
|
def self.api_key_for(name)
|
|
175
|
+
# Shell provider needs no key.
|
|
176
|
+
return "none" if name == "shell"
|
|
177
|
+
|
|
178
|
+
# OpenCode providers: check OPENCODE_API_KEY, fall back to "public" for anonymous access.
|
|
179
|
+
if name == "opencode_zen" || name == "opencode_go"
|
|
180
|
+
return ENV["OPENCODE_API_KEY"] || "public"
|
|
181
|
+
end
|
|
182
|
+
|
|
164
183
|
# Explicit generic key always works
|
|
165
184
|
return ENV["LLM_API_KEY"] if ENV["LLM_API_KEY"]
|
|
166
185
|
|
|
@@ -178,6 +197,7 @@ module Brute
|
|
|
178
197
|
# 2. ANTHROPIC_API_KEY (implicit: provider = anthropic)
|
|
179
198
|
# 3. OPENAI_API_KEY (implicit: provider = openai)
|
|
180
199
|
# 4. GOOGLE_API_KEY (implicit: provider = google)
|
|
200
|
+
# 5. OPENCODE_API_KEY (implicit: provider = opencode_zen)
|
|
181
201
|
#
|
|
182
202
|
# Returns nil if no key is found. Error is deferred to Orchestrator#run.
|
|
183
203
|
def self.resolve_provider
|
|
@@ -193,6 +213,9 @@ module Brute
|
|
|
193
213
|
elsif ENV['GOOGLE_API_KEY']
|
|
194
214
|
key = ENV['GOOGLE_API_KEY']
|
|
195
215
|
name = 'google'
|
|
216
|
+
elsif ENV['OPENCODE_API_KEY']
|
|
217
|
+
key = ENV['OPENCODE_API_KEY']
|
|
218
|
+
name = 'opencode_zen'
|
|
196
219
|
else
|
|
197
220
|
return nil
|
|
198
221
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 1980-01-
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: async
|
|
@@ -155,6 +155,11 @@ files:
|
|
|
155
155
|
- lib/brute/prompts/text/tool_usage/google.txt
|
|
156
156
|
- lib/brute/prompts/tone_and_style.rb
|
|
157
157
|
- lib/brute/prompts/tool_usage.rb
|
|
158
|
+
- lib/brute/providers/models_dev.rb
|
|
159
|
+
- lib/brute/providers/opencode_go.rb
|
|
160
|
+
- lib/brute/providers/opencode_zen.rb
|
|
161
|
+
- lib/brute/providers/shell.rb
|
|
162
|
+
- lib/brute/providers/shell_response.rb
|
|
158
163
|
- lib/brute/session.rb
|
|
159
164
|
- lib/brute/skill.rb
|
|
160
165
|
- lib/brute/snapshot_store.rb
|