rubik 0.5.0 → 0.6.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 +78 -1
- data/lib/rubik/benchmark/runner.rb +14 -0
- data/lib/rubik/benchmark/vakanuvis_adoption.rb +439 -0
- data/lib/rubik/log.rb +4 -3
- data/lib/rubik/orchestrator/step_executor.rb +3 -3
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +31 -11
- data/lib/rubik/prompt/emergency_compactor.rb +7 -3
- data/lib/rubik/repl/commands/agent_execution.rb +7 -0
- data/lib/rubik/repl/input_execution.rb +5 -1
- data/lib/rubik/storage/vakanuvis/annal_writer.rb +51 -4
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +14 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +16 -13
- data/lib/rubik/storage/vakanuvis/harness.rb +40 -15
- data/lib/rubik/storage/vakanuvis/job.rb +25 -0
- data/lib/rubik/storage/vakanuvis.rb +12 -0
- data/lib/rubik/tools/truncator.rb +53 -0
- data/lib/rubik/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd6a93a2277eebd80ce20dbf33ceba1ba7354d6540a6906740b4d58274736847
|
|
4
|
+
data.tar.gz: 2f46b92bbf23cbcea71767c433ffd45dd55b821e6bb8f1b18cb8c2e7310c5be0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 258d3631c684bfdd719a3b341197dd02da8fc64789c30645b8df0cd4814fc7546392c371737582adab36ff4d46052828fe8d0fed536a1efb592b2c5dcca34571
|
|
7
|
+
data.tar.gz: d9a517f0f4b7026d666d3ac0e2f880805ce40d2341613e5cc75ed72b5b37df3eabf60f07cee9cdf2d8a491b3758b0871edc43b62e501b423bde817b814b1edaf
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,82 @@ 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
|
+
## [0.6.0] - 2026-11-02
|
|
10
|
+
|
|
11
|
+
A measurement-and-honesty minor: the planned vakanüvis fixes gain their
|
|
12
|
+
baseline with a tool-adoption bench scenario, goal suspensions finally
|
|
13
|
+
reach the chronicle through a typed digest contract, interleaved-goal
|
|
14
|
+
suspend rows stop mispairing their records, the annal dedupe can no
|
|
15
|
+
longer be poisoned by free-text `event=` markers, and a typo'd
|
|
16
|
+
truncation strategy warns instead of silently no-oping.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Goal suspension chronicling:** the chronicler now subscribes to
|
|
21
|
+
`:goal_suspended` (previously posted by `GoalState` but bus-orphaned —
|
|
22
|
+
the producer comment promised a consumption that never existed) and
|
|
23
|
+
appends a `goal.<n>.suspended` row to the session annal; the ordinal is
|
|
24
|
+
peeked rather than minted, so the eventual finish shares the same
|
|
25
|
+
`goal.<n>` record and no duplicate goal note is born from a suspension.
|
|
26
|
+
- **vakanüvis tool-adoption bench scenario (#338, #354):** a two-plane
|
|
27
|
+
measurement of whether models actually adopt recall/record — a
|
|
28
|
+
deterministic replay (default, runs in `rake bench` and CI) driving the
|
|
29
|
+
real tool plane through adopter/wrong-path/struggler profiles, and an
|
|
30
|
+
opt-in live plane (`rake bench:vakanuvis_adoption MODELS=...`) looping
|
|
31
|
+
real provider completions with both tools available; per-model metrics
|
|
32
|
+
cover recall adoption, Keywords usage, record-via-vakanüvis vs direct
|
|
33
|
+
storage put, answer grounding and turn-cap hits, with honest Skip
|
|
34
|
+
(quota) vs Error rows and a fresh seeded store per driver.
|
|
35
|
+
`:goal_suspended` (previously posted by `GoalState` but bus-orphaned —
|
|
36
|
+
the producer comment promised a consumption that never existed) and
|
|
37
|
+
appends a `goal.<n>.suspended` row to the session annal; the ordinal is
|
|
38
|
+
peeked rather than minted, so the eventual finish shares the same
|
|
39
|
+
`goal.<n>` record and no duplicate goal note is born from a suspension.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **Digest contract typed:** the digest job is one `DistillGate::Job`
|
|
44
|
+
Struct (the queue's two Hash literals collapse into a keyword-minted
|
|
45
|
+
shape), the orphaned `:goal_id` read that no site ever set becomes a
|
|
46
|
+
live `goal_ordinal` carried from goal-end auto-digest — the distiller
|
|
47
|
+
brief now cites the goal record for goal-tied digests — and the digest
|
|
48
|
+
receipt text is single-homed in the DistillGate rules module
|
|
49
|
+
(`receipt` / `duplicate_receipt` / `full_receipt`) instead of three
|
|
50
|
+
templates across two files.
|
|
51
|
+
- **Truncation strategy parity:** `tools.truncation_strategy` was honored
|
|
52
|
+
by exactly one of the Truncator call sites while the subagent tool
|
|
53
|
+
dispatch hard-coded `head_tail` under a comment claiming the two planes
|
|
54
|
+
cannot drift; both orchestrator planes now fetch the knob (fallback
|
|
55
|
+
`Truncator::DEFAULT_STRATEGY`), the restated 12_000/300 literals resolve
|
|
56
|
+
from `Truncator::DEFAULT_MAX_CHARS/LINES`, and a parity pin test turns
|
|
57
|
+
the prose invariant into a test invariant (classification, truncation
|
|
58
|
+
and the strategy knob are asserted equal across the two planes, oracle-
|
|
59
|
+
anchored against Truncator directly). The configuration docs now name
|
|
60
|
+
the honoring consumers.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- **Goal-identity suspend keys and honest logs (#350, #351):** suspend
|
|
65
|
+
rows key on goal identity (ledger id, else slug) plus steps and an
|
|
66
|
+
error digest instead of a peeked ordinal — interleaved goals no longer
|
|
67
|
+
mispair A's suspend row to B's finish and two suspensions sharing a
|
|
68
|
+
peek no longer dedupe into one row; `AnnalWriter.event_context` adds
|
|
69
|
+
status + goal so a dropped suspension and a dropped finish are
|
|
70
|
+
distinguishable in the logs; a `:duplicate` auto-digest admission warns
|
|
71
|
+
with the topic and unattached ordinal instead of silently succeeding;
|
|
72
|
+
the append dedupe anchors on line ends so free-text fields carrying
|
|
73
|
+
`(event=...)` cannot plant phantom keys that suppress genuine rows; the
|
|
74
|
+
chronicler worker gains a per-iteration last-ditch rescue with a
|
|
75
|
+
backtrace frame, the ledger id is stamped before `suspend!`, and a
|
|
76
|
+
spawn-disabled flush warns honestly instead of queueing in silence.
|
|
77
|
+
- **Unrecognized truncation strategy values warn once (#349):** a typo'd
|
|
78
|
+
`tools.truncation_strategy` (or `context.emergency_truncate_strategy`)
|
|
79
|
+
previously fell into the `head_tail` else branch with no signal in
|
|
80
|
+
either plane; `Truncator::STRATEGIES` is now the honest frontier, all
|
|
81
|
+
three config-fetch sites resolve through it, and each distinct unknown
|
|
82
|
+
value warns exactly once per knob+value pair with a UTF-8-scrubbed
|
|
83
|
+
message that can never turn tool results into errors.
|
|
84
|
+
|
|
9
85
|
## [0.5.0] - 2026-10-31
|
|
10
86
|
|
|
11
87
|
A vakanüvis minor: the memory system gains a harness-owned auto-digest
|
|
@@ -1007,7 +1083,8 @@ installation section.
|
|
|
1007
1083
|
- Context: serialized index mutations, snapshot-outside-lock reconcile,
|
|
1008
1084
|
streaming-first truncation.
|
|
1009
1085
|
|
|
1010
|
-
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.
|
|
1086
|
+
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.6.0...HEAD
|
|
1087
|
+
[0.6.0]: https://github.com/ozgurulukir/rubik/compare/v0.5.0...v0.6.0
|
|
1011
1088
|
[0.5.0]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...v0.5.0
|
|
1012
1089
|
[0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
|
|
1013
1090
|
[0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "benchmark"
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
|
|
6
|
+
require_relative "vakanuvis_adoption"
|
|
7
|
+
|
|
6
8
|
module Rubik
|
|
7
9
|
module Benchmark
|
|
8
10
|
class MetricResult
|
|
@@ -38,9 +40,21 @@ module Rubik
|
|
|
38
40
|
results << bench_grep_tool
|
|
39
41
|
results << bench_cold_scan
|
|
40
42
|
results << bench_search_ranking
|
|
43
|
+
results.concat(bench_vakanuvis_adoption)
|
|
41
44
|
results
|
|
42
45
|
end
|
|
43
46
|
|
|
47
|
+
# issue #338: araç-benimseme taban çizgisi. Ana süit YALNIZ deterministik
|
|
48
|
+
# replay düzlemini koşar (canlı model çağrısı asla default olamaz);
|
|
49
|
+
# canlı ölçüm rake bench:vakanuvis_adoption MODELS=... ile açık istektir.
|
|
50
|
+
def bench_vakanuvis_adoption
|
|
51
|
+
rows = Rubik::Benchmark::VakanuvisAdoption.run(
|
|
52
|
+
Rubik::Benchmark::VakanuvisAdoption::REPLAY_PROFILES.map { |p| Rubik::Benchmark::VakanuvisAdoption::ReplayDriver.new(p) },
|
|
53
|
+
io: nil
|
|
54
|
+
)
|
|
55
|
+
Rubik::Benchmark::VakanuvisAdoption.metric_results(rows, io: nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
def generate_markdown_report(results)
|
|
45
59
|
lines = []
|
|
46
60
|
lines << "| Metric | Target | Measured | Status |"
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../storage/vakanuvis"
|
|
6
|
+
require_relative "../tools/builtins/storage"
|
|
7
|
+
require_relative "../tools/builtins/vakanuvis"
|
|
8
|
+
|
|
9
|
+
module Rubik
|
|
10
|
+
module Benchmark
|
|
11
|
+
# Vakanüvis araç-benimseme senaryosu (issue #338): küçük modellerin
|
|
12
|
+
# vakanüvis'i ne sıklıkla, nasıl kullandığını ölçen taban çizgisi.
|
|
13
|
+
# "Bu düzeltme iğneyi hareket ettirdi mi?" sorusunun cevabı için
|
|
14
|
+
# prompt-doktrin / yönelim-enjeksiyonu / otomatik-damıtma düzeltmeleri
|
|
15
|
+
# ÖNCE bu ölçüm ister.
|
|
16
|
+
#
|
|
17
|
+
# Düzlem 1 (deterministik, CI-güvenli): tohum depo + ReplayDriver —
|
|
18
|
+
# üç davranış profili GERÇEK araç düzlemini çalıştırır (script'lenmiş
|
|
19
|
+
# araç çağrıları, sahte sonuçlar değil); metrik borusu ve recall
|
|
20
|
+
# çıktı biçimi regresyona girerse burada patlar.
|
|
21
|
+
# Düzlem 2 (canlı): LiveDriver provider.complete döngüsü — MODELS
|
|
22
|
+
# env'i ile model başına gerçek benimseme ölçümü.
|
|
23
|
+
#
|
|
24
|
+
# Sessizlik: canlı modeller maliyetli ve deterministik değildir — bu
|
|
25
|
+
# yüzden default Düzlem 1'dir; rake bench ana süitine asla canlı çağrı
|
|
26
|
+
# sızdırmaz (bench:vakanuvis_adoption MODELS=... açık istek ister).
|
|
27
|
+
module VakanuvisAdoption
|
|
28
|
+
# Tohum kartlar: TR/EN karışık, Keywords: satırlı/satırsız — issue
|
|
29
|
+
# senaryosunun ta kendisi. Görev-1 kartının ayırt edici değerleri
|
|
30
|
+
# (8 saniye adımı + 5 deneme) yanıt-kullanımını dürüstçe sayılabilir
|
|
31
|
+
# kılar: model hatırladığı karttan alıntıladıysa ikisi de görünür.
|
|
32
|
+
SEED_CARDS = [
|
|
33
|
+
{
|
|
34
|
+
topic: "Ağ geçidi yeniden deneme politikası",
|
|
35
|
+
body: "Ağ geçidi 429/503 yanıtında üstel geri çekilme uygular: 2s, 4s, 8s " \
|
|
36
|
+
"(en fazla 5 deneme). Devre kesici 30 ardışık hata sonra açılır.\n" \
|
|
37
|
+
"Keywords: gateway retry backoff üstel geri çekilme 429 503 circuit breaker exponent"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
topic: "Sandbox filesystem allowlist",
|
|
41
|
+
body: "The container sandbox mounts the worktree read-write and $HOME read-only; " \
|
|
42
|
+
"the allowlist file is sandbox.allow at the repo root.\n" \
|
|
43
|
+
"Keywords: sandbox allowlist container readonly mount worktree izin listesi"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
topic: "Vakanüvis damıtma kapısı",
|
|
47
|
+
body: "Damıtma kapısı aynı hedefli işi yutar (dedupe); maksimum kuyruk 8 iş, " \
|
|
48
|
+
"hedef başına tek iz."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
topic: "Fiber scheduler budget",
|
|
52
|
+
body: "The fiber scheduler caps concurrent subagent fibers at 12; overflow " \
|
|
53
|
+
"queues FIFO and never drops."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
topic: "Goal sıra türetimi",
|
|
57
|
+
body: "Goal sırası mağazadaki en büyük annal.goal.N'den türetilir; duvar saati " \
|
|
58
|
+
"ASLA kullanılmaz — kronik MARKER-ZIKKIM-7 determinizmi budur."
|
|
59
|
+
}
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# Görev 1 yanıt izleri: karttan alıntı → ikisi de eşleşir; uydurma
|
|
63
|
+
# yanıt → en az biri (genelde ikisi) kaçar.
|
|
64
|
+
ANSWER_MARKER_BACKOFF = /8\s*(s\b|sn|sec|second|saniye)/i
|
|
65
|
+
ANSWER_MARKER_TRIES = /(5|beş|five)\s*(deneme|attempt|retr|try)/i
|
|
66
|
+
|
|
67
|
+
TASKS = [
|
|
68
|
+
{
|
|
69
|
+
id: "recall-tr-en",
|
|
70
|
+
prompt: "Bu sorunun cevabı rubik://storage kronikçisindeki bir hafıza kartında duruyor: " \
|
|
71
|
+
"ağ geçidi (gateway) 429/503 yanıtında yeniden deneme / geri çekilme politikası nedir? " \
|
|
72
|
+
"Kısa cevabını ver."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "durable-conclusion",
|
|
76
|
+
prompt: "We just concluded that the chronicler's digest gate deduplicates jobs for the " \
|
|
77
|
+
"same target. Persist this durable conclusion so future sessions can recall it, " \
|
|
78
|
+
"then confirm in one line."
|
|
79
|
+
}
|
|
80
|
+
].freeze
|
|
81
|
+
|
|
82
|
+
MAX_TURNS = 8
|
|
83
|
+
REPLAY_PROFILES = ["adopter", "wrong-path", "struggler"].freeze
|
|
84
|
+
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
# ---- mağaza + araç masası ---------------------------------------------
|
|
88
|
+
|
|
89
|
+
# Kronikçi yapılandırması: damıtıcı KAPALI (bench'te asla LLM doğumu —
|
|
90
|
+
# ölçtüğümüz şey maestro davranışı, damıtıcı maliyeti değil).
|
|
91
|
+
class QuietConfig
|
|
92
|
+
def fetch(key, default = nil)
|
|
93
|
+
return false if key == "subagents.vakanuvis.distiller"
|
|
94
|
+
|
|
95
|
+
default
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Deterministik tohum: aynı kart dizisi ⇒ aynı mağaza ⇒ aynı recall
|
|
100
|
+
# davranışı. Her model/profil ÖZGÜN kök dizinle başlar (record
|
|
101
|
+
# yan etkileri bir sonraki modelin ölçümünü kirletmesin).
|
|
102
|
+
def seed_store(root)
|
|
103
|
+
store = Rubik::Storage::Store.new(root: root, project: "vakanuvis-adoption-bench")
|
|
104
|
+
harness = Rubik::Storage::Vakanuvis::Harness.new(store: store, config: QuietConfig.new, session_slug: "bench-seed")
|
|
105
|
+
SEED_CARDS.each { |card| harness.record(topic: card[:topic], body: card[:body]) }
|
|
106
|
+
harness.shutdown
|
|
107
|
+
store
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# İki gerçek yerleşik araç: vakanüvis (doğru yol) + storage (yanlış
|
|
111
|
+
# yolun mümkün olması için masada DURMALI — yoksa yanlış-yol oranı
|
|
112
|
+
# ölçülemez, sahte sıfır doğardı).
|
|
113
|
+
class ToolBench
|
|
114
|
+
def initialize(store, harness)
|
|
115
|
+
@tools = {
|
|
116
|
+
"vakanuvis" => Rubik::Tools::VakanuvisTool.new(harness),
|
|
117
|
+
"storage" => Rubik::Tools::StorageTool.new(store)
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def execute(name, args)
|
|
122
|
+
tool = @tools[name.to_s]
|
|
123
|
+
return "Unknown tool '#{name}' (bench toolbelt: vakanuvis, storage)." unless tool
|
|
124
|
+
|
|
125
|
+
tool.execute(args || {})
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
"tool error: #{e.class}: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def schemas = @tools.values.map(&:to_h)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ---- sürücüler --------------------------------------------------------
|
|
134
|
+
# Sözleşme: run_task(task, tools) -> { turns:, answer:, calls:,
|
|
135
|
+
# first_recall_turn:, hit_cap: }. calls = [[name, args, result_text]]
|
|
136
|
+
# (sıralı). first_recall_turn = ilk recall'in 1-tabanlı turn'ü.
|
|
137
|
+
|
|
138
|
+
# Script'lenmiş davranış profilleri — scriptler GERÇEK araçlarla
|
|
139
|
+
# koşar (sonuç sahtelenmez); metrik borusunun deterministik kanıtı.
|
|
140
|
+
class ReplayDriver
|
|
141
|
+
attr_reader :profile
|
|
142
|
+
|
|
143
|
+
def initialize(profile)
|
|
144
|
+
@profile = profile.to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def label = "scripted/#{@profile}"
|
|
148
|
+
|
|
149
|
+
def live? = false
|
|
150
|
+
|
|
151
|
+
def run_task(task, tools)
|
|
152
|
+
calls = []
|
|
153
|
+
answer = ""
|
|
154
|
+
turns = 0
|
|
155
|
+
first_recall_turn = nil
|
|
156
|
+
SCRIPTS.fetch(@profile).fetch(task[:id]).each do |step|
|
|
157
|
+
turns += 1
|
|
158
|
+
steps = step[:call] ? [step[:call]] : (step[:call_each] || [])
|
|
159
|
+
steps.each do |name, args|
|
|
160
|
+
first_recall_turn ||= turns if recall_step?(name, args)
|
|
161
|
+
calls << [name, args, tools.execute(name, args)]
|
|
162
|
+
end
|
|
163
|
+
answer = step[:answer] if step.key?(:answer)
|
|
164
|
+
end
|
|
165
|
+
{ turns: turns, answer: answer, calls: calls, first_recall_turn: first_recall_turn, hit_cap: false }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def recall_step?(name, args)
|
|
171
|
+
name == "vakanuvis" && (args["action"].nil? || args["action"] == "recall")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Canlı sürücü: provider.complete döngüsü. Prompt görevi söyletir,
|
|
176
|
+
# araç seçimini ASLA emretmez — benimseme ölçümünün koşulu budur.
|
|
177
|
+
class LiveDriver
|
|
178
|
+
def initialize(provider, max_turns: MAX_TURNS)
|
|
179
|
+
@provider = provider
|
|
180
|
+
@max_turns = max_turns
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def label = "live/#{@provider.name}/#{@provider.model}"
|
|
184
|
+
|
|
185
|
+
def live? = true
|
|
186
|
+
|
|
187
|
+
def run_task(task, tools)
|
|
188
|
+
history = [{ "role" => "user", "content" => task[:prompt] }]
|
|
189
|
+
calls = []
|
|
190
|
+
turns = 0
|
|
191
|
+
first_recall_turn = nil
|
|
192
|
+
answer = ""
|
|
193
|
+
|
|
194
|
+
@max_turns.times do
|
|
195
|
+
turns += 1
|
|
196
|
+
res = @provider.complete(history, { tools: tools.schemas })
|
|
197
|
+
# Sağlayıcı sözleşmesi (inceleme turu 2): Response dışı bir
|
|
198
|
+
# dönüş, respond_to? zincirlerine girip sahte "araç
|
|
199
|
+
# benimsemedi / tur sınırına vurdu" veri noktası üretmesin —
|
|
200
|
+
# dürüstçe patla.
|
|
201
|
+
raise "provider returned #{res.inspect}, expected Rubik::LLM::Response" unless res.is_a?(Rubik::LLM::Response)
|
|
202
|
+
|
|
203
|
+
tool_calls = res.respond_to?(:tool_calls?) && res.tool_calls? ? res.tool_calls : []
|
|
204
|
+
if tool_calls.empty?
|
|
205
|
+
answer = res.respond_to?(:text) ? res.text.to_s : res.to_s
|
|
206
|
+
break
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
history << {
|
|
210
|
+
role: "assistant",
|
|
211
|
+
content: res.respond_to?(:text) ? res.text.to_s : "",
|
|
212
|
+
tool_calls: tool_calls.map { |tc| { "id" => tc["id"], "tool" => tc["tool"], "args" => tc["args"] } }
|
|
213
|
+
}
|
|
214
|
+
tool_calls.each do |tc|
|
|
215
|
+
args = tc["args"].is_a?(Hash) ? tc["args"] : {}
|
|
216
|
+
action = args["action"].to_s
|
|
217
|
+
first_recall_turn ||= turns if tc["tool"] == "vakanuvis" && (action.empty? || action == "recall")
|
|
218
|
+
result = tools.execute(tc["tool"], args)
|
|
219
|
+
calls << [tc["tool"], args, result]
|
|
220
|
+
history << { role: "tool", tool_call_id: tc["id"], tool_name: tc["tool"], content: result.to_s }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
{ turns: turns, answer: answer, calls: calls, first_recall_turn: first_recall_turn, hit_cap: answer.empty? }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ---- metrikler --------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
Metrics = Struct.new(
|
|
231
|
+
:label, :recall_called, :first_recall_turn, :recall_calls, :avg_query_terms,
|
|
232
|
+
:keywords_line_used, :recall_empty_rate, :record_called, :wrong_path_puts,
|
|
233
|
+
:answer_used_recall, :hit_turn_cap, keyword_init: true
|
|
234
|
+
) do
|
|
235
|
+
def summary
|
|
236
|
+
"recall=#{recall_called ? "turn #{first_recall_turn}" : 'never'} calls=#{recall_calls} " \
|
|
237
|
+
"terms=#{avg_query_terms || '-'} kw=#{keywords_line_used ? 'yes' : 'no'} empty=#{recall_empty_rate || '-'}% " \
|
|
238
|
+
"record=#{record_called ? 'vakanuvis' : 'none'} wrong_put=#{wrong_path_puts} " \
|
|
239
|
+
"answer_used=#{answer_used_recall ? 'yes' : 'no'}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def metrics_for(label, task_runs)
|
|
244
|
+
recall_events = []
|
|
245
|
+
record_calls = 0
|
|
246
|
+
wrong_puts = 0
|
|
247
|
+
first_recall_turn = nil
|
|
248
|
+
answer_used = false
|
|
249
|
+
hit_cap = false
|
|
250
|
+
|
|
251
|
+
task_runs.each do |run|
|
|
252
|
+
run[:calls].each do |name, args, result|
|
|
253
|
+
if name == "vakanuvis"
|
|
254
|
+
action = args["action"].to_s
|
|
255
|
+
if action.empty? || action == "recall"
|
|
256
|
+
recall_events << [args["query"].to_s, result.to_s]
|
|
257
|
+
first_recall_turn ||= run[:first_recall_turn]
|
|
258
|
+
elsif action == "record"
|
|
259
|
+
record_calls += 1
|
|
260
|
+
end
|
|
261
|
+
elsif name == "storage" && args["action"].to_s == "put" && ["memory", "wiki"].include?(args["type"].to_s)
|
|
262
|
+
wrong_puts += 1 # kronikçi masada DURUYORKEN doğrudan put = yanlış yol
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
answer_used ||= answer_uses_recall?(run[:answer])
|
|
266
|
+
hit_cap ||= run[:hit_cap] == true
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
terms = recall_events.map { |query, _res| query.strip.split(/\s+/).reject(&:empty?).size }
|
|
270
|
+
Metrics.new(
|
|
271
|
+
label: label,
|
|
272
|
+
recall_called: !recall_events.empty?,
|
|
273
|
+
first_recall_turn: first_recall_turn,
|
|
274
|
+
recall_calls: recall_events.size,
|
|
275
|
+
avg_query_terms: terms.empty? ? nil : terms.sum.fdiv(terms.size).round(1),
|
|
276
|
+
keywords_line_used: recall_events.any? { |query, _res| query.include?("Keywords:") },
|
|
277
|
+
recall_empty_rate: recall_events.empty? ? nil : empty_rate(recall_events),
|
|
278
|
+
record_called: record_calls.positive?,
|
|
279
|
+
wrong_path_puts: wrong_puts,
|
|
280
|
+
answer_used_recall: answer_used,
|
|
281
|
+
hit_turn_cap: hit_cap
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# "Recalled N ..." başlığı dışındaki her şey (anahtar-AND boş + yalnız
|
|
286
|
+
# fallback izleri) boş sayılır — issue'nin "fallback dahil" tanımı.
|
|
287
|
+
def empty_rate(recall_events)
|
|
288
|
+
empty_count = recall_events.count { |_query, res| !res.start_with?("Recalled ") }
|
|
289
|
+
(empty_count.fdiv(recall_events.size) * 100).round(0)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def answer_uses_recall?(text)
|
|
293
|
+
text = text.to_s
|
|
294
|
+
text.match?(ANSWER_MARKER_BACKOFF) && text.match?(ANSWER_MARKER_TRIES)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ---- koşu -------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
# Her sürücü özgün tohumlu mağaza alır (izolasyon: record yan
|
|
300
|
+
# etkileri sızamaz). Sürücü hatası TEK modeli tablodan düşürür —
|
|
301
|
+
# diğer modellerin ölçümü yaşamaya devam eder; düşen satır sınıfa
|
|
302
|
+
# göre ayrışır: RateLimitError → SkipRow (kota/ağ ölümü — geçici),
|
|
303
|
+
# diğer her şey → ErrorRow (kimlik doğrulama, bozuk JSON, determin-
|
|
304
|
+
# istik düzlemde script hatası... bunlar "skipped" kıyafetini taşımaya
|
|
305
|
+
# değmez). Deterministik düzlemde çöküş loud: diğer bench metotları
|
|
306
|
+
# gibi rake bench'i kırmalı — çökme "skipped" diye kaydedilmez.
|
|
307
|
+
def run(drivers, io: $stdout)
|
|
308
|
+
drivers.map do |driver|
|
|
309
|
+
root = Dir.mktmpdir("rubik_vakanuvis_adoption")
|
|
310
|
+
begin
|
|
311
|
+
store = seed_store(root)
|
|
312
|
+
harness = Rubik::Storage::Vakanuvis::Harness.new(store: store, config: QuietConfig.new, session_slug: "bench-#{driver.label}")
|
|
313
|
+
tools = ToolBench.new(store, harness)
|
|
314
|
+
runs = TASKS.map { |task| driver.run_task(task, tools) }
|
|
315
|
+
tool_errors = runs.sum { |r| r[:calls].count { |_n, _a, res| TOOL_ERROR_MARKERS.any? { |m| res.to_s.start_with?(m) } } }
|
|
316
|
+
if tool_errors.positive?
|
|
317
|
+
first_bad = runs.flat_map { |r| r[:calls] }.find { |_n, _a, res| TOOL_ERROR_MARKERS.any? { |m| res.to_s.start_with?(m) } }
|
|
318
|
+
raise "#{driver.label}: #{tool_errors} tool error(s) — the bench toolbelt itself failed; " \
|
|
319
|
+
"these numbers would read as model behavior. first: #{first_bad[2].to_s[0, 160]}"
|
|
320
|
+
end
|
|
321
|
+
harness.shutdown
|
|
322
|
+
metrics_for(driver.label, runs).tap { |m| io&.puts(" [vakanuvis-adoption] #{m.label}: #{m.summary}") }
|
|
323
|
+
rescue Rubik::LLM::RateLimitError => e
|
|
324
|
+
location = (e.backtrace || []).first
|
|
325
|
+
io&.puts(" [vakanuvis-adoption] #{driver.label}: SKIPPED (rate limited) — #{e.message} @ #{location}")
|
|
326
|
+
SkipRow.new(driver.label, e)
|
|
327
|
+
rescue Rubik::LLM::ClientError, StandardError => e
|
|
328
|
+
# Deterministik düzlem: asla satıra dönüşmesin (rake bench
|
|
329
|
+
# loud patlar). Canlı düzlem: tek bozuk sürücü kalan modellerin
|
|
330
|
+
# ölçümünü öldürmesin — dürüst ERROR satırı düşer.
|
|
331
|
+
raise unless driver.live?
|
|
332
|
+
|
|
333
|
+
location = (e.backtrace || []).first
|
|
334
|
+
io&.puts(" [vakanuvis-adoption] #{driver.label}: ERROR — #{e.class}: #{e.message} @ #{location}")
|
|
335
|
+
ErrorRow.new(driver.label, e)
|
|
336
|
+
ensure
|
|
337
|
+
begin
|
|
338
|
+
FileUtils.remove_entry(root)
|
|
339
|
+
rescue StandardError => e
|
|
340
|
+
# Windows EBUSY/ENOTEMPTY (AV tarayıcı, indeksleyici) cleanup'ı
|
|
341
|
+
# patlatabilir — cleanup hatası tabloyu ve işlenen hatayı
|
|
342
|
+
# MaskeleMEsin; tüm benchmark'ı da abort etmesin.
|
|
343
|
+
warn " [vakanuvis-adoption] cleanup failed for #{root}: #{e.class}: #{e.message}"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Kota/ağ ölümü yaşayan modelin tablo satırı: yok saymak değil —
|
|
350
|
+
# "ölçülemedi"yi tabloda göster (sessiz-satır-kaybı yok).
|
|
351
|
+
SkipRow = Struct.new(:label, :error) do
|
|
352
|
+
def summary = "skipped: #{error.class}: #{error.message.to_s[0, 120]}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Kota DEĞİL — kimlik doğrulama, bozuk sağlayıcı yanıtı, kabuk...
|
|
356
|
+
# "skipped" yalan olur; tabloda error diye ayrışır.
|
|
357
|
+
ErrorRow = Struct.new(:label, :error) do
|
|
358
|
+
def summary = "error: #{error.class}: #{error.message.to_s[0, 120]}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Bench tarafı araç hataları (ToolBench#execute'un kurtarma dizgileri):
|
|
362
|
+
# bunlar model davranışı DEĞİL — metrik borusuna girmeden yakalanır.
|
|
363
|
+
TOOL_ERROR_MARKERS = ["tool error: ", "Unknown tool "].freeze
|
|
364
|
+
|
|
365
|
+
# Metrik satırlarından runner sonuçları: hedef taban çizgisi (model
|
|
366
|
+
# puanı değil — senaryo KOŞTU mu). Tablo io'ya markdown düşer.
|
|
367
|
+
def metric_results(rows, io: $stdout)
|
|
368
|
+
io&.puts("\n#### Vakanüvis tool adoption (issue #338)\n")
|
|
369
|
+
io&.puts("| model | recall | first@ | calls | terms | kw:line | empty% | record | wrong put | answer used |")
|
|
370
|
+
io&.puts("|---|---|---|---|---|---|---|---|---|---|")
|
|
371
|
+
rows.map do |m|
|
|
372
|
+
if m.is_a?(SkipRow) || m.is_a?(ErrorRow)
|
|
373
|
+
# Ölçülemeyen model tabloda KALIR — satır kaybı sessiz değil;
|
|
374
|
+
# kota ölümü (skipped) ile gerçek hata (error) ayrışır.
|
|
375
|
+
status = m.is_a?(SkipRow) ? "skipped" : "error"
|
|
376
|
+
# 10 hücre: başlık 10 sütun — 11. hücreye düşen skip/error
|
|
377
|
+
# gerekçesi GFM tarafından SESSİZCE düşürülüyordu.
|
|
378
|
+
io&.puts("| #{m.label} | #{status} | - | - | - | - | - | - | - | (#{m.summary}) |")
|
|
379
|
+
next Rubik::Benchmark::MetricResult.new(
|
|
380
|
+
name: "Vakanüvis Tool Adoption — #{m.label}",
|
|
381
|
+
target: "baseline (issue #338)",
|
|
382
|
+
measured: m.summary,
|
|
383
|
+
passed: false
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
io&.puts("| #{m.label} | #{m.recall_called ? 'yes' : 'NO'} | #{m.first_recall_turn || '-'} | #{m.recall_calls} | " \
|
|
387
|
+
"#{m.avg_query_terms || '-'} | #{m.keywords_line_used ? 'yes' : 'no'} | #{m.recall_empty_rate || '-'}% | " \
|
|
388
|
+
"#{m.record_called ? 'vakanuvis' : 'none'} | #{m.wrong_path_puts} | #{m.answer_used_recall ? 'yes' : 'no'} |")
|
|
389
|
+
Rubik::Benchmark::MetricResult.new(
|
|
390
|
+
name: "Vakanüvis Tool Adoption — #{m.label}",
|
|
391
|
+
target: "baseline (issue #338)",
|
|
392
|
+
measured: m.summary,
|
|
393
|
+
passed: true
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Replay script'leri: her adım {call: [name, args]} | {call_each: [[name,
|
|
399
|
+
# args], ...]} | {answer: "..."} — ReplayDriver bunları GERÇEK
|
|
400
|
+
# araçlarla sırayla koşturur; yanıtlar profilin davranış taahhüdüdür.
|
|
401
|
+
SCRIPTS = {
|
|
402
|
+
"adopter" => {
|
|
403
|
+
"recall-tr-en" => [
|
|
404
|
+
{ call: ["vakanuvis", { "action" => "recall", "query" => "gateway retry backoff 429" }] },
|
|
405
|
+
{ answer: "Üstel geri çekilme: 2s, 4s, 8s — en fazla 5 deneme; 30 ardışık hata sonra devre kesici açılır." }
|
|
406
|
+
],
|
|
407
|
+
"durable-conclusion" => [
|
|
408
|
+
{ call: ["vakanuvis", { "action" => "record", "topic" => "digest gate dedupe",
|
|
409
|
+
"body" => "Same-target digest jobs are deduplicated; queue cap is 8 jobs, one in-flight per target.\n" \
|
|
410
|
+
"Keywords: digest dedupe gate queue target" }] },
|
|
411
|
+
{ answer: "Recorded as a memory card via the chronicler." }
|
|
412
|
+
]
|
|
413
|
+
},
|
|
414
|
+
"wrong-path" => {
|
|
415
|
+
"recall-tr-en" => [
|
|
416
|
+
{ answer: "Bilmiyorum; geçidi yeniden dene ve sonra tekrar sor." }
|
|
417
|
+
],
|
|
418
|
+
"durable-conclusion" => [
|
|
419
|
+
{ call_each: [["storage", { "action" => "put", "type" => "memory", "title" => "digest dedupe",
|
|
420
|
+
"body" => "Same-target digest jobs are deduplicated." }]] },
|
|
421
|
+
{ answer: "Persisted directly to storage." }
|
|
422
|
+
]
|
|
423
|
+
},
|
|
424
|
+
"struggler" => {
|
|
425
|
+
"recall-tr-en" => [
|
|
426
|
+
{ call: ["vakanuvis", { "action" => "recall", "query" => "gateway hangi durumlarda üstel bekleme uygular ve devre kesici kaç hata sonra açılır" }] },
|
|
427
|
+
{ call: ["vakanuvis", { "action" => "recall", "query" => "gateway retry backoff" }] },
|
|
428
|
+
{ answer: "2s, 4s, 8s üstel geri çekilme uygulanır (en fazla 5 deneme)." }
|
|
429
|
+
],
|
|
430
|
+
"durable-conclusion" => [
|
|
431
|
+
{ call: ["vakanuvis", { "action" => "record", "topic" => "digest gate dedupe",
|
|
432
|
+
"body" => "Same-target digest jobs are deduplicated." }] },
|
|
433
|
+
{ answer: "Saved via vakanuvis record." }
|
|
434
|
+
]
|
|
435
|
+
}
|
|
436
|
+
}.freeze
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
data/lib/rubik/log.rb
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
# follows the repo's [rubik] prefix convention. Emit is error-isolated: a
|
|
7
7
|
# logger must never raise, least of all from inside a rescue body.
|
|
8
8
|
#
|
|
9
|
-
# warn/error always print
|
|
10
|
-
# RUBIK_LOG_LEVEL (default "warn")
|
|
11
|
-
#
|
|
9
|
+
# warn/error always print at the DEFAULT level. debug/info/warn are all
|
|
10
|
+
# filtered by RUBIK_LOG_LEVEL (default "warn") — an operator running at
|
|
11
|
+
# "error" also silences :warn, by design. The RUBIK_DEBUG-gated debug_warn
|
|
12
|
+
# stays a separate mechanism and is NOT migrated by this slice.
|
|
12
13
|
module Rubik
|
|
13
14
|
module Log
|
|
14
15
|
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
@@ -176,9 +176,9 @@ module Rubik
|
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def normalize_raw_output(raw_output, tool_name, call_id, dur)
|
|
179
|
-
max_chars = @deps.shell&.config&.fetch("tools.max_output_chars",
|
|
180
|
-
max_lines = @deps.shell&.config&.fetch("tools.max_output_lines",
|
|
181
|
-
trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy",
|
|
179
|
+
max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS) || Rubik::Tools::Truncator::DEFAULT_MAX_CHARS
|
|
180
|
+
max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", Rubik::Tools::Truncator::DEFAULT_MAX_LINES) || Rubik::Tools::Truncator::DEFAULT_MAX_LINES
|
|
181
|
+
trunc_strat = Rubik::Tools::Truncator.resolve_strategy(@deps.shell&.config&.fetch("tools.truncation_strategy", Rubik::Tools::Truncator::DEFAULT_STRATEGY))
|
|
182
182
|
|
|
183
183
|
if raw_output.is_a?(Rubik::Tools::Result)
|
|
184
184
|
trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
|
|
@@ -17,41 +17,61 @@ module Rubik
|
|
|
17
17
|
return error_result(name, "Tool '#{name}' is not available in subagent toolset.") unless tool
|
|
18
18
|
|
|
19
19
|
raw = tool.execute(args)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
cfg = @config.respond_to?(:fetch) ? @config : nil
|
|
21
|
+
max_chars = (cfg&.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS) || Rubik::Tools::Truncator::DEFAULT_MAX_CHARS).to_i
|
|
22
|
+
max_lines = (cfg&.fetch("tools.max_output_lines", Rubik::Tools::Truncator::DEFAULT_MAX_LINES) || Rubik::Tools::Truncator::DEFAULT_MAX_LINES).to_i
|
|
23
|
+
strategy = Rubik::Tools::Truncator.resolve_strategy(cfg&.fetch("tools.truncation_strategy", Rubik::Tools::Truncator::DEFAULT_STRATEGY))
|
|
24
|
+
normalize_tool_result(raw, name, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
|
|
23
25
|
rescue StandardError => e
|
|
24
26
|
error_result(name, "Tool execution error: #{e.class}: #{e.message}", error: e.message)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
private
|
|
28
30
|
|
|
29
|
-
def normalize_tool_result(raw, name, max_chars:, max_lines:)
|
|
31
|
+
def normalize_tool_result(raw, name, max_chars:, max_lines:, strategy: Rubik::Tools::Truncator::DEFAULT_STRATEGY)
|
|
30
32
|
if raw.is_a?(Rubik::Tools::Result)
|
|
31
|
-
trunc = Rubik::Tools::Truncator.truncate(raw.output, max_chars: max_chars, max_lines: max_lines)
|
|
33
|
+
trunc = Rubik::Tools::Truncator.truncate(raw.output, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
|
|
32
34
|
# Truncation only shortens the display text — status, exit code,
|
|
33
35
|
# metadata and the original truncation flag all survive it.
|
|
36
|
+
first_line = raw.output.to_s.lines.first.to_s.strip
|
|
34
37
|
Rubik::Tools::Result.new(tool_name: raw.tool_name.to_s.empty? ? name : raw.tool_name,
|
|
35
|
-
status: raw.status,
|
|
38
|
+
status: raw.status,
|
|
39
|
+
error: raw.error || (raw.error? ? first_line : nil), exit_code: raw.exit_code,
|
|
36
40
|
output: trunc[:text], duration_ms: raw.duration_ms,
|
|
37
41
|
truncated: raw.truncated || trunc[:truncated], metadata: raw.metadata)
|
|
38
42
|
elsif raw.respond_to?(:status) && raw.respond_to?(:output)
|
|
39
43
|
out_str = raw.output.to_s
|
|
40
|
-
trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines)
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
|
|
45
|
+
first_line = out_str.lines.first.to_s.strip
|
|
46
|
+
status = raw.status.to_sym
|
|
47
|
+
Rubik::Tools::Result.new(tool_name: name, status: status,
|
|
48
|
+
error: (raw.respond_to?(:error) && raw.error) || (status == :error ? first_line : nil),
|
|
49
|
+
output: trunc[:text], truncated: trunc[:truncated],
|
|
43
50
|
exit_code: raw.respond_to?(:exit_code) ? raw.exit_code : nil,
|
|
44
51
|
metadata: raw.respond_to?(:metadata) ? raw.metadata : {})
|
|
45
52
|
else
|
|
46
53
|
out_str = raw.to_s
|
|
47
|
-
trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines)
|
|
48
|
-
status =
|
|
54
|
+
trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
|
|
55
|
+
status = classify_raw_string(raw, out_str)
|
|
49
56
|
Rubik::Tools::Result.new(tool_name: name, status: status,
|
|
50
57
|
error: status == :error ? out_str.lines.first.to_s.strip : nil,
|
|
51
58
|
output: trunc[:text], truncated: trunc[:truncated])
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
|
|
62
|
+
# Root-parity string classification (arch scan card 5, review F1): the
|
|
63
|
+
# same lexicon as StepExecutor#normalize_raw_output's else branch —
|
|
64
|
+
# error?/failed? predicates, then the shared ERROR_PREFIXES table.
|
|
65
|
+
# Without this, a bare "Error: ..." string reads status=ok in subagent
|
|
66
|
+
# history while the root plane honestly reports :error.
|
|
67
|
+
def classify_raw_string(raw, out_str)
|
|
68
|
+
return :error if raw.respond_to?(:error?) && raw.error?
|
|
69
|
+
return :error if raw.respond_to?(:failed?) && raw.failed?
|
|
70
|
+
return :error if raw.is_a?(String) && Rubik::Orchestrator::StepExecutor::ERROR_PREFIXES.any? { |pfx| out_str.start_with?(pfx) }
|
|
71
|
+
|
|
72
|
+
:ok
|
|
73
|
+
end
|
|
74
|
+
|
|
55
75
|
def error_result(name, text, error: nil)
|
|
56
76
|
Rubik::Tools::Result.new(tool_name: name, status: :error, output: text, error: error || text)
|
|
57
77
|
end
|
|
@@ -52,9 +52,13 @@ module Rubik
|
|
|
52
52
|
# "head_tail" (default, original behavior) keeps head + tail around the
|
|
53
53
|
# omission notice; "head_only" keeps the message head — useful when the
|
|
54
54
|
# actionable guidance at the tail must not crowd out the error header.
|
|
55
|
-
# Truncator normalizes strings/symbols
|
|
56
|
-
# unknown values
|
|
57
|
-
|
|
55
|
+
# Truncator.resolve_strategy normalizes strings/symbols, falls back to
|
|
56
|
+
# head_tail on unknown values AND warns once — the same typo-honesty
|
|
57
|
+
# contract as the tools.truncation_strategy knob (issue #349).
|
|
58
|
+
strategy = Rubik::Tools::Truncator.resolve_strategy(
|
|
59
|
+
config["emergency_truncate_strategy"] || config[:emergency_truncate_strategy],
|
|
60
|
+
key: "context.emergency_truncate_strategy"
|
|
61
|
+
)
|
|
58
62
|
|
|
59
63
|
history.each do |msg|
|
|
60
64
|
next unless ConversationRows.row?(msg)
|
|
@@ -270,6 +270,13 @@ module Rubik
|
|
|
270
270
|
private_class_method def self.suspend_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
|
|
271
271
|
goal_text:, goal_id:, record_pair:, reason:, tokens: nil)
|
|
272
272
|
attempt = suspended_attempt(ctx, goal_id) + 1
|
|
273
|
+
# Ledger kimliği askıdan ÖNCE okunur: suspend! → ledger.suspend!
|
|
274
|
+
# sırası metanın bu turun token'larını içermesi için zorunlu,
|
|
275
|
+
# ama o damga storage_id'yi değiştirdiği için sonradan atanan id
|
|
276
|
+
# SUSPEND_EVENT'e geç kalırdı — ilk askıda kronik satırı slug
|
|
277
|
+
# kimliğine düşer, ledger= bağlantısı kaybolurdu.
|
|
278
|
+
# resume_cross_process ile aynı uçuş-içi atama önceliği.
|
|
279
|
+
goal_state.ledger_id = goal_ledger.snapshot[:storage_id] if goal_ledger
|
|
273
280
|
goal_state.suspend!(reason, tokens: tokens)
|
|
274
281
|
goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
|
|
275
282
|
registry = goal_registry_for(ctx)
|
|
@@ -82,7 +82,11 @@ module Rubik
|
|
|
82
82
|
clean_raw = Rubik::Security::Redactor.redact(parsed.raw)
|
|
83
83
|
@recorder&.record_repl_command(clean_raw, output_text)
|
|
84
84
|
# Plan 61 D-8: bound what a bang command leaves in LLM memory; the recorder keeps the full text.
|
|
85
|
-
|
|
85
|
+
max_chars = @config.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS)
|
|
86
|
+
# Deliberately context-scoped (issue #349 F8): truncation_strategy is a
|
|
87
|
+
# tool-output contract (the two orchestrator planes); bang-command echo
|
|
88
|
+
# is REPL history capture and stays head_tail, outside the knob's promise.
|
|
89
|
+
trunc = Rubik::Tools::Truncator.truncate(output_text, max_chars: max_chars, strategy: :head_tail)
|
|
86
90
|
@conversation_history << { role: "user", content: clean_raw.delete_prefix("!") } # bangs stay out of LLM history; recorder keeps the literal
|
|
87
91
|
@conversation_history << { role: "assistant", content: trunc[:truncated] ? trunc[:text] : output_text }
|
|
88
92
|
trim_conversation_window!
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Rubik
|
|
4
6
|
module Storage
|
|
5
7
|
module Vakanuvis
|
|
@@ -35,6 +37,44 @@ module Rubik
|
|
|
35
37
|
[["goal.#{payload[:goal_ordinal]}.#{state}", "#{head} (event=goal.#{payload[:goal_ordinal]}.#{state})"]]
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Askı satırı (issue #350): anahtar PEEK sırası DEĞİL hedef
|
|
41
|
+
# kimliğidir. Askı peek'lediği sıraya ait görünür; araya başka bir
|
|
42
|
+
# goal bitişi girerse peek yanlış kayda bağlanır, iki askı aynı
|
|
43
|
+
# peek'i paylaşınca dedup ikinci satırı yutar. Kimlik = ledger_id
|
|
44
|
+
# (goal.ledger açıkken post_lifecycle'nin damgası), yoksa goal
|
|
45
|
+
# metninin slug'ı; steps + hata özeti anahtara işlenir — gerçek
|
|
46
|
+
# yeniden teslim düşer, içerik değişen askı kalır. Satır goal=
|
|
47
|
+
# tek-satırını da taşır: sıra bağlantısı kalkınca metnin kendisi
|
|
48
|
+
# dürüst bağdır (goal kaydı askıda henüz mint edilmez).
|
|
49
|
+
def suspend_line(seq, payload)
|
|
50
|
+
ledger = payload[:ledger_id].to_s
|
|
51
|
+
identity = ledger.empty? ? Vocabulary.slug(payload[:goal]) : ledger
|
|
52
|
+
head = "- [#{seq}] goal status=suspended steps=#{payload[:steps]}/#{payload[:max_steps]}"
|
|
53
|
+
head += " error=#{one_line(payload[:last_error], 80)}" unless payload[:last_error].to_s.empty?
|
|
54
|
+
head += " ledger=#{one_line(ledger, 40)}" unless ledger.empty?
|
|
55
|
+
head += " goal=#{one_line(payload[:goal], 60)}"
|
|
56
|
+
error = payload[:last_error].to_s
|
|
57
|
+
err_part = error.empty? ? "ok" : Digest::SHA256.hexdigest(error)[0, 8]
|
|
58
|
+
key = "goal.suspend.#{identity}.#{payload[:steps]}.#{err_part}"
|
|
59
|
+
[[key, "#{head} (event=#{key})"]]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Olay-kimliği eki (issue #351 F7): handle_event'in kurtarma
|
|
63
|
+
# günlüğü tek başına kind ile askı satırını bitiş satırından
|
|
64
|
+
# ayırt edemez — düşen kronik satırının kimliği status + goal
|
|
65
|
+
# tek-satırıdır (bitiş ayrıca maintain(goal_end: true)'i atlar).
|
|
66
|
+
# Üretilemezse boş dizgi; günlük satırı asla patlamaz.
|
|
67
|
+
def event_context(kind, payload)
|
|
68
|
+
return "" unless payload.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
case kind
|
|
71
|
+
when :goal then " status=#{payload[:status]} goal=#{one_line(payload[:goal], 40)}"
|
|
72
|
+
when :subagent then " id=#{payload[:storage_id] || payload[:id]} state=#{payload[:state]}"
|
|
73
|
+
when :turn then " kind=#{payload[:kind]} prompt=#{one_line(payload[:prompt], 40)}"
|
|
74
|
+
else ""
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
38
78
|
# Kök-turn satırı (root-turn planı 2026-10-04): REPL turn'ünün
|
|
39
79
|
# deterministik özeti. Boş alan (tools/tokens) satırda görünmez;
|
|
40
80
|
# ordinal harness-lokal sayaçtan gelir, anahtar turn.<n>.<kind>.
|
|
@@ -64,13 +104,20 @@ module Rubik
|
|
|
64
104
|
"goal: #{text}"
|
|
65
105
|
end
|
|
66
106
|
|
|
67
|
-
# Satır ekleme + tekilleştirme: body'de
|
|
68
|
-
# daha yazılmaz (olay yeniden teslimi — resume
|
|
69
|
-
# tamamlanma gibi — yinelenen satır açmaz).
|
|
107
|
+
# Satır ekleme + tekilleştirme: body'de SATIR SONU (event=anahtar)
|
|
108
|
+
# taşıyan satır bir daha yazılmaz (olay yeniden teslimi — resume
|
|
109
|
+
# sonrası ikinci tamamlanma gibi — yinelenen satır açmaz). Bağ
|
|
110
|
+
# (end_with?) zorunludur: serbest metin alanları (goal=, error=,
|
|
111
|
+
# prompt= …) bir "(event=X)" dizgisi TAŞIYABİLİR; gövde-geniş
|
|
112
|
+
# include taraması o dizgiyi sahte anahtar sayıp gelecekteki
|
|
113
|
+
# gerçek X satırını sessizce yutardı (review #353: zehirlenmiş
|
|
114
|
+
# dedup). Anahtar daima satırı bitirdiğinden bağ tarama hem dürüst
|
|
115
|
+
# hem ucuzdur.
|
|
70
116
|
def append(store, ref:, title:, tags: Vocabulary::ANNAL_TAGS, links: {}, lines: [])
|
|
71
117
|
existing = store.get(type: "note", id: ref)
|
|
72
118
|
body = existing ? existing["body"].to_s : ""
|
|
73
|
-
|
|
119
|
+
settled = body.lines.map(&:rstrip)
|
|
120
|
+
fresh = lines.reject { |key, _line| settled.any? { |l| l.end_with?("(event=#{key})") } }
|
|
74
121
|
return existing if existing && fresh.empty?
|
|
75
122
|
|
|
76
123
|
merged = ([body] + fresh.map { |_key, line| line }).reject { |l| l.to_s.strip.empty? }.join("\n")
|
|
@@ -25,11 +25,13 @@ module Rubik
|
|
|
25
25
|
# v2 §4-V6: öneri sonundaki smoke-test sorgularının sayı sınırı.
|
|
26
26
|
CHECK_LIMIT = 5
|
|
27
27
|
|
|
28
|
-
def receipt(query:, target:, spawned:)
|
|
28
|
+
def receipt(query:, target:, spawned:, spawn_failed: false)
|
|
29
29
|
head = "Digest queued (async): the chronicler will compile '#{AnnalWriter.one_line(query, 80)}' into '#{target}'."
|
|
30
30
|
tail = if spawned
|
|
31
31
|
"This call did NOT wait for the LLM — conclude your turn; recall it later with " \
|
|
32
32
|
"vakanuvis(action: 'recall', query: '...') once compiled."
|
|
33
|
+
elsif spawn_failed
|
|
34
|
+
"The distiller spawn attempt failed (see dmesg) — the job stays queued and the spawn retries with the next digest or flush."
|
|
33
35
|
else
|
|
34
36
|
"No distiller run started yet (subagents.vakanuvis.distiller or the chronicler type may be " \
|
|
35
37
|
"disabled) — the job stays queued and compiles when the distiller becomes available."
|
|
@@ -37,6 +39,16 @@ module Rubik
|
|
|
37
39
|
"#{head} #{tail}"
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
def duplicate_receipt(query, target)
|
|
43
|
+
"Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
|
|
44
|
+
"compiling it — recall it later with vakanuvis(action: 'recall', query: '...')."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def full_receipt(target, capacity)
|
|
48
|
+
"Digest not accepted for '#{target}': capacity #{capacity} is full. " \
|
|
49
|
+
"Existing jobs are preserved; retry after a digest finishes."
|
|
50
|
+
end
|
|
51
|
+
|
|
40
52
|
# Öneri şeması: ilk satır hedef ref (wiki.<slug>), gerisi gövde;
|
|
41
53
|
# gövde sonunda isteğe bağlı `CHECK: <sorgu>` satırları (v2 §4-V6
|
|
42
54
|
# smoke testi — en fazla CHECK_LIMIT adet, gövdeden AYIKLANIR,
|
|
@@ -128,7 +140,7 @@ module Rubik
|
|
|
128
140
|
"read_file(path: '#{annal_uri}')"
|
|
129
141
|
end
|
|
130
142
|
Array(job[:transcript_uris]).each { |u| sources << "- Transcript: read_file(path: '#{u}')" }
|
|
131
|
-
sources << "- Goal record: read_file(path: '#{store.uri_for('
|
|
143
|
+
sources << "- Goal record: read_file(path: '#{store.uri_for('note', Vocabulary.goal_ref(job[:goal_ordinal]))}')" if job[:goal_ordinal]
|
|
132
144
|
if (gaps = open_recall_gaps(store))
|
|
133
145
|
sources << gaps
|
|
134
146
|
end
|
|
@@ -58,7 +58,9 @@ module Rubik
|
|
|
58
58
|
# submit_with_status aynı kabulü [makbuz, durum] ikilisi olarak
|
|
59
59
|
# verir (:accepted|:duplicate|:full) — otomatik damıtım red'i
|
|
60
60
|
# makbuz dizgesinden ayırt edebilsin diye (review #346).
|
|
61
|
-
|
|
61
|
+
# goal_ordinal: goal-end auto-digest'in kanıt hedefi (tarama kartı 1,
|
|
62
|
+
# 2026-11-01) — işe işlenir, brief goal kaydını kaynak gösterir.
|
|
63
|
+
def submit_with_status(query, spawn: true, goal_ordinal: nil)
|
|
62
64
|
target = Vocabulary.wiki_ref(query)
|
|
63
65
|
# v2 §4-V5: aynı hedefli iş zaten kuyrukta ya da uçuştaysa makbuz
|
|
64
66
|
# 'already queued' varyantına düşer — çift doğum yok.
|
|
@@ -66,21 +68,18 @@ module Rubik
|
|
|
66
68
|
next :duplicate if @digest_targets.key?(target)
|
|
67
69
|
next :full if @digest_targets.size >= @max_digest_jobs
|
|
68
70
|
|
|
69
|
-
@pending_digests <<
|
|
71
|
+
@pending_digests << Job.new(query: query, target: target, transcript_uris: source_transcript_uris, goal_ordinal: goal_ordinal)
|
|
70
72
|
@digest_targets[target] = true
|
|
71
73
|
:accepted
|
|
72
74
|
end
|
|
73
|
-
if admission == :duplicate
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
77
|
-
if admission == :full
|
|
78
|
-
return ["Digest not accepted for '#{target}': capacity #{@max_digest_jobs} is full. " \
|
|
79
|
-
"Existing jobs are preserved; retry after a digest finishes.", admission]
|
|
80
|
-
end
|
|
75
|
+
return [DistillGate.duplicate_receipt(query, target), admission] if admission == :duplicate
|
|
76
|
+
|
|
77
|
+
return [DistillGate.full_receipt(target, @max_digest_jobs), admission] if admission == :full
|
|
81
78
|
|
|
82
79
|
spawned = spawn ? spawn_job!(batch: false) : nil
|
|
83
|
-
|
|
80
|
+
# REVIEW: patlak doğum "disabled" makbuzuyla yalanlanmasın (F2).
|
|
81
|
+
failed_spawn = spawn && spawned.nil? && spawn_allowed?
|
|
82
|
+
[DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?, spawn_failed: failed_spawn), admission]
|
|
84
83
|
end
|
|
85
84
|
|
|
86
85
|
def submit(query, spawn: true) = submit_with_status(query, spawn: spawn)[0]
|
|
@@ -150,7 +149,7 @@ module Rubik
|
|
|
150
149
|
# Salt-okunuma peeki (test/kurcalama yüzeyi): kuyruğun başını
|
|
151
150
|
# gösterir — tüketmez. Üretim görünürlüğü aşağıdaki job_targets'tır.
|
|
152
151
|
def peek_job(batch: false)
|
|
153
|
-
@lock.synchronize { @pending_digests.first || (batch ?
|
|
152
|
+
@lock.synchronize { @pending_digests.first || (batch ? Job.new(batch: true) : nil) }
|
|
154
153
|
end
|
|
155
154
|
|
|
156
155
|
# İş defteri görünümü (issue #100 P2b): kuyruktaki + uçuştaki
|
|
@@ -219,11 +218,15 @@ module Rubik
|
|
|
219
218
|
@supervisor.respond_to?(:spawn) &&
|
|
220
219
|
@supervisor.respond_to?(:type_registry) && @supervisor.type_registry.get(TYPE_NAME)
|
|
221
220
|
end
|
|
221
|
+
# Genel durum sorgusu (inceleme turu 2): Harness, kabul edilen
|
|
222
|
+
# digest topic'lerinin hiç derlenemeyeceğini dürüstçe
|
|
223
|
+
# bildirmek için doğum kapısını sorabilir olmalı.
|
|
224
|
+
public :spawn_allowed?
|
|
222
225
|
|
|
223
226
|
# Aşağısı YALNIZ @lock altından çağrılır (çağıran: submit /
|
|
224
227
|
# spawn_job! / completion_event) — kendi kilidi almaz.
|
|
225
228
|
def claim_job(batch: false)
|
|
226
|
-
@pending_digests.shift || (batch ?
|
|
229
|
+
@pending_digests.shift || (batch ? Job.new(batch: true, transcript_uris: source_transcript_uris) : nil)
|
|
227
230
|
end
|
|
228
231
|
|
|
229
232
|
def restore_job(job)
|
|
@@ -27,6 +27,9 @@ module Rubik
|
|
|
27
27
|
SUBAGENT_EVENT = :subagent_completed
|
|
28
28
|
GOAL_EVENT = :goal_finished
|
|
29
29
|
TURN_EVENT = :turn_finished
|
|
30
|
+
# Askı kroniği (tarama kartı 2, 2026-11-01): GoalState::SUSPEND_EVENT — abonelik
|
|
31
|
+
# :goal akışına yazar; worker status=="suspended" ile ayırt eder.
|
|
32
|
+
SUSPEND_EVENT = :goal_suspended
|
|
30
33
|
|
|
31
34
|
attr_reader :store, :config, :supervisor, :dmesg, :session_slug, :gate
|
|
32
35
|
|
|
@@ -269,6 +272,7 @@ module Rubik
|
|
|
269
272
|
|
|
270
273
|
@event_bus.subscribe(SUBAGENT_EVENT, context: @context) { |p| enqueue_event(:subagent, p) }
|
|
271
274
|
@event_bus.subscribe(GOAL_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
|
|
275
|
+
@event_bus.subscribe(SUSPEND_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
|
|
272
276
|
@event_bus.subscribe(TURN_EVENT, context: @context) { |p| enqueue_event(:turn, p) } if @root_turns
|
|
273
277
|
end
|
|
274
278
|
|
|
@@ -295,13 +299,19 @@ module Rubik
|
|
|
295
299
|
break if kind == STOP
|
|
296
300
|
|
|
297
301
|
handle_event(kind, payload)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
# Last-ditch net: a raise escaping handle_event's own rescue
|
|
304
|
+
# would kill the worker with queued events unconsumed — and
|
|
305
|
+
# shutdown's alive?-check stays blind to that loss.
|
|
306
|
+
Vakanuvis.log_via(@dmesg, :warn, "chronicler worker event dispatch failed (#{e.class}: #{e.message}) — worker continues")
|
|
298
307
|
ensure
|
|
299
308
|
@monitor.synchronize { @busy -= 1 }
|
|
300
309
|
end
|
|
301
310
|
end
|
|
302
311
|
|
|
303
312
|
# TranscriptIndexer disiplini: kronikçinin kendi hatası akışı
|
|
304
|
-
# asla bozmaz — uyarı düşer, sonraki olay işlenir.
|
|
313
|
+
# asla bozmaz — uyarı düşer, sonraki olay işlenir. Kimlik eki
|
|
314
|
+
# (F7) AnnalWriter.event_context'te.
|
|
305
315
|
def handle_event(kind, payload)
|
|
306
316
|
case kind
|
|
307
317
|
when :subagent then handle_subagent(payload)
|
|
@@ -309,7 +319,9 @@ module Rubik
|
|
|
309
319
|
when :turn then handle_turn(payload)
|
|
310
320
|
end
|
|
311
321
|
rescue StandardError => e
|
|
312
|
-
|
|
322
|
+
# İlk backtrace karesi (event_loop.rb kuralı): depo hatasını
|
|
323
|
+
# annal/digest hatasından ayırt eder.
|
|
324
|
+
Vakanuvis.log_via(@dmesg, :warn, "event handling failed (#{kind}#{AnnalWriter.event_context(kind, payload)}): #{e.class}: #{e.message} @ #{(e.backtrace || []).first}")
|
|
313
325
|
end
|
|
314
326
|
|
|
315
327
|
def handle_subagent(payload)
|
|
@@ -336,6 +348,10 @@ module Rubik
|
|
|
336
348
|
end
|
|
337
349
|
|
|
338
350
|
def handle_goal(payload)
|
|
351
|
+
if payload[:status].to_s == "suspended" # askı kroniği: satır düşer, mint/upsert/maintain yok (kart 2)
|
|
352
|
+
seq = next_seq # anahtar hedef kimliği — sıra peek'i değil (issue #350)
|
|
353
|
+
return append_session(AnnalWriter.suspend_line(seq, payload))
|
|
354
|
+
end
|
|
339
355
|
ordinal = next_goal_ordinal
|
|
340
356
|
enriched = payload.merge(goal_ordinal: ordinal)
|
|
341
357
|
seq = next_seq
|
|
@@ -344,17 +360,16 @@ module Rubik
|
|
|
344
360
|
title: AnnalWriter.goal_title(ordinal, enriched[:goal]), body: goal_body.join("\n"),
|
|
345
361
|
tags: Vocabulary::ANNAL_TAGS + [Vocabulary::GOAL_TAG], links: { "session" => Vocabulary.session_ref(@session_slug) })
|
|
346
362
|
append_session(AnnalWriter.goal_lines(seq, enriched))
|
|
347
|
-
maintain(goal_end: true)
|
|
363
|
+
maintain(goal_end: true, goal_ordinal: ordinal)
|
|
348
364
|
end
|
|
349
365
|
|
|
350
366
|
# Goal sırası mağazadan türetilir (refs'teki en büyük annal.goal.<n>
|
|
351
|
-
# + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz
|
|
352
|
-
#
|
|
353
|
-
def next_goal_ordinal
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
end
|
|
367
|
+
# + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz.
|
|
368
|
+
# Askı artık peek ETMEZ (issue #350) — sıra yalnız bitişte mint edilir.
|
|
369
|
+
def next_goal_ordinal = @goal_ordinal = peek_goal_ordinal
|
|
370
|
+
|
|
371
|
+
def peek_goal_ordinal
|
|
372
|
+
@monitor.synchronize { [@goal_ordinal, @store.refs.keys.filter_map { |k| Vocabulary.goal_ordinal(k) }.max.to_i].max + 1 }
|
|
358
373
|
end
|
|
359
374
|
|
|
360
375
|
# Flush sayacı (R8): subagent ve turn olayları aynı ölçüde sayılır;
|
|
@@ -372,13 +387,13 @@ module Rubik
|
|
|
372
387
|
# yeniden hesap + dokunulan hafıza konularının otomatik damıtımı +
|
|
373
388
|
# damıtıcı doğum kararı. v2 §4-V5: toplu doğum yalnızca kirli işaretle;
|
|
374
389
|
# hedefli damıtım TÜM konuları kuyrukladıysa toplu doğum atlanır — kayıp/red varsa batch telafi eder.
|
|
375
|
-
def maintain(goal_end: false)
|
|
390
|
+
def maintain(goal_end: false, goal_ordinal: nil)
|
|
376
391
|
# Sweep record() ile aynı kilide koşar (tek-yazar disiplini):
|
|
377
392
|
# get-then-put dansı çift satır açamaz.
|
|
378
393
|
swept = @monitor.synchronize { MemorySteward.sweep_rogue_memory(@store) }
|
|
379
394
|
Vakanuvis.log_via(@dmesg, :info, "steward swept #{swept} rogue memory row(s)") if swept.positive?
|
|
380
395
|
WikiSkeleton.rebuild_home(@store)
|
|
381
|
-
digested = auto_digest_touched!(spawn: !stopped
|
|
396
|
+
digested = auto_digest_touched!(spawn: !stopped?, goal_ordinal: goal_ordinal) if goal_end
|
|
382
397
|
@gate.flush_tick unless stopped? || digested
|
|
383
398
|
end
|
|
384
399
|
|
|
@@ -387,7 +402,7 @@ module Rubik
|
|
|
387
402
|
# spawn=false (kapanış) zombi LLM doğumunu engeller. Konu-başı yalıtım
|
|
388
403
|
# (review #346): tek başarısız/red'li submit kalan konuları kaybettirmez —
|
|
389
404
|
# kayıplar @touched_topics'e geri alınır, bir sonraki flush noktası dener.
|
|
390
|
-
def auto_digest_touched!(spawn: true)
|
|
405
|
+
def auto_digest_touched!(spawn: true, goal_ordinal: nil)
|
|
391
406
|
return false unless @auto_digest && @distiller_enabled
|
|
392
407
|
|
|
393
408
|
spawn_allowed = spawn && !stopped?
|
|
@@ -395,10 +410,11 @@ module Rubik
|
|
|
395
410
|
return false if topics.empty?
|
|
396
411
|
|
|
397
412
|
stranded = topics.reject do |topic|
|
|
398
|
-
_receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed)
|
|
413
|
+
_receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed, goal_ordinal: goal_ordinal)
|
|
414
|
+
next true if status == :duplicate && Vakanuvis.log_duplicate_digest(@dmesg, topic, goal_ordinal) # F9: sessiz başarı değil
|
|
399
415
|
next true unless status == :full
|
|
400
416
|
|
|
401
|
-
Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next
|
|
417
|
+
Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next goal end or shutdown")
|
|
402
418
|
false
|
|
403
419
|
rescue StandardError => e
|
|
404
420
|
Vakanuvis.log_via(@dmesg, :warn, "auto-digest submit failed for '#{topic}' (#{e.class}: #{e.message})")
|
|
@@ -411,6 +427,15 @@ module Rubik
|
|
|
411
427
|
note += "; #{stranded.size} refused/failed topic(s) kept for the next flush point (warnings above)" unless stranded.empty?
|
|
412
428
|
Vakanuvis.log_via(@dmesg, :warn, note)
|
|
413
429
|
end
|
|
430
|
+
# Kapalı doğum: kapı :accepted dedi ama damıtıcı hiç doğamaz —
|
|
431
|
+
# uyarı yoksa topic'ler tüm oturum kuyrukta sessizce birikirdi.
|
|
432
|
+
if spawn_allowed && @gate.respond_to?(:spawn_allowed?) && !@gate.spawn_allowed?
|
|
433
|
+
queued = topics - stranded
|
|
434
|
+
unless queued.empty?
|
|
435
|
+
note = "auto-digest queued #{queued.size} touched topic(s) but no distiller can run (spawn disabled)"
|
|
436
|
+
Vakanuvis.log_via(@dmesg, :warn, "#{note} — they will not compile this session: #{queued.join(', ')}")
|
|
437
|
+
end
|
|
438
|
+
end
|
|
414
439
|
stranded.empty?
|
|
415
440
|
end
|
|
416
441
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module Storage
|
|
5
|
+
module Vakanuvis
|
|
6
|
+
module DistillGate
|
|
7
|
+
# Digest iş sözleşmesinin tek evi (tarama kartı 1, 2026-11-01 — defer #9
|
|
8
|
+
# kapanışı): kuyrukta iki ayrı Hash literal mint ediliyor, tüketiciler
|
|
9
|
+
# dört anahtarı [] ile okuyor ve öksüz :goal_id hiçbir mint sitesinde
|
|
10
|
+
# yazılmıyordu. Job bu şekli tiper: Struct#[] sembol okumasını korur
|
|
11
|
+
# (mevcut okuma siteleri değişmez), keyword_init mint sitelerini
|
|
12
|
+
# sözlüğe sabitler, var olmayan anahtar imkânsızlaşır.
|
|
13
|
+
#
|
|
14
|
+
# - query: damıtılacak konu sorgusu (toplu işlerde nil)
|
|
15
|
+
# - target: hedef wiki ref (Vocabulary.wiki_ref) — nil ⇒ toplu derleme;
|
|
16
|
+
# nil-target ayırt etme sözleşmesi değişmez, batch onun açık bayrağıdır
|
|
17
|
+
# - transcript_uris: kaynak disiplinine uyan tamamlanmış transcript URI'leri
|
|
18
|
+
# - goal_ordinal: goal-end auto-digest taşıyıcısı (Q2, 2026-11-01) — brief
|
|
19
|
+
# goal kaydını (annal.goal.<n> note'u) kaynak listesine ekler;
|
|
20
|
+
# kapanış ve toplu işlerde nil, satır brief'e hiç girmez
|
|
21
|
+
Job = Struct.new(:query, :target, :transcript_uris, :batch, :goal_ordinal, keyword_init: true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -69,6 +69,17 @@ module Rubik
|
|
|
69
69
|
existing["links"] == links.to_h { |k, v| [k.to_s, v.to_s] }.sort.to_h &&
|
|
70
70
|
existing["status"] == status.to_s
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Mükerrer otomatik-damıtım kabulü (issue #351 F9): "already queued"
|
|
74
|
+
# sessiz başarı sayılmaz — mevcut iş değişmez, bu goal kaydına
|
|
75
|
+
# bağlanmaz; iz satırı düşer. Dönen true çağrı yerindeki kısa-devre
|
|
76
|
+
# koşuluna hizmet eder (mükerrer stranded DEĞİLDİR, yeniden kuyruğa
|
|
77
|
+
# girmez — var olan iş hedefi zaten derliyor).
|
|
78
|
+
def log_duplicate_digest(dmesg, topic, goal_ordinal)
|
|
79
|
+
note = goal_ordinal ? "goal record ##{goal_ordinal} not attached; " : ""
|
|
80
|
+
log_via(dmesg, :warn, "auto-digest duplicate for '#{topic}' — #{note}existing job keeps its own context")
|
|
81
|
+
true
|
|
82
|
+
end
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
@@ -78,6 +89,7 @@ require_relative "vakanuvis/annal_writer"
|
|
|
78
89
|
require_relative "vakanuvis/memory_steward"
|
|
79
90
|
require_relative "vakanuvis/wiki_skeleton"
|
|
80
91
|
require_relative "vakanuvis/distill_gate"
|
|
92
|
+
require_relative "vakanuvis/job"
|
|
81
93
|
require_relative "vakanuvis/gate"
|
|
82
94
|
require_relative "vakanuvis/harness"
|
|
83
95
|
require_relative "vakanuvis/orientation"
|
|
@@ -5,6 +5,59 @@ module Rubik
|
|
|
5
5
|
module Truncator
|
|
6
6
|
DEFAULT_MAX_CHARS = 12_000
|
|
7
7
|
DEFAULT_MAX_LINES = 300
|
|
8
|
+
# The strategy knob's default lives here (arch scan card 5, 2026-11-01):
|
|
9
|
+
# both orchestrator planes read tools.truncation_strategy with this
|
|
10
|
+
# fallback — one literal for the whole repo instead of restated copies.
|
|
11
|
+
DEFAULT_STRATEGY = :head_tail
|
|
12
|
+
# Known-strategy set (issue #349): a typo'd config value used to die
|
|
13
|
+
# silently into the head_tail `else` branch in both planes. This set
|
|
14
|
+
# is the honest frontier — resolve_strategy warns against it, and a
|
|
15
|
+
# new strategy added to #truncate must register here to be reachable.
|
|
16
|
+
STRATEGIES = [:head_tail, :head_only, :tail_only].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Configured-value resolver for the strategy fetch sites (root
|
|
20
|
+
# StepExecutor + subagent ToolDispatch + prompt EmergencyCompactor).
|
|
21
|
+
# nil/false/empty/missing fall back to the default SILENTLY (that
|
|
22
|
+
# path is correct today); a non-empty value outside STRATEGIES warns
|
|
23
|
+
# ONCE per distinct knob+value pair, then keeps the default — the
|
|
24
|
+
# operator "tuning" truncation with a typo must not get
|
|
25
|
+
# byte-identical old behavior in silence. `key:` names the config
|
|
26
|
+
# knob in the warn so each caller's message points at its own
|
|
27
|
+
# setting, and the frontier dedups on the pair: the same typo under
|
|
28
|
+
# two different knobs must warn twice, or fixing one knob leaves
|
|
29
|
+
# the other misconfigured in silence.
|
|
30
|
+
def resolve_strategy(value, key: "tools.truncation_strategy")
|
|
31
|
+
return DEFAULT_STRATEGY if value.nil? || value == false || value.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
sym = value.to_s.downcase.to_sym
|
|
34
|
+
return sym if STRATEGIES.include?(sym)
|
|
35
|
+
|
|
36
|
+
warn_unknown_strategy(value, key)
|
|
37
|
+
DEFAULT_STRATEGY
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# One-time-per-knob-and-value warn (UI::Theme#paint precedent):
|
|
43
|
+
# the first bad value speaks, re-occurrences (every tool result
|
|
44
|
+
# would re-resolve) stay quiet. Warn level, not debug: a config
|
|
45
|
+
# typo is operator-visible in normal operation. The value is
|
|
46
|
+
# forced to UTF-8 and scrubbed BEFORE interpolation — scrub alone
|
|
47
|
+
# is a no-op on BINARY strings — because Log.warn's rescue cannot
|
|
48
|
+
# reach a message that fails to build, and a binary-encoding
|
|
49
|
+
# config value must never turn tool results into errors.
|
|
50
|
+
def warn_unknown_strategy(value, key)
|
|
51
|
+
shown = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
52
|
+
return unless unknown_strategies.add?([key, shown])
|
|
53
|
+
|
|
54
|
+
Rubik::Log.warn(:tools, "#{key}='#{shown}' is not a known strategy (#{STRATEGIES.join('/')}) — using #{DEFAULT_STRATEGY}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unknown_strategies
|
|
58
|
+
@unknown_strategies ||= Set.new
|
|
59
|
+
end
|
|
60
|
+
end
|
|
8
61
|
|
|
9
62
|
def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail, separator: "\n")
|
|
10
63
|
str = text.to_s
|
data/lib/rubik/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubik
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Özgür Ulukır
|
|
@@ -267,6 +267,7 @@ files:
|
|
|
267
267
|
- lib/rubik/benchmark.rb
|
|
268
268
|
- lib/rubik/benchmark/runner.rb
|
|
269
269
|
- lib/rubik/benchmark/synth_repo.rb
|
|
270
|
+
- lib/rubik/benchmark/vakanuvis_adoption.rb
|
|
270
271
|
- lib/rubik/buffer.rb
|
|
271
272
|
- lib/rubik/buffer/apply_contract.rb
|
|
272
273
|
- lib/rubik/buffer/diff.rb
|
|
@@ -720,6 +721,7 @@ files:
|
|
|
720
721
|
- lib/rubik/storage/vakanuvis/distill_gate.rb
|
|
721
722
|
- lib/rubik/storage/vakanuvis/gate.rb
|
|
722
723
|
- lib/rubik/storage/vakanuvis/harness.rb
|
|
724
|
+
- lib/rubik/storage/vakanuvis/job.rb
|
|
723
725
|
- lib/rubik/storage/vakanuvis/memory_steward.rb
|
|
724
726
|
- lib/rubik/storage/vakanuvis/orientation.rb
|
|
725
727
|
- lib/rubik/storage/vakanuvis/service.rb
|