rubik 0.3.5 → 0.4.1
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/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +99 -3
- data/lib/rubik/ast/c_extractor.rb +1 -1
- data/lib/rubik/ast/fallback_cleaner.rb +3 -1
- 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/ast/rust_extractor.rb +1 -1
- data/lib/rubik/ast/typescript_extractor.rb +3 -3
- data/lib/rubik/diagnostics/parser.rb +65 -13
- data/lib/rubik/llm/openai_compatible.rb +2 -1
- data/lib/rubik/llm/registry.rb +81 -74
- data/lib/rubik/lsp/manager.rb +15 -5
- data/lib/rubik/orchestrator/step_executor.rb +139 -155
- data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
- data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
- data/lib/rubik/orchestrator/turn_executor.rb +3 -2
- data/lib/rubik/platform/path.rb +2 -1
- data/lib/rubik/platform/sanitize.rb +4 -1
- data/lib/rubik/prompt/token_estimator.rb +16 -12
- data/lib/rubik/repl/step_formatter.rb +2 -1
- data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
- data/lib/rubik/security/blocked_guidance.rb +1 -1
- data/lib/rubik/security/classifier/classifier.rb +3 -1
- data/lib/rubik/security/redactor.rb +3 -3
- data/lib/rubik/storage/layout.rb +3 -1
- data/lib/rubik/storage/render.rb +1 -1
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +5 -2
- data/lib/rubik/tools/base.rb +2 -1
- data/lib/rubik/tools/truncator.rb +21 -8
- data/lib/rubik/ui/renderer.rb +1 -0
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
data/lib/rubik/lsp/manager.rb
CHANGED
|
@@ -200,18 +200,16 @@ module Rubik
|
|
|
200
200
|
|
|
201
201
|
client.stop
|
|
202
202
|
@clients.delete(lang_str)
|
|
203
|
-
|
|
204
|
-
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
203
|
+
unregister_client_unlocked(lang_str)
|
|
205
204
|
{ ok: true, message: "Stopped LSP server for '#{lang_str}'" }
|
|
206
205
|
end
|
|
207
206
|
end
|
|
208
207
|
|
|
209
208
|
def stop_all
|
|
210
209
|
@monitor.synchronize do
|
|
211
|
-
effective_ctx = @context || @runtime_manager&.context
|
|
212
210
|
@clients.each do |lang_str, client|
|
|
213
211
|
client.stop
|
|
214
|
-
|
|
212
|
+
unregister_client_unlocked(lang_str)
|
|
215
213
|
end
|
|
216
214
|
@clients.clear
|
|
217
215
|
end
|
|
@@ -387,7 +385,10 @@ module Rubik
|
|
|
387
385
|
recovering = true
|
|
388
386
|
record_crash(lang)
|
|
389
387
|
stop_client_safely(client)
|
|
390
|
-
|
|
388
|
+
if stored.equal?(client)
|
|
389
|
+
@clients.delete(lang)
|
|
390
|
+
unregister_client_unlocked(lang)
|
|
391
|
+
end
|
|
391
392
|
client = nil
|
|
392
393
|
end
|
|
393
394
|
|
|
@@ -418,6 +419,7 @@ module Rubik
|
|
|
418
419
|
else
|
|
419
420
|
record_crash(lang)
|
|
420
421
|
stop_client_safely(client)
|
|
422
|
+
unregister_client_unlocked(lang)
|
|
421
423
|
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
422
424
|
end
|
|
423
425
|
elsif recovering
|
|
@@ -439,6 +441,7 @@ module Rubik
|
|
|
439
441
|
else
|
|
440
442
|
record_crash(lang)
|
|
441
443
|
stop_client_safely(client)
|
|
444
|
+
unregister_client_unlocked(lang)
|
|
442
445
|
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
443
446
|
end
|
|
444
447
|
end
|
|
@@ -465,6 +468,13 @@ module Rubik
|
|
|
465
468
|
end
|
|
466
469
|
end
|
|
467
470
|
|
|
471
|
+
def unregister_client_unlocked(lang)
|
|
472
|
+
lang_str = lang.to_s.downcase
|
|
473
|
+
@registered_disposable_langs.delete(lang_str)
|
|
474
|
+
effective_ctx = @context || @runtime_manager&.context
|
|
475
|
+
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
476
|
+
end
|
|
477
|
+
|
|
468
478
|
def stop_client_safely(client)
|
|
469
479
|
client.stop
|
|
470
480
|
rescue StandardError
|
|
@@ -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
|
|
@@ -328,7 +312,7 @@ module Rubik
|
|
|
328
312
|
end
|
|
329
313
|
|
|
330
314
|
# Strip ```json ... ``` blocks
|
|
331
|
-
str.gsub(/```(?:json)?\s*\{
|
|
315
|
+
str.gsub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
|
|
332
316
|
end
|
|
333
317
|
end
|
|
334
318
|
end
|
|
@@ -68,6 +68,7 @@ module Rubik
|
|
|
68
68
|
@instances = {}
|
|
69
69
|
@deferred = {}
|
|
70
70
|
@monitor = Monitor.new
|
|
71
|
+
@wait_cond = @monitor.new_cond
|
|
71
72
|
@counter = 0
|
|
72
73
|
@max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
|
|
73
74
|
@max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
|
|
@@ -83,6 +84,13 @@ module Rubik
|
|
|
83
84
|
# redacted, paginated — see ResultArchive.
|
|
84
85
|
@result_archive = ResultArchive.new(config: @config)
|
|
85
86
|
|
|
87
|
+
if @event_bus.respond_to?(:subscribe)
|
|
88
|
+
@event_bus.subscribe(Notices::ERROR_PAUSED_EVENT) { notify_wait! }
|
|
89
|
+
@event_bus.subscribe(Notices::BUDGET_EXHAUSTED_EVENT) { notify_wait! }
|
|
90
|
+
@event_bus.subscribe(Notices::COMPLETED_EVENT) { notify_wait! }
|
|
91
|
+
@event_bus.subscribe(Notices::QUESTION_EVENT) { notify_wait! } if defined?(Notices::QUESTION_EVENT)
|
|
92
|
+
end
|
|
93
|
+
|
|
86
94
|
# Register inverse in root context (maestro hook)
|
|
87
95
|
@root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
|
|
88
96
|
end
|
|
@@ -93,20 +101,26 @@ module Rubik
|
|
|
93
101
|
inst = get(id)
|
|
94
102
|
return false unless inst.is_a?(Instance)
|
|
95
103
|
|
|
96
|
-
inst.extend_budget(steps)
|
|
104
|
+
res = inst.extend_budget(steps)
|
|
105
|
+
notify_wait! if res
|
|
106
|
+
res
|
|
97
107
|
end
|
|
98
108
|
|
|
99
109
|
def resume(id, additional_steps: nil)
|
|
100
110
|
inst = get(id)
|
|
101
|
-
inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
|
|
111
|
+
res = inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
|
|
112
|
+
notify_wait! if res
|
|
113
|
+
res
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
def resume_all
|
|
105
|
-
@monitor.synchronize do
|
|
117
|
+
resumed = @monitor.synchronize do
|
|
106
118
|
@instances.values.select(&:parked?).map do |i|
|
|
107
119
|
i.resume! ? i.id : nil
|
|
108
120
|
end.compact
|
|
109
121
|
end
|
|
122
|
+
notify_wait! unless resumed.empty?
|
|
123
|
+
resumed
|
|
110
124
|
end
|
|
111
125
|
|
|
112
126
|
def list(parent_id: nil)
|
|
@@ -129,6 +143,7 @@ module Rubik
|
|
|
129
143
|
# monitörü 2s'ye kadar bloklar (emit kuralı ihlali olurdu).
|
|
130
144
|
announce_cancellations(cancelled)
|
|
131
145
|
release_ready!
|
|
146
|
+
notify_wait!
|
|
132
147
|
found
|
|
133
148
|
end
|
|
134
149
|
|
|
@@ -142,6 +157,7 @@ module Rubik
|
|
|
142
157
|
true
|
|
143
158
|
end
|
|
144
159
|
announce_cancellations(cancelled)
|
|
160
|
+
notify_wait!
|
|
145
161
|
end
|
|
146
162
|
|
|
147
163
|
def shutdown_all
|
|
@@ -151,6 +167,7 @@ module Rubik
|
|
|
151
167
|
@instances.clear
|
|
152
168
|
@deferred.clear
|
|
153
169
|
end
|
|
170
|
+
notify_wait!
|
|
154
171
|
end
|
|
155
172
|
|
|
156
173
|
def reap_orphans
|
|
@@ -20,25 +20,32 @@ module Rubik
|
|
|
20
20
|
wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def notify_wait!
|
|
24
|
+
@monitor.synchronize { @wait_cond&.broadcast }
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
|
|
24
28
|
target_ids = Array(ids).compact.map(&:to_s)
|
|
25
29
|
deadline = Time.now + [timeout.to_i, 1].max
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
@monitor.synchronize do
|
|
32
|
+
loop do
|
|
29
33
|
candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
|
|
30
|
-
candidates.select do |inst|
|
|
34
|
+
active_targets = candidates.select do |inst|
|
|
31
35
|
inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
|
|
32
36
|
end
|
|
33
|
-
end
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
# Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
|
|
39
|
+
rem = deadline - Time.now
|
|
40
|
+
break if (active_targets.empty? && !deferred_pending?(target_ids)) || rem <= 0
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
if @wait_cond
|
|
43
|
+
@wait_cond.wait([rem, 0].max)
|
|
44
|
+
else
|
|
45
|
+
sleep [rem, 0.1].min
|
|
46
|
+
end
|
|
47
|
+
end
|
|
40
48
|
|
|
41
|
-
@monitor.synchronize do
|
|
42
49
|
candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
|
|
43
50
|
paused = candidates.select { |c| parked_candidate?(c) }
|
|
44
51
|
active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
|
|
@@ -107,8 +107,8 @@ module Rubik
|
|
|
107
107
|
max_steps: @deps.max_steps, layer: layer)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def announce_llm_thought(text)
|
|
111
|
-
@deps.on_step&.call(type: :llm_thought, content: text, step: @deps.step_count, max_steps: @deps.max_steps)
|
|
110
|
+
def announce_llm_thought(text, streamed: false)
|
|
111
|
+
@deps.on_step&.call(type: :llm_thought, content: text, streamed: streamed, step: @deps.step_count, max_steps: @deps.max_steps)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
# ── terminals & dispatch ──
|
|
@@ -218,7 +218,8 @@ module Rubik
|
|
|
218
218
|
def tool_turn(res, text_resp, tool_calls)
|
|
219
219
|
channel.nudge_policy.reset!
|
|
220
220
|
channel.append_tool_call_row(channel.final_text(res, text_resp), tool_calls)
|
|
221
|
-
|
|
221
|
+
streamed = res.respond_to?(:streamed) && res.streamed == true
|
|
222
|
+
channel.announce_llm_thought(text_resp, streamed: streamed) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
|
|
222
223
|
|
|
223
224
|
tool_calls.each do |tc|
|
|
224
225
|
# finish is exempt (pinned by test_orchestrator_stream): its args
|
|
@@ -300,7 +301,7 @@ module Rubik
|
|
|
300
301
|
# ── announce ──
|
|
301
302
|
def announce_compaction(_reason, _layer) = nil
|
|
302
303
|
|
|
303
|
-
def announce_llm_thought(_text) = nil
|
|
304
|
+
def announce_llm_thought(_text, **) = nil
|
|
304
305
|
|
|
305
306
|
# Goal-ledger observation projection (plan D): related-optional
|
|
306
307
|
# capability — base channel is structurally inert (subagent planes
|
data/lib/rubik/platform/path.rb
CHANGED
|
@@ -228,7 +228,8 @@ module Rubik
|
|
|
228
228
|
# drive root?" depend on the process cwd (which drive the workspace
|
|
229
229
|
# lives on). Security callers pass already-expanded absolute paths;
|
|
230
230
|
# compare the literal form here.
|
|
231
|
-
cleaned = path.to_s.tr("\\", "/").downcase
|
|
231
|
+
cleaned = path.to_s.tr("\\", "/").downcase
|
|
232
|
+
cleaned = cleaned.delete_suffix("/") while cleaned.end_with?("/")
|
|
232
233
|
cleaned =~ /^[a-z]:$/
|
|
233
234
|
end
|
|
234
235
|
end
|
|
@@ -108,14 +108,17 @@ module Rubik
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
VALID_SYMBOL_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
|
|
112
|
+
|
|
111
113
|
# Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
|
|
112
114
|
# bare validity check would fast-path them through UNCLEANED — the old
|
|
113
115
|
# clean_utf8 path strips their >= 0x80 bytes and the byte loss would
|
|
114
116
|
# instead surface later, at the JSON/display layer. Only UTF-8 (or the
|
|
115
117
|
# lossless US-ASCII subset) symbols may take the fast path.
|
|
118
|
+
# PERF: Hoist encoding array to frozen constant to eliminate array allocations per symbol check (~26% faster, ~99.99% fewer allocations).
|
|
116
119
|
def fast_clean_symbol(sym)
|
|
117
120
|
name = sym.name
|
|
118
|
-
return sym if
|
|
121
|
+
return sym if VALID_SYMBOL_ENCODINGS.include?(name.encoding) && name.valid_encoding?
|
|
119
122
|
|
|
120
123
|
clean_utf8(name).to_sym
|
|
121
124
|
end
|
|
@@ -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
|