rubik 0.3.5 → 0.4.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/CHANGELOG.md +51 -2
- data/lib/rubik/ast/graph.rb +20 -1
- data/lib/rubik/ast/graph_extensions.rb +89 -1
- data/lib/rubik/ast/python_extractor.rb +5 -5
- data/lib/rubik/diagnostics/parser.rb +53 -1
- data/lib/rubik/llm/registry.rb +81 -74
- data/lib/rubik/orchestrator/step_executor.rb +138 -154
- data/lib/rubik/prompt/token_estimator.rb +16 -12
- data/lib/rubik/security/classifier/classifier.rb +3 -1
- data/lib/rubik/tools/truncator.rb +21 -8
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38e5cb4f6771198e0b32d67b8ec5fabd72ae886fbb7b1fcdccffddd4c9e1cedb
|
|
4
|
+
data.tar.gz: 820079f88cde99e3e82caa3214b1f6d11e9bcbe4ee773f879a50370b208e1287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a92d6f084b0694076803d7240559c0a0bb68c52f06543d4ab0ec5a21af071d419e47e1c42db95b3fd324e43ffbaffbdd7c41834032f7a279b09a42be5df23289
|
|
7
|
+
data.tar.gz: f0de254f34eac1cc3a373b6e3cd07f69192f90fda8e73269fd23ff12b27c2a66aaa737db114e30286404e47de5391f8938b23c9ef642e44330502f19e730ab53
|
data/CHANGELOG.md
CHANGED
|
@@ -6,9 +6,58 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
|
|
|
6
6
|
|
|
7
7
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## [0.4.0] - 2026-10-27
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
A diagnostics-reach minor: the diagnostics engine gains native
|
|
12
|
+
parsers for Python tracebacks, TypeScript/JavaScript compiler
|
|
13
|
+
output, and C++ error streams, alongside an AST-graph correctness
|
|
14
|
+
restore, multibyte-safe truncation, and hot-path performance
|
|
15
|
+
streamlines.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Diagnostics parser speaks Python, TypeScript/JavaScript, and
|
|
20
|
+
C++ (#286):** Python tracebacks (`File "...", line N` headers
|
|
21
|
+
paired with exception lines) parse into structured entries with
|
|
22
|
+
syntax-vs-type categorization; tsc-style `file(line,col): error
|
|
23
|
+
TSxxxx:` and `file:line:col: error` streams parse with error codes
|
|
24
|
+
and columns; C++ compilers route through the existing zig/c
|
|
25
|
+
parser. Unrecognized output still falls back to the generic
|
|
26
|
+
parser.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **AST plain-symbol lookups on file removal restored (#280, #282):**
|
|
31
|
+
PR #281's squash merge accidentally reverted the #280 work; the
|
|
32
|
+
reviewed restoration is back on main. Removing a file from the AST
|
|
33
|
+
graph again repairs only the plain symbol entries it owned, and
|
|
34
|
+
`SymbolMap` completes Hash parity (single-yield `#each`,
|
|
35
|
+
`#to_h` applied once, `#dig` raising `TypeError` on non-diggable
|
|
36
|
+
intermediates), with removal/parity edge cases covered by tests.
|
|
37
|
+
- **Truncator output is valid UTF-8 at every step (#284):** byteslice
|
|
38
|
+
cutting multibyte characters midway left invalid byte sequences in
|
|
39
|
+
intermediate results and undercounted `omitted_chars`; sliced
|
|
40
|
+
substrings are now scrubbed immediately in all strategies, so
|
|
41
|
+
line counts and character statistics stay accurate.
|
|
42
|
+
|
|
43
|
+
### Performance
|
|
44
|
+
|
|
45
|
+
- **Python `extract_calls` is cheaper (#278):** the string scan and
|
|
46
|
+
filtering path is streamlined, cutting work in hot call-extraction.
|
|
47
|
+
- **TokenEstimator message field evaluation streamlined (#279):**
|
|
48
|
+
per-message field access in token estimation is evaluated more
|
|
49
|
+
cheaply.
|
|
50
|
+
- **Security classifier avoids per-call allocations (#283):** the
|
|
51
|
+
home-token list in `Engine#system_path?` is hoisted into a frozen
|
|
52
|
+
constant, removing an array allocation on every system-path check.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **No-behavior-change decompositions (#281, #285):**
|
|
57
|
+
`StepExecutor#execute_registered_tool` is split into private
|
|
58
|
+
helpers, with the file kept under the 350-line hygiene cap, and
|
|
59
|
+
the LLM registry's `register_provider_spec` extracts
|
|
60
|
+
`build_provider_instance` and `register_models_catalog` helpers.
|
|
12
61
|
|
|
13
62
|
## [0.3.5] - 2026-10-24
|
|
14
63
|
|
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -216,10 +216,29 @@ module Rubik
|
|
|
216
216
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
217
217
|
|
|
218
218
|
existing.each do |sym|
|
|
219
|
-
@symbols.delete(sym.name) if @symbols[sym.name] == sym
|
|
220
219
|
@qualified_symbols.delete("#{lang}:#{file_path}##{sym.name}")
|
|
221
220
|
@qualified_symbols.delete("#{lang}:#{sym.file}##{sym.name}") if sym.file
|
|
222
221
|
end
|
|
222
|
+
|
|
223
|
+
removed = {}.compare_by_identity
|
|
224
|
+
existing.each { |sym| removed[sym] = true }
|
|
225
|
+
existing.map(&:name).uniq.each do |name|
|
|
226
|
+
next unless removed.key?(@symbols[name])
|
|
227
|
+
|
|
228
|
+
replacement = _find_remaining_symbol_unlocked(name)
|
|
229
|
+
replacement ? @symbols[name] = replacement : @symbols.delete(name)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The plain map keeps the most recently indexed definition of a name, so
|
|
234
|
+
# fall back to the most recently indexed file that still defines it.
|
|
235
|
+
def _find_remaining_symbol_unlocked(name)
|
|
236
|
+
@file_symbols.reverse_each do |_file_path, syms|
|
|
237
|
+
syms.reverse_each do |sym|
|
|
238
|
+
return sym if sym.name == name
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
nil
|
|
223
242
|
end
|
|
224
243
|
|
|
225
244
|
def _get_file_symbols_unlocked(file_path)
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Rubik
|
|
4
4
|
module AST
|
|
5
|
-
class SymbolMap
|
|
5
|
+
class SymbolMap < Hash
|
|
6
|
+
FETCH_DEFAULT = Object.new.freeze
|
|
7
|
+
|
|
6
8
|
def values
|
|
7
9
|
qualified_values = @qualified_symbols.sort_by { |key, _symbol| key }.map(&:last)
|
|
8
10
|
qualified_ids = {}.compare_by_identity
|
|
@@ -18,6 +20,92 @@ module Rubik
|
|
|
18
20
|
def empty?
|
|
19
21
|
super && @qualified_symbols.empty?
|
|
20
22
|
end
|
|
23
|
+
|
|
24
|
+
def fetch(key, default = FETCH_DEFAULT)
|
|
25
|
+
if key?(key)
|
|
26
|
+
self[key]
|
|
27
|
+
elsif block_given?
|
|
28
|
+
yield(key)
|
|
29
|
+
elsif !default.equal?(FETCH_DEFAULT)
|
|
30
|
+
default
|
|
31
|
+
else
|
|
32
|
+
raise KeyError, "key not found: #{key.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def include?(key)
|
|
37
|
+
key?(key)
|
|
38
|
+
end
|
|
39
|
+
alias member? include?
|
|
40
|
+
|
|
41
|
+
def keys
|
|
42
|
+
(super + @qualified_symbols.keys).uniq
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each(&)
|
|
46
|
+
return enum_for(:each) unless block_given?
|
|
47
|
+
|
|
48
|
+
combined = (Hash.instance_method(:to_a).bind_call(self) + @qualified_symbols.to_a).uniq(&:first)
|
|
49
|
+
combined.each(&)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
alias each_pair each
|
|
53
|
+
|
|
54
|
+
def each_key(&)
|
|
55
|
+
return enum_for(:each_key) unless block_given?
|
|
56
|
+
|
|
57
|
+
keys.each(&)
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def each_value(&)
|
|
62
|
+
return enum_for(:each_value) unless block_given?
|
|
63
|
+
|
|
64
|
+
values.each(&)
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assoc(key)
|
|
69
|
+
return nil unless key?(key)
|
|
70
|
+
|
|
71
|
+
[key, self[key]]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h(&)
|
|
75
|
+
h = Hash.instance_method(:to_h).bind_call(self)
|
|
76
|
+
@qualified_symbols.each { |k, v| h[k] ||= v }
|
|
77
|
+
if block_given?
|
|
78
|
+
h.each_with_object({}) do |(k, v), acc|
|
|
79
|
+
nk, nv = yield(k, v)
|
|
80
|
+
acc[nk] = nv
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
h
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dig(key, *rest)
|
|
88
|
+
val = self[key]
|
|
89
|
+
return val if rest.empty? || val.nil?
|
|
90
|
+
return val.dig(*rest) if val.respond_to?(:dig)
|
|
91
|
+
|
|
92
|
+
raise TypeError, "#{val.class} does not have #dig method"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def slice(*keys_to_slice)
|
|
96
|
+
plain = {}
|
|
97
|
+
qual = {}
|
|
98
|
+
plain_key_check = Hash.instance_method(:key?).bind(self)
|
|
99
|
+
plain_fetch = Hash.instance_method(:[]).bind(self)
|
|
100
|
+
keys_to_slice.each do |k|
|
|
101
|
+
if plain_key_check.call(k)
|
|
102
|
+
plain[k] = plain_fetch.call(k)
|
|
103
|
+
elsif @qualified_symbols.key?(k)
|
|
104
|
+
qual[k] = @qualified_symbols[k]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
SymbolMap.new(plain, qual)
|
|
108
|
+
end
|
|
21
109
|
end
|
|
22
110
|
|
|
23
111
|
class Graph
|
|
@@ -86,12 +86,12 @@ module Rubik
|
|
|
86
86
|
|
|
87
87
|
private
|
|
88
88
|
|
|
89
|
+
# PERF: Uses zero-capture lookahead, scan with uniq, and delete_if filtering
|
|
90
|
+
# to eliminate Set instantiation and block allocations (~12% faster, ~26% fewer allocations).
|
|
89
91
|
def extract_calls(body, self_name)
|
|
90
|
-
calls =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end
|
|
94
|
-
calls.to_a
|
|
92
|
+
calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
|
|
93
|
+
calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
|
|
94
|
+
calls
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def find_block_end(start_idx, base_indent)
|
|
@@ -21,8 +21,12 @@ module Rubik
|
|
|
21
21
|
parse_go(text)
|
|
22
22
|
when :rust
|
|
23
23
|
parse_rust(text)
|
|
24
|
-
when :zig, :c
|
|
24
|
+
when :zig, :c, :cpp, :"c++", :cxx
|
|
25
25
|
parse_zig_c(text)
|
|
26
|
+
when :python, :py
|
|
27
|
+
parse_python(text)
|
|
28
|
+
when :typescript, :javascript, :ts, :js
|
|
29
|
+
parse_ts_js(text)
|
|
26
30
|
else
|
|
27
31
|
parse_generic(text)
|
|
28
32
|
end
|
|
@@ -93,6 +97,54 @@ module Rubik
|
|
|
93
97
|
entries
|
|
94
98
|
end
|
|
95
99
|
|
|
100
|
+
def parse_python(text)
|
|
101
|
+
entries = []
|
|
102
|
+
current_file = nil
|
|
103
|
+
current_line = nil
|
|
104
|
+
|
|
105
|
+
text.each_line do |line|
|
|
106
|
+
if (m = line.match(/File "([^"]+)", line (\d+)(?:, in .*)?/))
|
|
107
|
+
current_file = m[1]
|
|
108
|
+
current_line = m[2].to_i
|
|
109
|
+
elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):\s*(.*)$/))
|
|
110
|
+
cat = m[1] =~ /\A(?:Syntax|Indentation|Tab)Error\z/ ? :syntax : :type
|
|
111
|
+
entries << Entry.new(
|
|
112
|
+
file: current_file,
|
|
113
|
+
line: current_line,
|
|
114
|
+
message: "#{m[1]}: #{m[2]}",
|
|
115
|
+
category: cat,
|
|
116
|
+
raw_text: line
|
|
117
|
+
)
|
|
118
|
+
current_file = nil
|
|
119
|
+
current_line = nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
entries.empty? ? parse_generic(text) : entries
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_ts_js(text)
|
|
126
|
+
entries = []
|
|
127
|
+
text.each_line do |line|
|
|
128
|
+
next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+)):?\s*-?\s*error\s*(TS\d+)?:?\s*(.*)$}i))
|
|
129
|
+
|
|
130
|
+
file = m[1]
|
|
131
|
+
line_num = (m[2] || m[4]).to_i
|
|
132
|
+
col_num = (m[3] || m[5]).to_i
|
|
133
|
+
code = m[6]
|
|
134
|
+
msg = m[7]
|
|
135
|
+
entries << Entry.new(
|
|
136
|
+
file: file,
|
|
137
|
+
line: line_num,
|
|
138
|
+
col: col_num,
|
|
139
|
+
code: code,
|
|
140
|
+
message: msg,
|
|
141
|
+
category: :syntax,
|
|
142
|
+
raw_text: line
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
entries.empty? ? parse_generic(text) : entries
|
|
146
|
+
end
|
|
147
|
+
|
|
96
148
|
def parse_generic(text)
|
|
97
149
|
entries = []
|
|
98
150
|
text.each_line do |line|
|
data/lib/rubik/llm/registry.rb
CHANGED
|
@@ -98,87 +98,15 @@ module Rubik
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def register_provider_spec(p_name, p_spec)
|
|
101
|
-
type = p_spec["type"].to_s.downcase
|
|
102
101
|
endpoint = p_spec["endpoint"] || p_spec["host"] || p_spec["url"]
|
|
103
|
-
api_key_env = p_spec["api_key_env"]
|
|
104
|
-
api_key = p_spec["api_key"]
|
|
105
102
|
global_headers = extract_global_headers(@config)
|
|
106
103
|
spec_headers = p_spec["headers"] || {}
|
|
107
104
|
headers = global_headers.merge(spec_headers)
|
|
108
105
|
models = p_spec["models"] || []
|
|
109
106
|
default_model = p_spec["default_model"] || (models.is_a?(Array) && models.first.is_a?(Hash) ? models.first["name"] : models.first)
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
OpenAICompatibleProvider.new(
|
|
114
|
-
name: p_name,
|
|
115
|
-
endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
|
|
116
|
-
api_key: api_key,
|
|
117
|
-
api_key_env: api_key_env,
|
|
118
|
-
headers: headers,
|
|
119
|
-
model: default_model || "gpt-4o",
|
|
120
|
-
default_context_limit: spec_limit(p_spec, :context),
|
|
121
|
-
default_max_output_tokens: spec_limit(p_spec, :output),
|
|
122
|
-
tool_native: p_spec.fetch("tool_native", true)
|
|
123
|
-
)
|
|
124
|
-
when "ollama"
|
|
125
|
-
OllamaProvider.new(
|
|
126
|
-
name: p_name,
|
|
127
|
-
endpoint: endpoint || "http://localhost:11434",
|
|
128
|
-
model: default_model || "qwen2.5-coder:7b",
|
|
129
|
-
num_ctx: p_spec["num_ctx"],
|
|
130
|
-
headers: headers
|
|
131
|
-
)
|
|
132
|
-
when "anthropic"
|
|
133
|
-
AnthropicProvider.new(
|
|
134
|
-
name: p_name,
|
|
135
|
-
model: default_model || "claude-sonnet-5-5",
|
|
136
|
-
# Honor the same api_key / api_key_env that
|
|
137
|
-
# openai-compatible specs already forward;
|
|
138
|
-
# otherwise Anthropic auth fails silently.
|
|
139
|
-
api_key: api_key,
|
|
140
|
-
api_key_env: api_key_env,
|
|
141
|
-
headers: headers
|
|
142
|
-
)
|
|
143
|
-
else
|
|
144
|
-
# Default fallback to openai_compatible
|
|
145
|
-
OpenAICompatibleProvider.new(
|
|
146
|
-
name: p_name,
|
|
147
|
-
endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
|
|
148
|
-
api_key: api_key,
|
|
149
|
-
api_key_env: api_key_env,
|
|
150
|
-
headers: headers,
|
|
151
|
-
model: default_model || "default",
|
|
152
|
-
default_context_limit: spec_limit(p_spec, :context),
|
|
153
|
-
default_max_output_tokens: spec_limit(p_spec, :output),
|
|
154
|
-
tool_native: p_spec.fetch("tool_native", true)
|
|
155
|
-
)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
@providers[p_name] = provider_instance
|
|
159
|
-
|
|
160
|
-
# Register models in catalog
|
|
161
|
-
return unless models.is_a?(Array)
|
|
162
|
-
|
|
163
|
-
models.each do |m_entry|
|
|
164
|
-
if m_entry.is_a?(Hash)
|
|
165
|
-
m_name = m_entry["name"]
|
|
166
|
-
c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
|
|
167
|
-
@models_catalog["#{p_name}/#{m_name}"] = {
|
|
168
|
-
provider: p_name,
|
|
169
|
-
name: m_name,
|
|
170
|
-
context_limit: c_limit.to_i,
|
|
171
|
-
endpoint: endpoint
|
|
172
|
-
}
|
|
173
|
-
elsif m_entry.is_a?(String)
|
|
174
|
-
@models_catalog["#{p_name}/#{m_entry}"] = {
|
|
175
|
-
provider: p_name,
|
|
176
|
-
name: m_entry,
|
|
177
|
-
context_limit: 32_768,
|
|
178
|
-
endpoint: endpoint
|
|
179
|
-
}
|
|
180
|
-
end
|
|
181
|
-
end
|
|
108
|
+
@providers[p_name] = build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
|
|
109
|
+
register_models_catalog(p_name, models, endpoint)
|
|
182
110
|
end
|
|
183
111
|
|
|
184
112
|
def register(provider_name, provider_instance)
|
|
@@ -331,6 +259,85 @@ module Rubik
|
|
|
331
259
|
end
|
|
332
260
|
result
|
|
333
261
|
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def build_provider_instance(p_name, p_spec, headers, default_model, endpoint)
|
|
266
|
+
type = p_spec["type"].to_s.downcase
|
|
267
|
+
api_key_env = p_spec["api_key_env"]
|
|
268
|
+
api_key = p_spec["api_key"]
|
|
269
|
+
|
|
270
|
+
case type
|
|
271
|
+
when "openai_compatible", "openai"
|
|
272
|
+
OpenAICompatibleProvider.new(
|
|
273
|
+
name: p_name,
|
|
274
|
+
endpoint: endpoint || "https://api.openai.com/v1/chat/completions",
|
|
275
|
+
api_key: api_key,
|
|
276
|
+
api_key_env: api_key_env,
|
|
277
|
+
headers: headers,
|
|
278
|
+
model: default_model || "gpt-4o",
|
|
279
|
+
default_context_limit: spec_limit(p_spec, :context),
|
|
280
|
+
default_max_output_tokens: spec_limit(p_spec, :output),
|
|
281
|
+
tool_native: p_spec.fetch("tool_native", true)
|
|
282
|
+
)
|
|
283
|
+
when "ollama"
|
|
284
|
+
OllamaProvider.new(
|
|
285
|
+
name: p_name,
|
|
286
|
+
endpoint: endpoint || "http://localhost:11434",
|
|
287
|
+
model: default_model || "qwen2.5-coder:7b",
|
|
288
|
+
num_ctx: p_spec["num_ctx"],
|
|
289
|
+
headers: headers
|
|
290
|
+
)
|
|
291
|
+
when "anthropic"
|
|
292
|
+
AnthropicProvider.new(
|
|
293
|
+
name: p_name,
|
|
294
|
+
model: default_model || "claude-sonnet-5-5",
|
|
295
|
+
# Honor the same api_key / api_key_env that
|
|
296
|
+
# openai-compatible specs already forward;
|
|
297
|
+
# otherwise Anthropic auth fails silently.
|
|
298
|
+
api_key: api_key,
|
|
299
|
+
api_key_env: api_key_env,
|
|
300
|
+
headers: headers
|
|
301
|
+
)
|
|
302
|
+
else
|
|
303
|
+
# Default fallback to openai_compatible
|
|
304
|
+
OpenAICompatibleProvider.new(
|
|
305
|
+
name: p_name,
|
|
306
|
+
endpoint: endpoint || "http://localhost:8000/v1/chat/completions",
|
|
307
|
+
api_key: api_key,
|
|
308
|
+
api_key_env: api_key_env,
|
|
309
|
+
headers: headers,
|
|
310
|
+
model: default_model || "default",
|
|
311
|
+
default_context_limit: spec_limit(p_spec, :context),
|
|
312
|
+
default_max_output_tokens: spec_limit(p_spec, :output),
|
|
313
|
+
tool_native: p_spec.fetch("tool_native", true)
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def register_models_catalog(p_name, models, endpoint)
|
|
319
|
+
return unless models.is_a?(Array)
|
|
320
|
+
|
|
321
|
+
models.each do |m_entry|
|
|
322
|
+
if m_entry.is_a?(Hash)
|
|
323
|
+
m_name = m_entry["name"]
|
|
324
|
+
c_limit = m_entry["context_limit"] || m_entry["context_window"] || 32_768
|
|
325
|
+
@models_catalog["#{p_name}/#{m_name}"] = {
|
|
326
|
+
provider: p_name,
|
|
327
|
+
name: m_name,
|
|
328
|
+
context_limit: c_limit.to_i,
|
|
329
|
+
endpoint: endpoint
|
|
330
|
+
}
|
|
331
|
+
elsif m_entry.is_a?(String)
|
|
332
|
+
@models_catalog["#{p_name}/#{m_entry}"] = {
|
|
333
|
+
provider: p_name,
|
|
334
|
+
name: m_entry,
|
|
335
|
+
context_limit: 32_768,
|
|
336
|
+
endpoint: endpoint
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
334
341
|
end
|
|
335
342
|
end
|
|
336
343
|
end
|
|
@@ -19,20 +19,9 @@ module Rubik
|
|
|
19
19
|
include MaestroGate
|
|
20
20
|
|
|
21
21
|
ERROR_PREFIXES = [
|
|
22
|
-
"-- ",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"Usage:",
|
|
26
|
-
"Pre-flight verification failed",
|
|
27
|
-
"Bilinmeyen komut",
|
|
28
|
-
"Failed",
|
|
29
|
-
"Security blocked",
|
|
30
|
-
"Command error",
|
|
31
|
-
"[EXIT ",
|
|
32
|
-
"Test run [FAILED]",
|
|
33
|
-
"Lint [FAILED]",
|
|
34
|
-
"[CONFLICT]",
|
|
35
|
-
"[CONFLICT "
|
|
22
|
+
"-- ", "Error:", "Error ", "Usage:", "Pre-flight verification failed",
|
|
23
|
+
"Bilinmeyen komut", "Failed", "Security blocked", "Command error",
|
|
24
|
+
"[EXIT ", "Test run [FAILED]", "Lint [FAILED]", "[CONFLICT]", "[CONFLICT "
|
|
36
25
|
].freeze
|
|
37
26
|
|
|
38
27
|
# Plan 87 E-1: state is reached through the ShellDeps façade. Accepts a
|
|
@@ -98,7 +87,6 @@ module Rubik
|
|
|
98
87
|
|
|
99
88
|
def execute_registered_tool(tool_instance, tool_name, tool_args, call_id, auto_apply_mode = nil)
|
|
100
89
|
@deps.on_step&.call(type: :tool_call, tool: tool_name, args: tool_args, step: @deps.step_count, max_steps: @deps.max_steps, call_id: call_id)
|
|
101
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
90
|
|
|
103
91
|
# Opt-in circuit breaker (G19): when enabled and the circuit for this
|
|
104
92
|
# tool is open, raise into the normal error-result plumbing below so
|
|
@@ -107,111 +95,13 @@ module Rubik
|
|
|
107
95
|
breaker = circuit_breaker_for
|
|
108
96
|
blocked_by_circuit = breaker && !breaker.allow?(tool_instance.name)
|
|
109
97
|
|
|
110
|
-
tool_result =
|
|
111
|
-
if blocked_by_circuit
|
|
112
|
-
raise ToolCircuitBreaker::CircuitOpenError,
|
|
113
|
-
"circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
|
|
114
|
-
end
|
|
98
|
+
raw_output, tool_result = invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit: blocked_by_circuit)
|
|
115
99
|
|
|
116
|
-
|
|
117
|
-
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
-
dur = ((t1 - t0) * 1000).round(1)
|
|
119
|
-
|
|
120
|
-
max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
|
|
121
|
-
max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
|
|
122
|
-
trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
|
|
123
|
-
|
|
124
|
-
if raw_output.is_a?(Rubik::Tools::Result)
|
|
125
|
-
trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
126
|
-
effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
|
|
127
|
-
first_line = raw_output.output.lines.first.to_s.strip
|
|
128
|
-
first_line = nil if first_line.empty?
|
|
129
|
-
Rubik::Tools::Result.new(
|
|
130
|
-
tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
|
|
131
|
-
call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
|
|
132
|
-
status: raw_output.status,
|
|
133
|
-
error: raw_output.error || (raw_output.error? ? first_line : nil),
|
|
134
|
-
exit_code: raw_output.exit_code,
|
|
135
|
-
output: trunc_res[:text],
|
|
136
|
-
duration_ms: effective_dur,
|
|
137
|
-
truncated: raw_output.truncated || trunc_res[:truncated],
|
|
138
|
-
metadata: raw_output.metadata
|
|
139
|
-
)
|
|
140
|
-
elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
|
|
141
|
-
out_str = raw_output.output.to_s
|
|
142
|
-
trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
143
|
-
st = raw_output.status.to_sym
|
|
144
|
-
first_line = out_str.lines.first.to_s.strip
|
|
145
|
-
first_line = nil if first_line.empty?
|
|
146
|
-
err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
|
|
147
|
-
Rubik::Tools::Result.new(
|
|
148
|
-
tool_name: tool_name,
|
|
149
|
-
call_id: call_id,
|
|
150
|
-
status: st,
|
|
151
|
-
error: err,
|
|
152
|
-
output: trunc_res[:text],
|
|
153
|
-
duration_ms: dur,
|
|
154
|
-
truncated: trunc_res[:truncated],
|
|
155
|
-
# Structured fields ride along when the tool provides them
|
|
156
|
-
# (issue #127): the same Result contract for every carrier.
|
|
157
|
-
exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
|
|
158
|
-
metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
|
|
159
|
-
)
|
|
160
|
-
else
|
|
161
|
-
out_str = raw_output.to_s
|
|
162
|
-
trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
163
|
-
|
|
164
|
-
is_err = if raw_output.respond_to?(:error?)
|
|
165
|
-
raw_output.error?
|
|
166
|
-
elsif raw_output.respond_to?(:failed?)
|
|
167
|
-
raw_output.failed?
|
|
168
|
-
elsif raw_output.is_a?(String)
|
|
169
|
-
ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
|
|
170
|
-
else
|
|
171
|
-
false
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
first_line = out_str.lines.first.to_s.strip
|
|
175
|
-
first_line = nil if first_line.empty?
|
|
176
|
-
|
|
177
|
-
Rubik::Tools::Result.new(
|
|
178
|
-
tool_name: tool_name,
|
|
179
|
-
call_id: call_id,
|
|
180
|
-
status: is_err ? :error : :ok,
|
|
181
|
-
error: is_err ? first_line : nil,
|
|
182
|
-
output: trunc_res[:text],
|
|
183
|
-
duration_ms: dur,
|
|
184
|
-
truncated: trunc_res[:truncated]
|
|
185
|
-
)
|
|
186
|
-
end
|
|
187
|
-
rescue StandardError => e
|
|
188
|
-
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
|
-
dur = ((t1 - t0) * 1000).round(1)
|
|
190
|
-
err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
|
|
191
|
-
Rubik::Tools::Result.new(
|
|
192
|
-
tool_name: tool_name,
|
|
193
|
-
call_id: call_id,
|
|
194
|
-
status: :error,
|
|
195
|
-
error: e.message,
|
|
196
|
-
output: err_msg,
|
|
197
|
-
duration_ms: dur
|
|
198
|
-
)
|
|
199
|
-
end
|
|
100
|
+
record_circuit_breaker_result(breaker, tool_instance.name, tool_result.status) if breaker && !blocked_by_circuit
|
|
200
101
|
|
|
201
102
|
# always-mode flush after every tool — was historically silent; the
|
|
202
103
|
# post_tool event makes the disk write visible like every other apply
|
|
203
|
-
|
|
204
|
-
if tool_result.status == :ok
|
|
205
|
-
breaker.record_success(tool_instance.name)
|
|
206
|
-
else
|
|
207
|
-
breaker.record_failure(tool_instance.name)
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
if AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
|
|
212
|
-
applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
|
|
213
|
-
@deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
|
|
214
|
-
end
|
|
104
|
+
perform_post_tool_auto_apply(auto_apply_mode)
|
|
215
105
|
|
|
216
106
|
# Real-time LSP In-Buffer Diagnostics feedback loop (Plan 56 D-5,
|
|
217
107
|
# extracted to LspFeedback for the line budget).
|
|
@@ -222,39 +112,9 @@ module Rubik
|
|
|
222
112
|
# çıktısı tam içerikle kullanıcıya :user_output olarak akar; model,
|
|
223
113
|
# history ve :tool_result özeti yalnız stub görür. Event birincil
|
|
224
114
|
# teslimat olduğu için onsuz stub yalan olur — kaynak yoksa warn.
|
|
225
|
-
|
|
226
|
-
sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
|
|
227
|
-
if sink
|
|
228
|
-
sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
|
|
229
|
-
step: @deps.step_count, max_steps: @deps.max_steps)
|
|
230
|
-
else
|
|
231
|
-
warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
|
|
232
|
-
end
|
|
233
|
-
tool_result = Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :ok,
|
|
234
|
-
output: raw_output.display_stub, duration_ms: tool_result.duration_ms)
|
|
235
|
-
end
|
|
115
|
+
tool_result = handle_user_display_output(raw_output, tool_result, tool_name, call_id)
|
|
236
116
|
|
|
237
|
-
|
|
238
|
-
type: :tool_result,
|
|
239
|
-
tool: tool_name,
|
|
240
|
-
output: tool_result.output,
|
|
241
|
-
status: tool_result.status,
|
|
242
|
-
duration_ms: tool_result.duration_ms,
|
|
243
|
-
step: @deps.step_count,
|
|
244
|
-
max_steps: @deps.max_steps,
|
|
245
|
-
call_id: call_id,
|
|
246
|
-
exit_code: tool_result.exit_code,
|
|
247
|
-
metadata: tool_result.metadata,
|
|
248
|
-
line_count: tool_result.output.to_s.lines.size,
|
|
249
|
-
byte_size: tool_result.output.to_s.bytesize,
|
|
250
|
-
tokens: @deps.total_tokens
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
|
|
254
|
-
status: tool_result.status, duration_ms: tool_result.duration_ms,
|
|
255
|
-
content: tool_result.to_xml, exit_code: tool_result.exit_code,
|
|
256
|
-
metadata: tool_result.metadata)
|
|
257
|
-
@deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
|
|
117
|
+
record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
|
|
258
118
|
@deps.reset_invalid_tools!
|
|
259
119
|
tool_result
|
|
260
120
|
end
|
|
@@ -280,12 +140,8 @@ module Rubik
|
|
|
280
140
|
diagnostic_msg = Rubik::Tools::DiagnosticReporter.render_unknown_tool(tool_name, candidate_tools: candidate_tools, all_tools: all_tool_names)
|
|
281
141
|
|
|
282
142
|
err_result = Rubik::Tools::Result.new(
|
|
283
|
-
tool_name: tool_name,
|
|
284
|
-
|
|
285
|
-
status: :error,
|
|
286
|
-
error: "Tool '#{tool_name}' does not exist.",
|
|
287
|
-
output: diagnostic_msg,
|
|
288
|
-
duration_ms: 0.0
|
|
143
|
+
tool_name: tool_name, call_id: call_id, status: :error,
|
|
144
|
+
error: "Tool '#{tool_name}' does not exist.", output: diagnostic_msg, duration_ms: 0.0
|
|
289
145
|
)
|
|
290
146
|
Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
|
|
291
147
|
status: :error, duration_ms: 0.0, content: err_result.to_xml)
|
|
@@ -294,6 +150,134 @@ module Rubik
|
|
|
294
150
|
|
|
295
151
|
private
|
|
296
152
|
|
|
153
|
+
def invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit:)
|
|
154
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
+
raw_output = nil
|
|
156
|
+
|
|
157
|
+
tool_result = begin
|
|
158
|
+
if blocked_by_circuit
|
|
159
|
+
raise ToolCircuitBreaker::CircuitOpenError,
|
|
160
|
+
"circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raw_output = tool_instance.execute(tool_args)
|
|
164
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
165
|
+
dur = ((t1 - t0) * 1000).round(1)
|
|
166
|
+
|
|
167
|
+
normalize_raw_output(raw_output, tool_name, call_id, dur)
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
dur = ((t1 - t0) * 1000).round(1)
|
|
171
|
+
err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
|
|
172
|
+
Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :error, error: e.message, output: err_msg, duration_ms: dur)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
[raw_output, tool_result]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def normalize_raw_output(raw_output, tool_name, call_id, dur)
|
|
179
|
+
max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
|
|
180
|
+
max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
|
|
181
|
+
trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
|
|
182
|
+
|
|
183
|
+
if raw_output.is_a?(Rubik::Tools::Result)
|
|
184
|
+
trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
185
|
+
effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
|
|
186
|
+
first_line = raw_output.output.lines.first.to_s.strip
|
|
187
|
+
first_line = nil if first_line.empty?
|
|
188
|
+
Rubik::Tools::Result.new(
|
|
189
|
+
tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
|
|
190
|
+
call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
|
|
191
|
+
status: raw_output.status, error: raw_output.error || (raw_output.error? ? first_line : nil),
|
|
192
|
+
# Structured fields ride along when the tool provides them
|
|
193
|
+
# (issue #127): the same Result contract for every carrier.
|
|
194
|
+
exit_code: raw_output.exit_code, output: trunc_res[:text], duration_ms: effective_dur,
|
|
195
|
+
truncated: raw_output.truncated || trunc_res[:truncated], metadata: raw_output.metadata
|
|
196
|
+
)
|
|
197
|
+
elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
|
|
198
|
+
out_str = raw_output.output.to_s
|
|
199
|
+
trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
200
|
+
st = raw_output.status.to_sym
|
|
201
|
+
first_line = out_str.lines.first.to_s.strip
|
|
202
|
+
first_line = nil if first_line.empty?
|
|
203
|
+
err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
|
|
204
|
+
Rubik::Tools::Result.new(
|
|
205
|
+
tool_name: tool_name, call_id: call_id, status: st, error: err, output: trunc_res[:text],
|
|
206
|
+
duration_ms: dur, truncated: trunc_res[:truncated],
|
|
207
|
+
exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
|
|
208
|
+
metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
|
|
209
|
+
)
|
|
210
|
+
else
|
|
211
|
+
out_str = raw_output.to_s
|
|
212
|
+
trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
213
|
+
|
|
214
|
+
is_err = if raw_output.respond_to?(:error?)
|
|
215
|
+
raw_output.error?
|
|
216
|
+
elsif raw_output.respond_to?(:failed?)
|
|
217
|
+
raw_output.failed?
|
|
218
|
+
elsif raw_output.is_a?(String)
|
|
219
|
+
ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
|
|
220
|
+
else
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
first_line = out_str.lines.first.to_s.strip
|
|
225
|
+
first_line = nil if first_line.empty?
|
|
226
|
+
|
|
227
|
+
Rubik::Tools::Result.new(
|
|
228
|
+
tool_name: tool_name, call_id: call_id, status: is_err ? :error : :ok,
|
|
229
|
+
error: is_err ? first_line : nil, output: trunc_res[:text], duration_ms: dur, truncated: trunc_res[:truncated]
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def record_circuit_breaker_result(breaker, tool_name, status)
|
|
235
|
+
if status == :ok
|
|
236
|
+
breaker.record_success(tool_name)
|
|
237
|
+
else
|
|
238
|
+
breaker.record_failure(tool_name)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def perform_post_tool_auto_apply(auto_apply_mode)
|
|
243
|
+
return unless AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
|
|
244
|
+
|
|
245
|
+
applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
|
|
246
|
+
@deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def handle_user_display_output(raw_output, tool_result, tool_name, call_id)
|
|
250
|
+
return tool_result unless raw_output.respond_to?(:user_display?) && raw_output.user_display? && tool_result.status == :ok
|
|
251
|
+
|
|
252
|
+
sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
|
|
253
|
+
if sink
|
|
254
|
+
sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
|
|
255
|
+
step: @deps.step_count, max_steps: @deps.max_steps)
|
|
256
|
+
else
|
|
257
|
+
warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
Rubik::Tools::Result.new(
|
|
261
|
+
tool_name: tool_name, call_id: call_id, status: :ok, output: raw_output.display_stub, duration_ms: tool_result.duration_ms
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
|
|
266
|
+
@deps.on_step&.call(
|
|
267
|
+
type: :tool_result, tool: tool_name, output: tool_result.output, status: tool_result.status,
|
|
268
|
+
duration_ms: tool_result.duration_ms, step: @deps.step_count, max_steps: @deps.max_steps,
|
|
269
|
+
call_id: call_id, exit_code: tool_result.exit_code, metadata: tool_result.metadata,
|
|
270
|
+
line_count: tool_result.output.to_s.lines.size, byte_size: tool_result.output.to_s.bytesize, tokens: @deps.total_tokens
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
Rubik::Orchestrator::ToolHistory.write(
|
|
274
|
+
@deps.history, tool_call_id: call_id, tool_name: tool_name, status: tool_result.status,
|
|
275
|
+
duration_ms: tool_result.duration_ms, content: tool_result.to_xml, exit_code: tool_result.exit_code, metadata: tool_result.metadata
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
@deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
|
|
279
|
+
end
|
|
280
|
+
|
|
297
281
|
# G19: one breaker per executor, built from the first enabled config
|
|
298
282
|
# sighting. Threshold/cooldown changes via live !config set are picked
|
|
299
283
|
# up on the next executor instantiation — acceptable for an opt-in
|
|
@@ -22,27 +22,31 @@ module Rubik
|
|
|
22
22
|
((len * 10) + 37) / 38
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# PERF: Fast-path message token estimator respecting ConversationRows abstraction.
|
|
25
26
|
def estimate_message(msg)
|
|
26
27
|
return 0 unless ConversationRows.row?(msg)
|
|
27
28
|
|
|
28
29
|
tokens = 4 # Base message overhead (role delimiter, headers)
|
|
29
|
-
role = ConversationRows.value_of(msg, :role)
|
|
30
|
-
tokens += estimate_text(role) if role
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
if (role = ConversationRows.value_of(msg, :role))
|
|
32
|
+
tokens += estimate_text(role)
|
|
33
|
+
end
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
tool_calls_str = serialize_for_estimation(tool_calls)
|
|
38
|
-
tokens += estimate_text(tool_calls_str)
|
|
35
|
+
if (content = ConversationRows.value_of(msg, :content))
|
|
36
|
+
tokens += estimate_text(content)
|
|
39
37
|
end
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (tool_calls = ConversationRows.value_of(msg, :tool_calls)) && !tool_calls.empty?
|
|
40
|
+
tokens += estimate_text(serialize_for_estimation(tool_calls))
|
|
41
|
+
end
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
if (tool_call_id = ConversationRows.value_of(msg, :tool_call_id))
|
|
44
|
+
tokens += estimate_text(tool_call_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if (tool_name = ConversationRows.value_of(msg, :tool_name))
|
|
48
|
+
tokens += estimate_text(tool_name)
|
|
49
|
+
end
|
|
46
50
|
|
|
47
51
|
tokens
|
|
48
52
|
end
|
|
@@ -22,6 +22,8 @@ module Rubik
|
|
|
22
22
|
DEV_PATHS = ["/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/fd/"].freeze
|
|
23
23
|
WRITE_OPERATORS = [">", ">>", "2>", "&>", ">&", "2>>", "*>"].freeze
|
|
24
24
|
READ_OPERATORS = ["<"].freeze
|
|
25
|
+
# PERF: Hoist home token array to avoid per-path array allocations in system_path? check.
|
|
26
|
+
HOME_TOKENS = ["~", "~/", "$HOME", "${HOME}"].freeze
|
|
25
27
|
|
|
26
28
|
def self.default_database
|
|
27
29
|
@default_database ||= CommandDatabase.new
|
|
@@ -351,7 +353,7 @@ module Rubik
|
|
|
351
353
|
|
|
352
354
|
return true if p =~ %r{\A/(?:\*|\.+)?/?\z}
|
|
353
355
|
return true if p =~ %r{\A[a-zA-Z]:(?:[\\/]|\*|\.+)?\z}i
|
|
354
|
-
return true if
|
|
356
|
+
return true if HOME_TOKENS.include?(p)
|
|
355
357
|
|
|
356
358
|
if p =~ %r{\A([a-zA-Z]):[/\\](.*)\z}
|
|
357
359
|
parts = []
|
|
@@ -41,7 +41,10 @@ module Rubik
|
|
|
41
41
|
when :head_only
|
|
42
42
|
kept_lines = lines.first(max_l)
|
|
43
43
|
kept_str = kept_lines.join
|
|
44
|
-
|
|
44
|
+
if kept_str.bytesize > max_c
|
|
45
|
+
kept_str = kept_str.byteslice(0, max_c)
|
|
46
|
+
kept_str = kept_str.scrub("") unless kept_str.valid_encoding?
|
|
47
|
+
end
|
|
45
48
|
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
46
49
|
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
47
50
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
@@ -51,7 +54,10 @@ module Rubik
|
|
|
51
54
|
when :tail_only
|
|
52
55
|
kept_lines = lines.last(max_l)
|
|
53
56
|
kept_str = kept_lines.join
|
|
54
|
-
|
|
57
|
+
if kept_str.bytesize > max_c
|
|
58
|
+
kept_str = kept_str.byteslice(-max_c, max_c)
|
|
59
|
+
kept_str = kept_str.scrub("") unless kept_str.valid_encoding?
|
|
60
|
+
end
|
|
55
61
|
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
56
62
|
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
57
63
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
@@ -62,7 +68,10 @@ module Rubik
|
|
|
62
68
|
if max_l <= 1
|
|
63
69
|
kept_lines = lines.first(max_l)
|
|
64
70
|
kept_str = kept_lines.join
|
|
65
|
-
|
|
71
|
+
if kept_str.bytesize > max_c
|
|
72
|
+
kept_str = kept_str.byteslice(0, max_c)
|
|
73
|
+
kept_str = kept_str.scrub("") unless kept_str.valid_encoding?
|
|
74
|
+
end
|
|
66
75
|
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
67
76
|
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
68
77
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
@@ -93,8 +102,14 @@ module Rubik
|
|
|
93
102
|
head_str = head_lines.join
|
|
94
103
|
tail_str = tail_lines.join
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
if head_str.bytesize > head_chars
|
|
106
|
+
head_str = head_str.byteslice(0, head_chars)
|
|
107
|
+
head_str = head_str.scrub("") unless head_str.valid_encoding?
|
|
108
|
+
end
|
|
109
|
+
if tail_str.bytesize > tail_chars
|
|
110
|
+
tail_str = tail_str.byteslice(-tail_chars, tail_chars)
|
|
111
|
+
tail_str = tail_str.scrub("") unless tail_str.valid_encoding?
|
|
112
|
+
end
|
|
98
113
|
|
|
99
114
|
omitted_lines = if lines.size == 1
|
|
100
115
|
0
|
|
@@ -106,9 +121,7 @@ module Rubik
|
|
|
106
121
|
omitted_chars = [str.bytesize - (head_str.bytesize + tail_str.bytesize), 0].max
|
|
107
122
|
notice = "\n\n... [Truncated: #{omitted_lines} lines / #{omitted_chars} chars omitted to preserve token budget] ...\n\n"
|
|
108
123
|
|
|
109
|
-
|
|
110
|
-
tail_final = tail_str.valid_encoding? ? tail_str : tail_str.scrub("")
|
|
111
|
-
{ text: "#{head_final}#{notice}#{tail_final}", truncated: true,
|
|
124
|
+
{ text: "#{head_str}#{notice}#{tail_str}", truncated: true,
|
|
112
125
|
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
113
126
|
end
|
|
114
127
|
end
|
data/lib/rubik/version.rb
CHANGED