diff --git a/apps/desktop/assets/codex-bridge/README.md b/apps/desktop/assets/codex-bridge/README.md index f27a3e1..65da265 100644 --- a/apps/desktop/assets/codex-bridge/README.md +++ b/apps/desktop/assets/codex-bridge/README.md @@ -36,25 +36,33 @@ decision always keeps the current path. - `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`, `verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`. - The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model` and - `bekci-sesli-bekci` always pass. + The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`, + `bekci-sesli-bekci` and `Sesli-Hizli-Zeki` always pass. - **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded - name `Sesli-Hizli-Zeki`; any exact target in the list owns it. + name `Sesli-Hizli-Zeki`; any exact target in the list owns it, but only while it always passes the check + (a checked answer may be replaced by a layer, and a receipt must not belong to an answer the user never saw). + A single SSE frame above 256 KB no longer breaks the stream; the rest of the stream passes unfiltered. - **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first. -- **Automatic model** (`autoRoute`): a virtual `ide/` entry ("Otomatik") in the Codex list. Each new - user message gets one decision call (hard? research?) and goes to the configured combo. Tool steps of - the same turn keep that combo, because switching providers mid-turn breaks encrypted history. The - user's explicit model choice is never changed by this stage. -- Both turn-start questions go in one call, bounded by `timeoutSeconds` (default 3 s). -- **Layer window filter**: a layer whose context window cannot hold the request is skipped. +- **Automatic model** (`autoRoute`): a virtual `ide/` entry ("Otomatik") in the Codex list. A new turn + (a user message after the previous final answer) gets one decision call (hard? research?) and goes to + the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on + the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or + fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's + explicit model choice is never changed by this stage. +- Both turn-start questions go in one call; the wait, including the local queue, is bounded by + `timeoutSeconds` (default 3 s). +- **Layer window filter**: a layer whose trusted context window (pencere.bekci_calisir) cannot hold the + request is skipped. The last link of the chain is never skipped. - **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer. - If the answer stopped early or claimed success without evidence, it goes back to the same model once - with `finishCheck.note`; the retry is delivered without another finish question. + If the answer stopped early or claimed success without evidence, it goes back to the same model with + `finishCheck.note`, at most once per turn. If the retry breaks, comes back empty or fails the check, the + first (already approved) answer is delivered. - **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every `refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`. - The transition chain and the always-pass list stay local. A turn never waits for Dify. + The transition chain and the always-pass list stay local. A turn never waits for Dify, and a redirect + from the Dify endpoint is refused so the app key never travels to another host. - **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked. diff --git a/apps/desktop/assets/codex-bridge/jev-settings-defaults.json b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json index 17848a7..ba9e363 100644 --- a/apps/desktop/assets/codex-bridge/jev-settings-defaults.json +++ b/apps/desktop/assets/codex-bridge/jev-settings-defaults.json @@ -7,7 +7,7 @@ { "from": "Katman-1", "to": "Katman-2" }, { "from": "Katman-2", "to": "Katman-3" } ], - "exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Katman-3"], + "exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Sesli-Hizli-Zeki", "Katman-3"], "thresholds": { "ret": 0.5, "sart": 0.7 }, "questions": { "ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.", @@ -41,8 +41,7 @@ "enabled": false, "question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.", "threshold": 0.6, - "note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster.", - "timeoutSeconds": 5 + "note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster." }, "verifierCheck": { "enabled": false, diff --git a/apps/desktop/assets/codex-bridge/jev_denetim.py b/apps/desktop/assets/codex-bridge/jev_denetim.py index 3531f98..34f65c5 100644 --- a/apps/desktop/assets/codex-bridge/jev_denetim.py +++ b/apps/desktop/assets/codex-bridge/jev_denetim.py @@ -38,11 +38,14 @@ SORU = { _ANAHTAR = {"okundu": False, "deger": ""} def izleme_mi(hedef): - """Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi.""" + """Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi. + Jev acikken yalniz muaf hedefte: denetlenen cevap baska katmana gecebilir, makbuz kullanicinin gormedigi + cevaba baglanmasin.""" try: - return hedef in jev_settings.read()[0]['watchModels'] + s = jev_settings.read()[0] + return hedef in s['watchModels'] and (not s['enabled'] or hedef in s['exemptModels']) except Exception: - return hedef == "Sesli-Hizli-Zeki" + return False def muaf_mi(hedef): @@ -125,18 +128,23 @@ def son_istek(req): return son_istek_sirasi(req)[0] +def istek_metni(oge): + """Bir kullanici mesajindaki istek: sesli aktarimda icindeki soz; sarmalli sistem mesaji ise "".""" + if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"): + return "" + metin = _metin(oge).strip() + m = re.search(r".*?(.*?)", metin, re.S) + if m: + return m.group(1).strip() + return metin if metin and not metin.startswith("<") else "" + + def son_istek_sirasi(req): """(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1).""" girdi = req.get("input") or [] for sira in range(len(girdi) - 1, -1, -1): - oge = girdi[sira] - if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"): - continue - metin = _metin(oge).strip() - m = re.search(r".*?(.*?)", metin, re.S) - if m: - return m.group(1).strip(), sira - if metin and not metin.startswith("<"): + metin = istek_metni(girdi[sira]) + if metin: return metin, sira return "", -1 @@ -202,7 +210,7 @@ def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None): return None govde = {"model": settings['model'], "state": durum, "questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}} - if not jev_settings.acquire(settings): + if not jev_settings.acquire(settings, wait=timeout): return None try: response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key) diff --git a/apps/desktop/assets/codex-bridge/jev_settings.py b/apps/desktop/assets/codex-bridge/jev_settings.py index 2efb9a9..e014a9a 100644 --- a/apps/desktop/assets/codex-bridge/jev_settings.py +++ b/apps/desktop/assets/codex-bridge/jev_settings.py @@ -87,11 +87,11 @@ def validate(settings): and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma')) and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))): raise ValueError('Invalid automatic routing') - if a['model'] in a['targets'].values(): + if a['model'] in a['targets'].values() or p['target'] == a['model']: raise ValueError('Automatic routing cannot target itself') f = settings['finishCheck'] if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold')) - and _text(f.get('note'), 4000) and _int(f.get('timeoutSeconds'), 1, 60)): + and _text(f.get('note'), 4000)): raise ValueError('Invalid finish check') v = settings['verifierCheck'] if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))): @@ -186,15 +186,27 @@ def write_policy(result): record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt') elif result.get('ok'): record['lastGoodAt'] = record['fetchedAt'] - tmp = path.with_name(path.name + f'.{os.getpid()}.tmp') + tmp = path.with_name(path.name + f'.{os.getpid()}.{threading.get_ident()}.tmp') tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8') - os.replace(tmp, path) + try: + for deneme in range(5): + try: + os.replace(tmp, path) # Windows: a reader holding the file makes this fail briefly + break + except PermissionError: + if deneme == 4: + raise + time.sleep(0.05 * (deneme + 1)) + finally: + if tmp.exists(): + tmp.unlink() return record -def acquire(settings): +def acquire(settings, wait=None): + """wait: at most this many seconds in the queue (turn-start questions must not hold a request).""" global _active - deadline = time.monotonic() + settings['queueWaitSeconds'] + deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else settings['queueWaitSeconds']) with _condition: while settings['maxConcurrent'] and _active >= settings['maxConcurrent']: remaining = deadline - time.monotonic() diff --git a/apps/desktop/assets/codex-bridge/karar_politikasi.py b/apps/desktop/assets/codex-bridge/karar_politikasi.py index e5dd149..a29a579 100644 --- a/apps/desktop/assets/codex-bridge/karar_politikasi.py +++ b/apps/desktop/assets/codex-bridge/karar_politikasi.py @@ -13,11 +13,21 @@ jev_settings.POLICY_KEYS, each holding the same fields as the local settings sec import copy import json import time +import urllib.error import urllib.request import jev_settings UA = "AITURK-IDE-codex-bridge/karar-politikasi" + + +class _YonlendirmeYok(urllib.request.HTTPRedirectHandler): + """A redirect would carry the Authorization header to another host; it is an error instead.""" + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise urllib.error.HTTPError(req.full_url, code, f"redirect refused: {newurl}", headers, fp) + + +_ACICI = urllib.request.build_opener(_YonlendirmeYok) MAX_BYTES = 64 * 1024 _son = {"zaman": 0.0} @@ -43,7 +53,7 @@ def ayikla(cikti, yerel): return overrides -def cek(settings, key, urlopen=urllib.request.urlopen): +def cek(settings, key, urlopen=_ACICI.open): """One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises.""" if not key: return {"ok": False, "error": "Dify key not configured"} @@ -70,7 +80,7 @@ def zamani_mi(settings, simdi=None): return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60 -def yenile(log=lambda _m: None, urlopen=urllib.request.urlopen, zorla=False): +def yenile(log=lambda _m: None, urlopen=_ACICI.open, zorla=False): """Fetch when due; store the outcome. Local settings are read without the Dify overlay.""" try: yerel, _ = _yerel_ayar() diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index e80fe56..b6bd4b6 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -245,6 +245,7 @@ def load_ide_catalog(ag=True): for h in set(oto["targets"].values())), "arac": True, "kaynak": "", "claude": False} model_map[slug] = (IDE_ROUTE, oto["model"], None) + OTO_ADI[0] = oto["model"] n += 1 else: ROUTES.pop(IDE_ROUTE, None) @@ -263,68 +264,115 @@ def _oto_ayari(): return None return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None -# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun arac adimlari ayni karari kullanir. +# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun adimlari ayni karari kullanir. ON_KARAR = {} ON_KARAR_SINIR = 500 +BITIS_TURLARI = {} # finishCheck kullanilan turlar (turda bir kez) +KARAR_KILIDI = threading.Lock() +OTO_ADI = ["Otomatik"] # katalogdaki sanal modelin son adi (karar asamasi hata verirse disari cikmasin) # Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler); # ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir. load_ide_catalog(ag=False) +def tur_bilgisi(req, sohbet): + """(sohbet koku, tur anahtari, tur basindaki istek, yeni tur mu). Tur, calisan bir turun icine dusmeyen + kullanici mesajiyla baslar. Arac adimlari, tur ortasinda yazilan mesaj, tek "." ve ozet istegi ayni turdadir; + yeni tur yalniz son oge yeni bir kullanici istegi oldugunda baslar.""" + girdi = req.get("input") or [] + kok = sohbet or "anon:" + hashlib.sha256(json.dumps(girdi[:1], ensure_ascii=False, sort_keys=True, + default=str).encode("utf-8", "replace")).hexdigest() + bas, istek = -1, "" + for i in range(len(girdi) - 1, -1, -1): + metin = jev_denetim.istek_metni(girdi[i]) + if not metin or metin == ".": + continue + if not istek: + bas, istek = i, metin # tur basi bulunamazsa en son gercek istek + if not model_gecis.tur_ortasi(req, i): + bas, istek = i, metin + break + son = model_gecis.son_sira(girdi) + yeni = son >= 0 and son == bas and not model_gecis.tur_ortasi(req, son) + tur = hashlib.sha256(f"{kok}\0{bas}\0{istek}".encode("utf-8", "replace")).hexdigest() + return kok, tur, istek, yeni + + def karar_asamasi(n, req, sohbet, slug): """14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi - tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar tur basinda bir kez verilir; ayni turun - arac adimlari ayni karari kullanir (her adimda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar, - bos istek ya da karar modeli hatasi bugunku yolu degistirmez (Otomatik icin guclu hedef secilir).""" + tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun + adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar, + bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez.""" if not slug.startswith(IDE_PREFIX): return slug settings = jev_settings.read()[0] + a, p = settings["autoRoute"], settings["prefilter"] + sanal = slug == IDE_PREFIX + a["model"] + kok, tur, istek, yeni = tur_bilgisi(req, sohbet) + with KARAR_KILIDI: + kayit = ON_KARAR.get(kok) + if sanal and not (settings["enabled"] and a["enabled"]): + # Kapatilmis Otomatik: son karar ya da guclu hedef; sanal ad gecide gitmez. + secilen = kayit[2] if kayit and kayit[1] == slug else IDE_PREFIX + a["targets"]["zor"] + log(f"[{n} {slug}] Otomatik kapali; bu adim {secilen} modeline gidiyor") + req["model"] = secilen + return secilen if not settings["enabled"]: return slug - a, p = settings["autoRoute"], settings["prefilter"] - oto = a["enabled"] and slug == IDE_PREFIX + a["model"] hedef = slug[len(IDE_PREFIX):] - ret = p["enabled"] and (oto or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"] - or hedef in jev_denetim.sonrakiler(p["target"]))) - if not oto and not ret: + ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"] + or hedef in jev_denetim.sonrakiler(p["target"]))) + if not sanal and not ret: return slug - istek, sira = jev_denetim.son_istek_sirasi(req) - # Tur = son kullanici mesaji (metni ve girdideki yeri); ayni turun arac adimlarinda degismez. - tur = hashlib.sha256(f"{sohbet}\0{sira}\0{istek}".encode("utf-8", "replace")).hexdigest() - kayit = ON_KARAR.get(sohbet) if sohbet else None - if kayit and kayit[0] == tur and kayit[1] == slug: + if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni): secilen = kayit[2] else: if istek: t0 = time.time() - sonuc = jev_denetim.on_karar(istek, oto, ret, jev_denetim.anahtar()) + sonuc = jev_denetim.on_karar(istek, sanal, ret, jev_denetim.anahtar()) puan = sonuc["puan"] log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): " + (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi")) else: - sonuc = {"hedef": a["targets"]["zor"] if oto else None, "ret": None} - if oto: + sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None} + if sanal: hedef = sonuc["hedef"] secilen = IDE_PREFIX + hedef if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"] and hedef not in jev_denetim.sonrakiler(p["target"])): log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor") secilen = IDE_PREFIX + p["target"] - if sohbet: - ON_KARAR[sohbet] = (tur, slug, secilen) + req["model"] = secilen + with KARAR_KILIDI: + ON_KARAR.pop(kok, None) # yeniden ekleme sirayi tazeler: aktif sohbet en son silinir + ON_KARAR[kok] = (tur, slug, secilen) while len(ON_KARAR) > ON_KARAR_SINIR: - ON_KARAR.pop(next(iter(ON_KARAR))) + ON_KARAR.pop(next(iter(ON_KARAR)), None) if secilen != slug: log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor") req["model"] = secilen return secilen + +def bitis_kullan(tur): + """finishCheck turda en fazla bir kez: ilk cagri True (ve isaretler), sonrakiler False.""" + with KARAR_KILIDI: + if tur in BITIS_TURLARI: + return False + BITIS_TURLARI[tur] = time.time() + while len(BITIS_TURLARI) > ON_KARAR_SINIR: + BITIS_TURLARI.pop(next(iter(BITIS_TURLARI)), None) + return True + + def sigan_katmanlar(sira, req, tag): - """Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin).""" + """Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin). Yalniz penceresi + guvenilir (pencere.bekci_calisir) katman atlanir; zincirin son halkasi hic atlanmaz.""" tahmin, sonuc = None, [] - for ad in sira: - p = (IDE_BILGI.get(IDE_PREFIX + ad) or {}).get("pencere") - if p: + for i, ad in enumerate(sira): + bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {} + p = bilgi.get("pencere") + if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p): if tahmin is None: tahmin = pencere.tahmini_token(req, 2.0) if tahmin > int(p * 0.90): @@ -1059,6 +1107,7 @@ class H(http.server.BaseHTTPRequestHandler): self._akis_basligi(resp, resp.status) kalan, son_yazi, son_govde = list(sira), time.time(), b"" devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir + yedek = None # geri verilen istek basarisiz olursa teslim edilecek, Jev'den gecmis ilk cevap def w(b): if b: @@ -1129,6 +1178,7 @@ class H(http.server.BaseHTTPRequestHandler): yeni_devam = None log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})") if yeni_devam is not None: + yedek = (parcalar, rewriter, tag) resp, rewriter, tag = yeni_devam log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi") continue @@ -1138,6 +1188,11 @@ class H(http.server.BaseHTTPRequestHandler): gecmedi = True log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: " f"{b''.join(parcalar)[-200:]!r}") + if gecmedi and yedek is not None: + # Geri verilen istek koptu, bos dondu ya da gecmedi: Jev'den gecmis ilk cevap teslim edilir. + parcalar, rewriter, tag = yedek + gecmedi = False + log(f"{tag} bitis denetimi: geri verilen istek kullanilamadi, ilk cevap teslim ediliyor") yeni = None while gecmedi and kalan and yeni is None: ad = kalan.pop(0) @@ -1670,6 +1725,12 @@ class H(http.server.BaseHTTPRequestHandler): slug = karar_asamasi(n, req, sohbet, slug) except Exception as e: log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}") + try: + if slug == IDE_PREFIX + OTO_ADI[0]: + # Sanal ad gecide gitmez: varsayilan guclu hedef. + slug = req["model"] = IDE_PREFIX + jev_settings.DEFAULTS["autoRoute"]["targets"]["zor"] + except Exception: + pass mapping = MODEL_MAP.get(slug) if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])): # Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida @@ -1862,14 +1923,19 @@ class H(http.server.BaseHTTPRequestHandler): # secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz # (rewriter yok) yolda kapalidir. jev_rota = ROUTES.get(ide_route) - jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar()) - jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else [] + try: + jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar()) + jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else [] + jev_istek = jev_denetim.son_istek(req) if jev_etkin else "" + except Exception as e: + # Okunamayan ayar istegi dusurmez: bu adim denetimsiz gecer. + log(f"{tag} katman denetimi ayari okunamadi, denetimsiz devam: {type(e).__name__}: {e}") + jev_etkin, jev_sira, jev_istek = False, [], "" if jev_sira: try: jev_sira = sigan_katmanlar(jev_sira, req, tag) except Exception as e: log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}") - jev_istek = jev_denetim.son_istek(req) if jev_etkin else "" def jev_ac(ad): katman_req = json.loads(data) @@ -1883,9 +1949,12 @@ class H(http.server.BaseHTTPRequestHandler): f"[{n} {slug}=>{ad}]") def jev_devam(ad, onceki): - """finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler.""" + """finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler. + Turda en fazla bir kez (bitis_kullan).""" if ad == target and route_name != ide_route: raise ValueError("hedef AITURK gecidinde degil") + if not bitis_kullan(bitis_turu): + raise ValueError("bu turda zaten geri verildi") devam_req = json.loads(data) devam_req["model"] = ad devam_req.setdefault("input", []).extend([ @@ -1900,7 +1969,12 @@ class H(http.server.BaseHTTPRequestHandler): return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names), f"[{n} {slug}=>{ad} devam]") try: + bitis_turu = tur_bilgisi(req, sohbet)[1] jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None + if jev_bitis is not None: + with KARAR_KILIDI: + if bitis_turu in BITIS_TURLARI: + jev_bitis = None # bu turda bir kez geri verildi except Exception: jev_bitis = None # A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may @@ -1994,13 +2068,24 @@ class WatchResponse: def __init__(self, response, accept): self.response, self.filter = response, MetadataFilter(accept) self.done = False + self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer def __getattr__(self, name): return getattr(self.response, name) def read1(self, size): + if self.gecis: + return self.response.read1(size) while not self.done: raw = self.response.read1(size) if raw: - data = self.filter.feed(raw) + try: + data = self.filter.feed(raw) + except ValueError: + # 256 KB'i asan tek cerceve: akisi dusurmek yerine suzgecsiz devam (yan kanal cercevesi + # bu boyda olmaz). Tamponda yan kanal varsa disari cikmaz. + data, self.filter.frames.buf = self.filter.frames.buf, b'' + self.gecis = True + log("izleme suzgeci: cok buyuk SSE cercevesi, kalan akis suzgecsiz geciyor") + return b'' if b'bekci.watch' in data else data if data: return data else: self.done = True diff --git a/apps/desktop/assets/codex-bridge/test_karar_asamalari.py b/apps/desktop/assets/codex-bridge/test_karar_asamalari.py index 6fe3bd1..7e2f82e 100644 --- a/apps/desktop/assets/codex-bridge/test_karar_asamalari.py +++ b/apps/desktop/assets/codex-bridge/test_karar_asamalari.py @@ -5,6 +5,7 @@ import copy import hashlib import io import json +import threading import tempfile import time import unittest @@ -205,10 +206,12 @@ class TurBasi(Ev): sahte_denetim.son_istek = jev_denetim.son_istek self.cevap = {'hedef': None, 'ret': None, 'puan': None} sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi + sahte_denetim.istek_metni = jev_denetim.istek_metni self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings, 'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time, - 'log': self.logs.append} - exec(compile(ast.Module(body=[fonksiyon('karar_asamasi')], type_ignores=[]), 'kopru.py', 'exec'), self.env) + 'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append} + govde = [fonksiyon(a) for a in ('tur_bilgisi', 'karar_asamasi', 'bitis_kullan')] + exec(compile(ast.Module(body=govde, type_ignores=[]), 'kopru.py', 'exec'), self.env) self.karar = self.env['karar_asamasi'] def on_karar(self, istek, oto, ret, key): @@ -220,8 +223,10 @@ class TurBasi(Ev): def test_everything_off_changes_nothing_and_asks_nothing(self): self.save() - req = self.istek('ide/Otomatik', user('yap')) - self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik') + for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'): + req = self.istek(model, user('yap')) + self.assertEqual(self.karar(1, req, 'sohbet-1', model), model) + self.assertEqual(req['model'], model) self.assertEqual(self.kararlar, []) def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self): @@ -235,7 +240,8 @@ class TurBasi(Ev): req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac) self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma') self.assertEqual(len(self.kararlar), 1) # same turn: no second question - req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, user('simdi kodu duzelt')) + son_cevap = {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Rapor hazir.'}]} + req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, son_cevap, user('simdi kodu duzelt')) self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}} self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan') self.assertEqual(len(self.kararlar), 2) # new user message: new decision @@ -272,9 +278,52 @@ class TurBasi(Ev): self.settings['autoRoute']['enabled'] = True self.save() req = self.istek('ide/Otomatik', user('yap')) - self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik') + # The virtual name never leaves the bridge; nothing is asked while the main switch is off. + self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/' + self.settings['autoRoute']['targets']['zor']) self.assertEqual(self.kararlar, []) + def test_a_message_typed_mid_turn_or_a_dot_keeps_the_choice(self): + self.settings['autoRoute']['enabled'] = True + self.save() + self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}} + bas = user('kaynaklari karsilastir') + cagri = {'type': 'function_call', 'call_id': 'c1', 'name': 'shell', 'arguments': '{}'} + cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'} + self.assertEqual(self.karar(1, self.istek('ide/Otomatik', bas), 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma') + self.cevap = {'hedef': 'Workflow-Kesif', 'ret': None, 'puan': {'zor': 0.1, 'arastirma': 0.1}} + for ek in (user('.'), user('bir de sunu ekle'), {'type': 'compaction_trigger'}): + req = self.istek('ide/Otomatik', bas, cagri, cikti, ek) + self.assertEqual(self.karar(2, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma', ek) + self.assertEqual(len(self.kararlar), 1) + + def test_a_disabled_automatic_model_never_reaches_the_gateway(self): + self.save() # autoRoute off + req = self.istek('ide/Otomatik', user('yap')) + secilen = self.karar(1, req, 'sohbet-1', 'ide/Otomatik') + self.assertEqual(secilen, 'ide/' + self.settings['autoRoute']['targets']['zor']) + self.assertEqual(req['model'], secilen) + self.assertEqual(self.kararlar, []) + self.settings['autoRoute']['enabled'] = True + self.settings['enabled'] = False # main switch off + self.save() + self.assertNotEqual(self.karar(2, self.istek('ide/Otomatik', user('yap')), 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik') + + def test_without_a_conversation_key_the_tool_steps_still_keep_the_choice(self): + self.settings['autoRoute']['enabled'] = True + self.save() + self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}} + bas = user('kodu duzelt') + self.assertEqual(self.karar(1, {'model': 'ide/Otomatik', 'input': [bas]}, '', 'ide/Otomatik'), 'ide/Ajan') + cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'} + req = {'model': 'ide/Otomatik', 'input': [bas, {'type': 'function_call', 'call_id': 'c1'}, cikti]} + self.assertEqual(self.karar(2, req, '', 'ide/Otomatik'), 'ide/Ajan') + self.assertEqual(len(self.kararlar), 1) + + def test_the_finish_retry_is_used_once_per_turn(self): + self.assertTrue(self.env['bitis_kullan']('tur-1')) + self.assertFalse(self.env['bitis_kullan']('tur-1')) + self.assertTrue(self.env['bitis_kullan']('tur-2')) + def test_turn_memory_is_bounded(self): self.settings['prefilter']['enabled'] = True self.save() @@ -285,15 +334,25 @@ class TurBasi(Ev): class KatmanPenceresi(unittest.TestCase): - def test_a_layer_too_small_for_the_request_is_skipped(self): + def suzgec(self, bilgi): logs = [] - env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': {'ide/Katman-2': {'pencere': 1000}, 'ide/Katman-3': {'pencere': 10 ** 6}}, - 'pencere': pencere, 'log': logs.append} + env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append} exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env) + return env['sigan_katmanlar'], logs + + def test_a_layer_too_small_for_the_request_is_skipped(self): + f, logs = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 10 ** 6}}) req = {'input': [user('x' * 20000)]} - self.assertEqual(env['sigan_katmanlar'](['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen']) + self.assertEqual(f(['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen']) self.assertIn('Katman-2', logs[0]) + def test_the_last_layer_and_untrusted_windows_are_never_skipped(self): + req = {'input': [user('x' * 20000)]} + f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 1000, 'kaynak': 'katalog'}}) + self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-3']) # last one stays + f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'varsayilan'}}) + self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-2', 'Katman-3']) # placeholder window + class BitisDenetimi(Ev): """_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once.""" @@ -372,6 +431,17 @@ class BitisDenetimi(Ev): self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk) self.assertIn(b'Bitti.', self.gonderilen()) + def test_a_broken_or_refused_retry_delivers_the_approved_first_answer(self): + for ikinci in (sse(), sse('Yapamam.')): + with self.subTest(ikinci=ikinci[:40]): + self.setUp() + self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.9, 'sart': 0.0}] + ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]')) + self.h._denetimli_akis(Yanit(sse('Ilk cevap.')), '[1]', None, None, 'yap', ['Katman-1'], ac, + hedef='Ajan', devam=lambda ad, onceki: (Yanit(ikinci), None, '[devam]')) + self.assertIn(b'Ilk cevap.', self.gonderilen()) + ac.assert_not_called() + def test_without_the_stage_the_finish_question_is_not_sent(self): govdeler = [] @@ -387,9 +457,6 @@ class BitisDenetimi(Ev): self.assertFalse(jev_denetim.bitmedi_mi(puan)) -if __name__ == '__main__': - unittest.main() - class DogrulayiciElemesi(Ev): def test_off_by_default_and_asked_only_when_enabled(self): @@ -416,3 +483,68 @@ class DogrulayiciElemesi(Ev): import io as _io import karar_sor self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2) + + +class Inceleme(Ev): + def test_the_correction_channel_needs_an_always_pass_target_while_checks_are_on(self): + self.settings['watchModels'] = ['Chat-Model', 'Ajan'] + self.save() + self.assertTrue(jev_denetim.izleme_mi('Chat-Model')) + self.assertFalse(jev_denetim.izleme_mi('Ajan')) # checked target: no sideband + self.settings['enabled'] = False + self.save() + self.assertTrue(jev_denetim.izleme_mi('Ajan')) + + def test_a_turn_start_question_does_not_wait_in_the_queue_beyond_its_timeout(self): + self.settings['maxConcurrent'] = 1 + self.settings['queueWaitSeconds'] = 3600 + self.save() + s = jev_settings.read()[0] + self.assertTrue(jev_settings.acquire(s)) # the only slot is taken + try: + t0 = time.monotonic() + self.assertIsNone(jev_denetim.sorular({'user_request': 'x'}, {'zor': 'q'}, 'k', 1, gonder=lambda g, k: {})) + self.assertLess(time.monotonic() - t0, 3) + finally: + jev_settings.release() + + def test_the_prefilter_target_cannot_be_the_automatic_model(self): + self.settings['prefilter']['target'] = self.settings['autoRoute']['model'] + self.save() + with self.assertRaises(ValueError): + jev_settings.read() + + def test_dify_redirects_are_refused_so_the_key_never_moves(self): + import urllib.error + with self.assertRaises(urllib.error.HTTPError): + karar_politikasi._YonlendirmeYok().redirect_request( + Mock(full_url='https://dify.example/v1/workflows/run'), None, 302, 'Found', {}, 'http://evil.example/') + + +class IzlemeSuzgeci(unittest.TestCase): + def test_a_huge_frame_passes_through_instead_of_breaking_the_stream(self): + tree_sinif = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == 'WatchResponse') + from bekci_watch import MetadataFilter + logs = [] + env = {'MetadataFilter': MetadataFilter, 'log': logs.append} + exec(compile(ast.Module(body=[tree_sinif], type_ignores=[]), 'kopru.py', 'exec'), env) + buyuk = b'data: ' + b'x' * 300000 + parcalar = [buyuk[:200000], buyuk[200000:], b'\n\n', b''] + + class Ham: + def read1(self, n): + return parcalar.pop(0) + w = env['WatchResponse'](Ham(), lambda m: None) + cikti = b'' + while True: + b = w.read1(65536) + if not b and w.done or not parcalar and not b: + break + cikti += b + self.assertIn(b'x' * 1000, cikti) + self.assertEqual(cikti.count(b'x'), 300000) + self.assertTrue(logs) + + +if __name__ == '__main__': + unittest.main() diff --git a/apps/desktop/electron/staged-transition-types.ts b/apps/desktop/electron/staged-transition-types.ts index 95ef7aa..d04b0ec 100644 --- a/apps/desktop/electron/staged-transition-types.ts +++ b/apps/desktop/electron/staged-transition-types.ts @@ -23,7 +23,7 @@ export interface StagedTransitionSettings { thresholds: { zor: number; arastirma: number } targets: { basit: string; zor: string; arastirma: string } } - finishCheck: { enabled: boolean; question: string; threshold: number; note: string; timeoutSeconds: number } + finishCheck: { enabled: boolean; question: string; threshold: number; note: string } verifierCheck: { enabled: boolean; question: string; threshold: number } policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number } } diff --git a/apps/desktop/electron/staged-transition.test.ts b/apps/desktop/electron/staged-transition.test.ts index 00f68fd..2adb492 100644 --- a/apps/desktop/electron/staged-transition.test.ts +++ b/apps/desktop/electron/staged-transition.test.ts @@ -106,5 +106,12 @@ describe('staged transition settings', () => { expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow() expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow() expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow() + for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) { + expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow() + expect(() => validateStagedTransition({ ...base, endpoint })).toThrow() + } + expect(() => + validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } }) + ).toThrow() }) }) diff --git a/apps/desktop/electron/staged-transition.ts b/apps/desktop/electron/staged-transition.ts index 75f05b8..109cead 100644 --- a/apps/desktop/electron/staged-transition.ts +++ b/apps/desktop/electron/staged-transition.ts @@ -53,13 +53,21 @@ const probability = (n: unknown): n is number => typeof n === 'number' && Number const integer = (n: unknown, min: number, max: number): n is number => typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max +// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone +// also accepts "https:host" and "https:/host", which the bridge rejects. function https(value: unknown): value is string { - if (!text(value, 2048)) { + if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) { return false } - const url = new URL(value) + let url: URL - return url.protocol === 'https:' && !url.username && !url.password && !url.hash + try { + url = new URL(value.trim()) + } catch { + return false + } + + return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash } export function validateStagedTransition(input: unknown): StagedTransitionSettings { @@ -160,7 +168,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin throw new Error('Invalid automatic routing') } - if (Object.values(a.targets).some(t => t.trim() === a.model.trim())) { + if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) { throw new Error('Automatic routing cannot target itself') } @@ -170,8 +178,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin typeof f?.enabled !== 'boolean' || !text(f.question) || !probability(f.threshold) || - !text(f.note, 4000) || - !integer(f.timeoutSeconds, 1, 60) + !text(f.note, 4000) ) { throw new Error('Invalid finish check') } @@ -214,13 +221,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma }, targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() } }, - finishCheck: { - enabled: f.enabled, - question: f.question, - threshold: f.threshold, - note: f.note, - timeoutSeconds: f.timeoutSeconds - }, + finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note }, verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold }, policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes } }