rubik 0.3.2 → 0.3.4
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 +77 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +19 -26
- data/lib/rubik/config/store.rb +1 -1
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/subagent/budget.rb +17 -10
- data/lib/rubik/orchestrator/subagent/instance.rb +14 -2
- data/lib/rubik/orchestrator/subagent/journal.rb +56 -13
- data/lib/rubik/orchestrator/subagent/reuse.rb +3 -3
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
- data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
- data/lib/rubik/platform/process.rb +7 -7
- data/lib/rubik/platform/sanitize.rb +41 -20
- data/lib/rubik/platform.rb +2 -2
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_execution.rb +100 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/subagents.rb +12 -11
- data/lib/rubik/repl/commands/workspace.rb +156 -152
- data/lib/rubik/repl/interrupt_handler.rb +10 -10
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/tools/builtins/subagent.rb +7 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
|
@@ -52,6 +52,7 @@ module Rubik
|
|
|
52
52
|
# Budget delegation: step counters live in Subagent::Budget (reader contract preserved).
|
|
53
53
|
def current_step = @budget.current_step
|
|
54
54
|
def max_steps = @budget.max_steps
|
|
55
|
+
def max_total_steps = @budget.max_total_steps
|
|
55
56
|
|
|
56
57
|
def initialize(id:, parent_id:, role:, type_name:, prompt:, context:, tool_registry:,
|
|
57
58
|
system_prompt: nil, workspace: "inherit", model: "inherit",
|
|
@@ -72,7 +73,15 @@ module Rubik
|
|
|
72
73
|
@tool_registry = tool_registry
|
|
73
74
|
@provider = provider
|
|
74
75
|
@buffer_manager = buffer_manager
|
|
75
|
-
|
|
76
|
+
max_total_steps = if config.is_a?(Hash)
|
|
77
|
+
config["subagents.max_total_steps"] || config.dig("subagents", "max_total_steps") ||
|
|
78
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
79
|
+
elsif config.respond_to?(:fetch)
|
|
80
|
+
config.fetch("subagents.max_total_steps", Budget::DEFAULT_MAX_TOTAL_STEPS)
|
|
81
|
+
else
|
|
82
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
83
|
+
end
|
|
84
|
+
@budget = Budget.new(max_steps: max_steps, max_total_steps: max_total_steps)
|
|
76
85
|
@root_dir = root_dir
|
|
77
86
|
@config = config
|
|
78
87
|
@event_bus = event_bus
|
|
@@ -189,7 +198,8 @@ module Rubik
|
|
|
189
198
|
def to_h
|
|
190
199
|
@monitor.synchronize do
|
|
191
200
|
{ id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
|
|
192
|
-
**@budget.to_h,
|
|
201
|
+
**@budget.to_h, max_total_steps: @budget.max_total_steps,
|
|
202
|
+
grants_received: @grants_received, run: @run_seq,
|
|
193
203
|
result_ref: result_ref, owner: @owner_task&.id, detached: @owner_task.nil?, uptime_seconds: uptime.round(2),
|
|
194
204
|
operation: operations.current, timings: operations.timings,
|
|
195
205
|
last_progress_seconds_ago: operations.last_progress_seconds_ago,
|
|
@@ -230,6 +240,8 @@ module Rubik
|
|
|
230
240
|
return false if terminal?
|
|
231
241
|
|
|
232
242
|
extra, total = @budget.grant!(steps)
|
|
243
|
+
return false if extra <= 0
|
|
244
|
+
|
|
233
245
|
transition!(:running)
|
|
234
246
|
@grants_received += 1
|
|
235
247
|
@pause_facts = nil # the wake retires the wait fact with the pause (B3's clearer, now fact-only)
|
|
@@ -22,9 +22,9 @@ module Rubik
|
|
|
22
22
|
#
|
|
23
23
|
# Retention is bounded independently of the model context: at most
|
|
24
24
|
# `subagents.journal.max_bytes` per execution file. Once the cap is
|
|
25
|
-
# reached a
|
|
26
|
-
#
|
|
27
|
-
#
|
|
25
|
+
# reached a `journal_truncated` record marks the boundary; detail events
|
|
26
|
+
# stop, while reserved space preserves the terminal run/cancellation
|
|
27
|
+
# outcome. Tool payloads are previewed
|
|
28
28
|
# (PREVIEW_CHARS) with explicit `output_chars` / `output_truncated`
|
|
29
29
|
# metadata plus an `artifact_ref` to the conversation snapshot, so replay
|
|
30
30
|
# can distinguish originals from summaries from clipped output.
|
|
@@ -37,10 +37,12 @@ module Rubik
|
|
|
37
37
|
# in Instance#to_h), never raised into the run.
|
|
38
38
|
class Journal
|
|
39
39
|
FILENAME = "journal.jsonl"
|
|
40
|
-
DEFAULT_MAX_BYTES =
|
|
40
|
+
DEFAULT_MAX_BYTES = 8_388_608
|
|
41
41
|
# Headroom kept free for the final truncation marker: accepted records
|
|
42
42
|
# never consume the last bytes, so the marker always fits inside the cap.
|
|
43
43
|
MARKER_RESERVE = 512
|
|
44
|
+
TERMINAL_RESERVE = 16_384
|
|
45
|
+
TERMINAL_EVENTS = ["run_finished", "cancelled"].freeze
|
|
44
46
|
MIN_MAX_BYTES = 1024
|
|
45
47
|
PREVIEW_CHARS = 4000
|
|
46
48
|
ARGS_CHARS = 2000
|
|
@@ -54,9 +56,12 @@ module Rubik
|
|
|
54
56
|
@path = path.to_s
|
|
55
57
|
@monitor = Monitor.new
|
|
56
58
|
@max_bytes = [config_get(config, "subagents.journal.max_bytes", DEFAULT_MAX_BYTES).to_i, MIN_MAX_BYTES].max
|
|
57
|
-
|
|
59
|
+
existing_records = self.class.read(@path).records
|
|
60
|
+
@seq = existing_records.map { |record| record["seq"].to_i }.max || 0
|
|
58
61
|
@bytes = File.exist?(@path) ? File.size(@path) : 0
|
|
59
|
-
@
|
|
62
|
+
@marker_reserve = [MARKER_RESERVE, @max_bytes / 4].min
|
|
63
|
+
@terminal_reserve = [TERMINAL_RESERVE, @max_bytes / 3].min
|
|
64
|
+
@closed = existing_records.any? { |record| record["event"] == "journal_truncated" }
|
|
60
65
|
@last_error = nil
|
|
61
66
|
@run_metrics = {}
|
|
62
67
|
end
|
|
@@ -65,16 +70,24 @@ module Rubik
|
|
|
65
70
|
# record envelope; the payload is masked HERE (single redaction gate).
|
|
66
71
|
def append(event, run: 1, data: {}, call_id: nil)
|
|
67
72
|
@monitor.synchronize do
|
|
68
|
-
|
|
73
|
+
event_name = event.to_s
|
|
74
|
+
terminal = TERMINAL_EVENTS.include?(event_name)
|
|
75
|
+
return nil if @closed && !terminal
|
|
69
76
|
|
|
70
77
|
@last_run = run.to_i
|
|
71
78
|
row = { "seq" => @seq + 1, "run" => @last_run, "ts" => Time.now.utc.iso8601(3),
|
|
72
|
-
"event" =>
|
|
79
|
+
"event" => event_name, "data" => Rubik::Security::Redactor.mask_value(data) }
|
|
73
80
|
row["call_id"] = call_id.to_s if call_id
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
|
|
82
|
+
return append_terminal!(row) if @closed
|
|
83
|
+
|
|
84
|
+
line = serialize(row)
|
|
85
|
+
if @bytes + line.bytesize + @marker_reserve + @terminal_reserve > @max_bytes
|
|
76
86
|
mark_truncated
|
|
77
|
-
return nil
|
|
87
|
+
return nil unless terminal
|
|
88
|
+
|
|
89
|
+
row["seq"] = @seq + 1
|
|
90
|
+
return append_terminal!(row)
|
|
78
91
|
end
|
|
79
92
|
return nil unless write!(line)
|
|
80
93
|
|
|
@@ -199,6 +212,35 @@ module Rubik
|
|
|
199
212
|
|
|
200
213
|
private
|
|
201
214
|
|
|
215
|
+
def serialize(row) = "#{Rubik::Platform.safe_json_generate(row)}\n"
|
|
216
|
+
|
|
217
|
+
def append_terminal!(row)
|
|
218
|
+
line = serialize(row)
|
|
219
|
+
if @bytes + line.bytesize > @max_bytes
|
|
220
|
+
row["data"] = compact_terminal_data(row["data"])
|
|
221
|
+
line = serialize(row)
|
|
222
|
+
end
|
|
223
|
+
return nil if @bytes + line.bytesize > @max_bytes || !write!(line)
|
|
224
|
+
|
|
225
|
+
@seq += 1
|
|
226
|
+
@bytes += line.bytesize
|
|
227
|
+
row
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def compact_terminal_data(data)
|
|
231
|
+
return {} unless data.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
summary = data.slice("state", "outcome", "duration_ms", "step_count")
|
|
234
|
+
["state", "outcome"].each do |key|
|
|
235
|
+
summary[key] = summary[key].to_s.slice(0, 64) if summary.key?(key)
|
|
236
|
+
end
|
|
237
|
+
summary["reason"] = data["reason"].to_s.slice(0, 256) if data["reason"]
|
|
238
|
+
result_ref = data["result_ref"]
|
|
239
|
+
summary["result_ref"] = result_ref.to_s.slice(0, 128) if result_ref
|
|
240
|
+
summary["terminal_summary_truncated"] = true
|
|
241
|
+
summary
|
|
242
|
+
end
|
|
243
|
+
|
|
202
244
|
def empty_run_metrics
|
|
203
245
|
{ tool_usage: {}, model_responses: 0, input_tokens: 0, output_tokens: 0,
|
|
204
246
|
input_token_responses: 0, output_token_responses: 0 }
|
|
@@ -240,8 +282,9 @@ module Rubik
|
|
|
240
282
|
@closed = true
|
|
241
283
|
marker = { "seq" => @seq + 1, "run" => (@last_run || 1).to_i, "ts" => Time.now.utc.iso8601(3),
|
|
242
284
|
"event" => "journal_truncated",
|
|
243
|
-
"data" => { "max_bytes" => @max_bytes,
|
|
244
|
-
|
|
285
|
+
"data" => { "max_bytes" => @max_bytes,
|
|
286
|
+
"reason" => "retention cap reached; detail events stop, terminal outcomes use reserved space" } }
|
|
287
|
+
line = serialize(marker)
|
|
245
288
|
return nil if @bytes + line.bytesize > @max_bytes || !write!(line)
|
|
246
289
|
|
|
247
290
|
@seq += 1
|
|
@@ -59,7 +59,7 @@ module Rubik
|
|
|
59
59
|
drain_inbox_into_history
|
|
60
60
|
@history << { role: "user", content: "[New task from maestro]: #{prompt}" }
|
|
61
61
|
@prompt = prompt.to_s
|
|
62
|
-
@budget = Budget.new(max_steps: max_steps.to_i)
|
|
62
|
+
@budget = Budget.new(max_steps: max_steps.to_i, max_total_steps: @budget.max_total_steps)
|
|
63
63
|
# @result reset is the ONE sanctioned clearer (terminal-only
|
|
64
64
|
# contract): a new lineage begins. @pause_facts rides along —
|
|
65
65
|
# the old run's wait fact must not narrate the new one.
|
|
@@ -114,8 +114,8 @@ module Rubik
|
|
|
114
114
|
@instances[inst.id] = inst
|
|
115
115
|
activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
|
|
116
116
|
end
|
|
117
|
-
# SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
|
|
118
|
-
# flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
|
|
117
|
+
# Start-to-Start (SS) bırakma kancası: redeploy da bir başlangıçtır — taze :running
|
|
118
|
+
# flip'i, bu id'yi Start-to-Start (SS) ile bekleyen kuyruk spesifikasyonlarını
|
|
119
119
|
# bırakır (kilit DIŞINDA — emit kuralı; spawn ile aynı düzen).
|
|
120
120
|
release_ready!
|
|
121
121
|
redeployed
|
|
@@ -69,11 +69,14 @@ module Rubik
|
|
|
69
69
|
def build_effective_system_prompt
|
|
70
70
|
base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
|
|
71
71
|
"Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
|
|
72
|
-
"Allocated Step Budget: #{@budget.max_steps} steps
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
72
|
+
"Allocated Step Budget: #{@budget.max_steps} steps; lifetime ceiling: " \
|
|
73
|
+
"#{@budget.max_total_steps} steps across all grants.\n\n" \
|
|
74
|
+
"A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). " \
|
|
75
|
+
"Pace yourself: when remaining steps (Y - X) run low, conclude the task or prepare a progress " \
|
|
76
|
+
"summary for the maestro supervisor — it may extend your budget up to the lifetime ceiling or " \
|
|
77
|
+
"steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
|
|
78
|
+
"ask_question tool — it blocks until the maestro answers or times out; ask only when the answer " \
|
|
79
|
+
"truly changes your work.\n\n" \
|
|
77
80
|
"Perform your assigned task with high precision and provide a structured synthesis report."
|
|
78
81
|
@system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
|
|
79
82
|
end
|
|
@@ -38,6 +38,7 @@ module Rubik
|
|
|
38
38
|
class Supervisor
|
|
39
39
|
attr_accessor :tool_registry, :router, :init
|
|
40
40
|
attr_reader :root_context, :type_registry, :instances, :max_depth, :max_concurrency, :default_max_steps,
|
|
41
|
+
:max_total_steps,
|
|
41
42
|
:provider_gate, :result_archive
|
|
42
43
|
|
|
43
44
|
include RedeploySupervisor
|
|
@@ -70,7 +71,12 @@ module Rubik
|
|
|
70
71
|
@counter = 0
|
|
71
72
|
@max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
|
|
72
73
|
@max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
|
|
73
|
-
|
|
74
|
+
configured_max_total_steps = config&.fetch("subagents.max_total_steps", Budget::DEFAULT_MAX_TOTAL_STEPS) ||
|
|
75
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
76
|
+
@max_total_steps = [configured_max_total_steps.to_i, 1].max
|
|
77
|
+
configured_default_steps = config&.fetch("subagents.default_max_steps", Budget::DEFAULT_MAX_STEPS) ||
|
|
78
|
+
Budget::DEFAULT_MAX_STEPS
|
|
79
|
+
@default_max_steps = [configured_default_steps.to_i, @max_total_steps].min
|
|
74
80
|
@provider_concurrency = (config&.fetch("subagents.provider_concurrency", 2) || 2).to_i
|
|
75
81
|
@provider_gate = ConcurrencyGate.new(@provider_concurrency)
|
|
76
82
|
# Retained completed-run results (issue #122): session-scoped,
|
|
@@ -92,7 +92,7 @@ module Rubik
|
|
|
92
92
|
# Plan 55 D-1 ile tutarlı dizi-form çağrı: cmd.exe ara katmanı ve kabuk
|
|
93
93
|
# yönlendirmesi ("> NUL 2>&1") yerine out:/err: seçenekleri kullanılır.
|
|
94
94
|
system("taskkill", "/F", "/T", "/PID", pid.to_s, out: File::NULL, err: File::NULL)
|
|
95
|
-
rescue
|
|
95
|
+
rescue StandardError
|
|
96
96
|
nil
|
|
97
97
|
end
|
|
98
98
|
else
|
|
@@ -162,17 +162,17 @@ module Rubik
|
|
|
162
162
|
return pids.reject(&:zero?) unless pids.empty?
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
-
out = begin
|
|
166
|
-
|
|
165
|
+
out, = begin
|
|
166
|
+
Open3.capture2("pgrep", "-P", pid.to_s, err: File::NULL)
|
|
167
167
|
rescue StandardError
|
|
168
|
-
nil
|
|
168
|
+
[nil, nil]
|
|
169
169
|
end
|
|
170
170
|
return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
|
|
171
171
|
|
|
172
|
-
out = begin
|
|
173
|
-
|
|
172
|
+
out, = begin
|
|
173
|
+
Open3.capture2("ps", "--ppid", pid.to_s, "-o", "pid=", err: File::NULL)
|
|
174
174
|
rescue StandardError
|
|
175
|
-
nil
|
|
175
|
+
[nil, nil]
|
|
176
176
|
end
|
|
177
177
|
return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
|
|
178
178
|
|
|
@@ -39,7 +39,7 @@ module Rubik
|
|
|
39
39
|
debug_warn("clean_utf8 scrub fallback: #{e.class}: #{e.message}")
|
|
40
40
|
begin
|
|
41
41
|
str.to_s.scrub("")
|
|
42
|
-
rescue
|
|
42
|
+
rescue StandardError
|
|
43
43
|
""
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -59,46 +59,67 @@ module Rubik
|
|
|
59
59
|
retagged.valid_encoding? ? retagged : retagged.scrub("")
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
#
|
|
62
|
+
# PERF: Fast-path deep UTF-8 cleaner. Performs zero-allocation Symbol
|
|
63
|
+
# validation via Symbol#name and tracks cycle object IDs in a Hash to
|
|
64
|
+
# eliminate Set instance allocations (~3.3x speedup, ~70% faster).
|
|
63
65
|
def clean_utf8_deep(obj, seen = nil)
|
|
64
66
|
case obj
|
|
65
67
|
when String
|
|
66
68
|
clean_utf8(obj)
|
|
69
|
+
when Symbol
|
|
70
|
+
fast_clean_symbol(obj)
|
|
67
71
|
when Hash
|
|
68
|
-
seen ||=
|
|
69
|
-
|
|
72
|
+
seen ||= {}
|
|
73
|
+
obj_id = obj.object_id
|
|
74
|
+
return "{...}" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
70
75
|
|
|
71
|
-
seen.add(
|
|
76
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
res = {}
|
|
79
|
+
obj.each do |k, v|
|
|
80
|
+
cleaned_k = if k.is_a?(Symbol)
|
|
81
|
+
fast_clean_symbol(k)
|
|
82
|
+
elsif k.is_a?(String)
|
|
75
83
|
clean_utf8(k)
|
|
76
84
|
else
|
|
77
|
-
|
|
85
|
+
clean_utf8_deep(k, seen)
|
|
78
86
|
end
|
|
79
|
-
|
|
87
|
+
res[cleaned_k] = clean_utf8_deep(v, seen)
|
|
80
88
|
end
|
|
89
|
+
res
|
|
81
90
|
when Array
|
|
82
|
-
seen ||=
|
|
83
|
-
|
|
91
|
+
seen ||= {}
|
|
92
|
+
obj_id = obj.object_id
|
|
93
|
+
return "[...]" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
84
94
|
|
|
85
|
-
seen.add(
|
|
95
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
86
96
|
|
|
87
97
|
obj.map { |item| clean_utf8_deep(item, seen) }
|
|
88
98
|
when Set
|
|
89
|
-
seen ||=
|
|
90
|
-
|
|
99
|
+
seen ||= {}
|
|
100
|
+
obj_id = obj.object_id
|
|
101
|
+
return Set.new(["[...]"]) if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
91
102
|
|
|
92
|
-
seen.add(
|
|
103
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
93
104
|
|
|
94
105
|
obj.to_set { |item| clean_utf8_deep(item, seen) }
|
|
95
|
-
when Symbol
|
|
96
|
-
clean_utf8(obj.to_s).to_sym
|
|
97
106
|
else
|
|
98
107
|
obj
|
|
99
108
|
end
|
|
100
109
|
end
|
|
101
110
|
|
|
111
|
+
# Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
|
|
112
|
+
# bare validity check would fast-path them through UNCLEANED — the old
|
|
113
|
+
# clean_utf8 path strips their >= 0x80 bytes and the byte loss would
|
|
114
|
+
# instead surface later, at the JSON/display layer. Only UTF-8 (or the
|
|
115
|
+
# lossless US-ASCII subset) symbols may take the fast path.
|
|
116
|
+
def fast_clean_symbol(sym)
|
|
117
|
+
name = sym.name
|
|
118
|
+
return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
|
|
119
|
+
|
|
120
|
+
clean_utf8(name).to_sym
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
# Generates JSON safely without raising JSON::GeneratorError on malformed UTF-8
|
|
103
124
|
def safe_json_generate(obj, *)
|
|
104
125
|
cleaned = clean_utf8_deep(obj)
|
|
@@ -108,7 +129,7 @@ module Rubik
|
|
|
108
129
|
fallback = sanitize_for_json_fallback(obj)
|
|
109
130
|
begin
|
|
110
131
|
JSON.generate(fallback, *)
|
|
111
|
-
rescue
|
|
132
|
+
rescue StandardError
|
|
112
133
|
"{}"
|
|
113
134
|
end
|
|
114
135
|
end
|
|
@@ -122,7 +143,7 @@ module Rubik
|
|
|
122
143
|
fallback = sanitize_for_json_fallback(obj)
|
|
123
144
|
begin
|
|
124
145
|
JSON.dump(fallback, *)
|
|
125
|
-
rescue
|
|
146
|
+
rescue StandardError
|
|
126
147
|
"{}"
|
|
127
148
|
end
|
|
128
149
|
end
|
|
@@ -148,7 +169,7 @@ module Rubik
|
|
|
148
169
|
debug_warn("sanitize_for_json_fallback rescue: #{e.class}: #{e.message}")
|
|
149
170
|
begin
|
|
150
171
|
obj.to_s.scrub("")
|
|
151
|
-
rescue
|
|
172
|
+
rescue StandardError
|
|
152
173
|
""
|
|
153
174
|
end
|
|
154
175
|
end
|
data/lib/rubik/platform.rb
CHANGED
|
@@ -87,7 +87,7 @@ module Rubik
|
|
|
87
87
|
|
|
88
88
|
def home_dir
|
|
89
89
|
Dir.home
|
|
90
|
-
rescue
|
|
90
|
+
rescue StandardError
|
|
91
91
|
(ENV["USERPROFILE"] || (ENV.fetch("HOMEDRIVE",
|
|
92
92
|
nil) && ENV.fetch("HOMEPATH",
|
|
93
93
|
nil) && "#{ENV.fetch('HOMEDRIVE',
|
|
@@ -96,7 +96,7 @@ module Rubik
|
|
|
96
96
|
|
|
97
97
|
def tmp_dir
|
|
98
98
|
Dir.tmpdir
|
|
99
|
-
rescue
|
|
99
|
+
rescue StandardError
|
|
100
100
|
(ENV["TEMP"] || ENV["TMP"] || "/tmp")
|
|
101
101
|
end
|
|
102
102
|
end
|
|
@@ -18,6 +18,10 @@ module Rubik
|
|
|
18
18
|
# ToolTeachings/Notices. Byte-identical rendering is pinned by
|
|
19
19
|
# test_prompt_golden_output — reword here = fixture diff there, never
|
|
20
20
|
# a context_builder.rb diff.
|
|
21
|
+
#
|
|
22
|
+
# Security Note: All strings and heredocs in this module contain pure
|
|
23
|
+
# prompt template prose for LLM system context. Backticks (`...`) in prose
|
|
24
|
+
# are markdown inline-code tags and are never evaluated as shell execution.
|
|
21
25
|
module Doctrine
|
|
22
26
|
module_function
|
|
23
27
|
|
|
@@ -80,107 +80,120 @@ module Rubik
|
|
|
80
80
|
next resume_suspended_goal(ctx) if args.length == 1 && sub == "resume"
|
|
81
81
|
next list_suspended_goals(ctx) if args.length == 1 && sub == "list"
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
filtered_args = []
|
|
86
|
-
i = 0
|
|
87
|
-
while i < args.length
|
|
88
|
-
arg = args[i]
|
|
89
|
-
if (m = arg.match(/\A--steps=(\d+)\z/))
|
|
90
|
-
custom_steps = m[1].to_i
|
|
91
|
-
elsif arg.start_with?("--steps=")
|
|
92
|
-
# --steps=X ama X sayı değil: flag metne sızmasın.
|
|
93
|
-
bad_steps = true
|
|
94
|
-
elsif ["--steps", "-s"].include?(arg)
|
|
95
|
-
if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
|
|
96
|
-
custom_steps = args[i + 1].to_i
|
|
97
|
-
i += 1
|
|
98
|
-
else
|
|
99
|
-
# Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
|
|
100
|
-
# yanlış koşu yerine).
|
|
101
|
-
bad_steps = true
|
|
102
|
-
end
|
|
103
|
-
else
|
|
104
|
-
filtered_args << arg
|
|
105
|
-
end
|
|
106
|
-
i += 1
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
if bad_steps || (custom_steps && custom_steps < 1)
|
|
83
|
+
parsed = parse_goal_args(args)
|
|
84
|
+
if !parsed[:valid]
|
|
110
85
|
ctx.out.puts USAGE_GOAL
|
|
111
86
|
ctx.out.puts "⚠️ [Rubik::Goal] --steps pozitif bir tam sayı bekliyor (örn. !goal --steps 200 <hedef>)"
|
|
112
87
|
next
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
goal_text = filtered_args.join(" ").strip
|
|
116
|
-
if goal_text.empty?
|
|
88
|
+
elsif parsed[:goal_text].empty?
|
|
117
89
|
ctx.out.puts USAGE_GOAL
|
|
118
90
|
next
|
|
119
91
|
end
|
|
120
92
|
|
|
121
|
-
ctx
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
125
|
-
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
93
|
+
execute_goal(ctx, parsed[:goal_text], parsed[:custom_steps])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
126
96
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
97
|
+
private_class_method def self.parse_goal_args(args)
|
|
98
|
+
custom_steps = nil
|
|
99
|
+
bad_steps = false
|
|
100
|
+
filtered_args = []
|
|
101
|
+
i = 0
|
|
102
|
+
while i < args.length
|
|
103
|
+
arg = args[i]
|
|
104
|
+
if (m = arg.match(/\A--steps=(\d+)\z/))
|
|
105
|
+
custom_steps = m[1].to_i
|
|
106
|
+
elsif arg.start_with?("--steps=")
|
|
107
|
+
# --steps=X ama X sayı değil: flag metne sızmasın.
|
|
108
|
+
bad_steps = true
|
|
109
|
+
elsif ["--steps", "-s"].include?(arg)
|
|
110
|
+
if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
|
|
111
|
+
custom_steps = args[i + 1].to_i
|
|
112
|
+
i += 1
|
|
113
|
+
else
|
|
114
|
+
# Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
|
|
115
|
+
# yanlış koşu yerine).
|
|
116
|
+
bad_steps = true
|
|
117
|
+
end
|
|
118
|
+
else
|
|
119
|
+
filtered_args << arg
|
|
150
120
|
end
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
|
|
154
|
-
goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
121
|
+
i += 1
|
|
122
|
+
end
|
|
155
123
|
|
|
156
|
-
|
|
157
|
-
|
|
124
|
+
goal_text = filtered_args.join(" ").strip
|
|
125
|
+
valid = !bad_steps && (custom_steps.nil? || custom_steps >= 1)
|
|
126
|
+
{ custom_steps: custom_steps, goal_text: goal_text, valid: valid }
|
|
127
|
+
end
|
|
158
128
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
model: routed[:model],
|
|
162
|
-
max_steps: goal_steps,
|
|
163
|
-
warn_at_ratio: warn_ratio,
|
|
164
|
-
context_limit: routed[:context_limit],
|
|
165
|
-
max_output_tokens: routed[:max_output_tokens],
|
|
166
|
-
on_step: build_goal_on_step(ctx, goal_state)
|
|
167
|
-
)
|
|
129
|
+
private_class_method def self.initialize_goal_ledger(ctx, goal_text)
|
|
130
|
+
return nil unless Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
|
|
168
131
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
132
|
+
store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
|
|
133
|
+
dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
|
|
134
|
+
goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
|
|
135
|
+
goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
|
|
136
|
+
event_bus: ctx.event_bus
|
|
137
|
+
)
|
|
138
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
|
|
139
|
+
goal_ledger
|
|
140
|
+
end
|
|
174
141
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
142
|
+
private_class_method def self.execute_goal(ctx, goal_text, custom_steps)
|
|
143
|
+
ctx.recorder&.record_user_turn("!goal #{goal_text}")
|
|
144
|
+
goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
|
|
145
|
+
warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
|
|
146
|
+
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
147
|
+
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
178
148
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
149
|
+
# Canlı hedef durumu: maestro ve alt ajanlar read_file
|
|
150
|
+
# rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
|
|
151
|
+
# semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
|
|
152
|
+
# yığmaz; session dispose'u tek delete-inverse ile temizler.
|
|
153
|
+
# event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
|
|
154
|
+
# kronikçisinin goal kancasına akar (K3) — notice/wake yok.
|
|
155
|
+
goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
|
|
156
|
+
goal_state.start!(goal_text, goal_steps)
|
|
157
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
|
|
158
|
+
|
|
159
|
+
# Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
|
|
160
|
+
# koşuyla doğar, terminal dallarda finalize olur, provide swap
|
|
161
|
+
# semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
|
|
162
|
+
# OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
|
|
163
|
+
goal_ledger = initialize_goal_ledger(ctx, goal_text)
|
|
164
|
+
|
|
165
|
+
# Askı kimliği (issue #106): koşu boyunca SABİT — registry
|
|
166
|
+
# girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
|
|
167
|
+
# (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
|
|
168
|
+
goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
169
|
+
|
|
170
|
+
ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
|
|
171
|
+
ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
|
|
172
|
+
|
|
173
|
+
orchestrator = ctx.orchestrator_loop(
|
|
174
|
+
provider: routed[:provider],
|
|
175
|
+
model: routed[:model],
|
|
176
|
+
max_steps: goal_steps,
|
|
177
|
+
warn_at_ratio: warn_ratio,
|
|
178
|
+
context_limit: routed[:context_limit],
|
|
179
|
+
max_output_tokens: routed[:max_output_tokens],
|
|
180
|
+
on_step: build_goal_on_step(ctx, goal_state)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
|
|
184
|
+
# Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
|
|
185
|
+
# restore için conversation_history VE record_assistant_response
|
|
186
|
+
# birlikte yazılmalı (başarısızlıkta da).
|
|
187
|
+
record_goal_pair = build_goal_record_pair(ctx, goal_text)
|
|
188
|
+
|
|
189
|
+
# Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
|
|
190
|
+
# aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
|
|
191
|
+
ctx.apply_staged_prune_plan!
|
|
192
|
+
|
|
193
|
+
drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
|
|
194
|
+
goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
|
|
195
|
+
record_pair: record_goal_pair,
|
|
196
|
+
start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
|
|
184
197
|
end
|
|
185
198
|
|
|
186
199
|
# ------------------------------------------------------------------
|