Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4037c65204 | ||
|
|
e7d3080e0a | ||
|
|
db5e876f40 | ||
|
|
ab7a721fbc | ||
|
|
2cca6a66a6 |
No files matched your search
@@ -23,6 +23,9 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ripgrep
|
||||
rg --version
|
||||
python3 -m venv "$RUNNER_TEMP/bootstrap"
|
||||
"$RUNNER_TEMP/bootstrap/bin/pip" install uv
|
||||
"$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv"
|
||||
@@ -32,8 +35,10 @@ jobs:
|
||||
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
|
||||
- name: Codex bridge regression tests
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
|
||||
- name: Project scoped and bounded search regressions
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf'
|
||||
- name: Native watch and speech mux pytest regressions
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py -q -p no:cacheprovider -rf'
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py apps/desktop/assets/codex-bridge/test_live_limits.py -q -p no:cacheprovider -rf'
|
||||
- name: Mobile and desktop startup regression tests
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
|
||||
|
||||
+5
-2
@@ -25,6 +25,8 @@ ayrıntı seviyesi, korunacak mesajlaşmalar, yüzde/token bütçesi ve otomatik
|
||||
seçimini sunar. Varsayılanlar: Gemini 3.8 Flash High, yedek Gemini 3 Flash,
|
||||
10 mesajlaşma, geçişte %70, otomatik tetiklemede %70 ve sonrasında %50.
|
||||
Kapasite bağlantıdan gelen model bilgisiyle hesaplanır; 272K genel tavan yoktur.
|
||||
`/models` içindeki doğrulanmış sayısal bağlam bilgisi, model adından çıkarılan
|
||||
yerel varsayılandan önce gelir; gözlenmiş daha düşük bir gerçek sınır korunur.
|
||||
Girdi hesabı talimatlar ve araçları da içeren temkinli bir token tahminidir.
|
||||
|
||||
Abonelik modellerinin kendi aralarındaki istekler ve GPT-Live ses yolu özgün
|
||||
@@ -58,8 +60,9 @@ npm run builder --workspace=apps/desktop -- --win nsis --x64 --publish never
|
||||
|
||||
Dağıtılacak derleme temiz, yayımlanmış Git commit'inden üretilmelidir. İlk açılış
|
||||
ajan kurulumunu paketteki `install-stamp.json` commit'ine sabitler. Windows
|
||||
imzası ve temiz kurulum doğrulanmadan bir paket doğrulanmış üretim sürümü diye
|
||||
sunulmamalıdır. macOS ve Linux paketleri bu beta kapsamında doğrulanmamıştır.
|
||||
paketleri imzasız EXE/ZIP olarak yayımlanır; SHA-256 ve gerçek Windows çalıştırma
|
||||
kontrolü gereklidir. Kullanıcı açıkça istemeden imzalama yapılmaz. macOS ve Linux
|
||||
paketleri bu beta kapsamında doğrulanmamıştır.
|
||||
|
||||
## Lisans ve kaynak
|
||||
|
||||
|
||||
@@ -8,6 +8,66 @@ path; broad diagnostic probes should apply this policy to recursive walks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
SEARCH_RG_THREADS = 2
|
||||
SEARCH_MAX_CONCURRENT = 2
|
||||
|
||||
_PROJECT_MARKERS = ('.git', '.hg', 'pyproject.toml', 'package.json',
|
||||
'Cargo.toml', 'go.mod', 'CMakeLists.txt')
|
||||
|
||||
|
||||
def _project_root(path):
|
||||
"""Prefer the enclosing repository over nested monorepo packages."""
|
||||
package = None
|
||||
for parent in (path, *path.parents):
|
||||
if (parent / '.git').exists() or (parent / '.hg').exists():
|
||||
return parent
|
||||
if package is None and any((parent / marker).exists() for marker in _PROJECT_MARKERS):
|
||||
package = parent
|
||||
return package
|
||||
|
||||
|
||||
def local_search_scope(path, cwd, home):
|
||||
"""Resolve broad local roots without starting a recursive search process.
|
||||
|
||||
Returns (root, notice, error). Only one level of candidate directories is
|
||||
inspected; no repository-wide discovery is needed to reject an umbrella.
|
||||
Real paths prevent symlink aliases from bypassing broad-root detection.
|
||||
"""
|
||||
root = Path(path)
|
||||
if not root.is_absolute():
|
||||
root = Path(cwd) / root
|
||||
root = root.resolve()
|
||||
if not root.is_dir():
|
||||
return path, None, None
|
||||
home = Path(home).resolve()
|
||||
project = _project_root(Path(cwd).resolve())
|
||||
root_project = _project_root(root)
|
||||
if root_project is not None:
|
||||
return path, None, None
|
||||
candidates = []
|
||||
try:
|
||||
# Bound metadata work even in directories containing many entries.
|
||||
from itertools import islice
|
||||
for child in islice(root.iterdir(), 256):
|
||||
if child.name in SEARCH_PRUNE_DIR_NAMES or not child.is_dir():
|
||||
continue
|
||||
if any((child / marker).exists() for marker in _PROJECT_MARKERS):
|
||||
candidates.append(str(child))
|
||||
except OSError:
|
||||
pass
|
||||
broad = (root == Path(root.anchor) or root == home or root in home.parents
|
||||
or len(candidates) > 1 or (project is not None and root in project.parents))
|
||||
if not broad:
|
||||
return path, None, None
|
||||
if project is not None and root in project.parents:
|
||||
chosen = str(project)
|
||||
return chosen, f'Search scoped to current project: {chosen}. Set allow_broad=true for the requested umbrella.', None
|
||||
choices = ', '.join(sorted(candidates)[:12]) or 'choose an explicit project directory'
|
||||
return path, None, ('Search scope needed: requested root spans projects or a home/drive. '
|
||||
f'Candidates: {choices}. Set allow_broad=true only for an intentional broad search.')
|
||||
|
||||
|
||||
# Keep this policy conservative and name-based so it works for local and remote
|
||||
# shell backends alike. The same set is used by context discovery and search
|
||||
|
||||
@@ -34,7 +34,7 @@ import urllib.request
|
||||
import gecis_context
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SURUM = "13" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
SURUM = "17" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||
@@ -484,15 +484,21 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
source = TRANSFERS.get(scope) if thread else None
|
||||
# A completed return to the subscription ends migration. Historical
|
||||
# foreign IDs must not keep later native turns in the preparer.
|
||||
mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign(req))
|
||||
if external or mixed:
|
||||
mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign_history(req))
|
||||
# Format repair is stateless: the saved transcript can still contain
|
||||
# foreign wrappers after a successful native return (or a restart).
|
||||
if external or mixed or gecis_context.foreign(req):
|
||||
try:
|
||||
normalized = gecis_context.normalize(req)
|
||||
except gecis_context.TransferError as e:
|
||||
self._transfer_begin()
|
||||
return self._transfer_fail(str(e))
|
||||
# Small returning requests need no summarizer or local worker.
|
||||
if external or gecis_context.tokens(normalized) > int(32768 * 0.70):
|
||||
# Native opaque state must remain on its owner route. Its byte
|
||||
# size is not a semantic token budget and cannot trigger a
|
||||
# foreign summarizer, including when transfer state is cold.
|
||||
if external or (mixed and not gecis_context.native_opaque(normalized)
|
||||
and gecis_context.tokens(normalized) > int(32768 * 0.70)):
|
||||
prepared, started = self._prepare(normalized, source)
|
||||
if prepared is None:
|
||||
return
|
||||
|
||||
@@ -180,9 +180,13 @@ def prepare(handler, module, payload, checkpoints):
|
||||
slug = original.get('model', '')
|
||||
external = module.ek_model_mu(slug)
|
||||
source_model = payload.get('source_model') or ''
|
||||
if not external and gc.native_opaque(original):
|
||||
# Defense in depth for direct/older splitter callers. Native encrypted
|
||||
# state never enters owner decoding or cross-provider summarization.
|
||||
return {'request': gc.normalize(original)}
|
||||
# Defense in depth: an accidental worker call cannot alter native-only history.
|
||||
if not external and not module.ek_model_mu(source_model) and not gc.foreign(original):
|
||||
return {'request': copy.deepcopy(original), 'window': handler._transfer_window(slug)}
|
||||
if not external and not module.ek_model_mu(source_model) and not gc.foreign_history(original):
|
||||
return {'request': gc.normalize(original)}
|
||||
settings = settings_store.read(module.HOME)
|
||||
actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug
|
||||
window = handler._transfer_window(actual)
|
||||
|
||||
@@ -7,6 +7,7 @@ must not enter the budget preparation path.
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
@@ -53,6 +54,22 @@ def foreign(req):
|
||||
return False
|
||||
|
||||
|
||||
def native_opaque(req):
|
||||
"""Native encrypted state belongs to the subscription, even in mixed history."""
|
||||
return any(isinstance(item, dict) and item.get("type") == "compaction" and item.get("encrypted_content")
|
||||
and not str(item["encrypted_content"]).startswith(ozet_kopru.ISARET)
|
||||
for item in req.get("input") or [])
|
||||
|
||||
|
||||
def foreign_history(req):
|
||||
"""Semantic migration evidence, excluding tool ID format mismatches."""
|
||||
return any(isinstance(item, dict) and (
|
||||
"chatcmpl-" in str(item.get("id") or "")
|
||||
or "astrabridge" in str(item.get("id") or "")
|
||||
or str(item.get("encrypted_content") or "").startswith(ozet_kopru.ISARET))
|
||||
for item in req.get("input") or [])
|
||||
|
||||
|
||||
def normalize(req):
|
||||
"""Repair local wrappers/IDs, retaining real OpenAI encrypted compaction.
|
||||
|
||||
@@ -81,6 +98,10 @@ def normalize(req):
|
||||
if any(visible):
|
||||
result.append(ozet_kopru._ozet_mesaji("\n".join(visible)))
|
||||
continue
|
||||
if ("chatcmpl-" in str(identity) or "astrabridge" in str(identity)) and (
|
||||
item.get("type") == "message" or item.get("role")):
|
||||
# Inline message content is portable; its foreign server ID is not.
|
||||
item.pop("id", None)
|
||||
prefix = PREFIXES.get(item.get("type"))
|
||||
if prefix and isinstance(identity, str) and identity and not identity.startswith(prefix + "_"):
|
||||
item["id"] = prefix + "_" + identity.split("_", 1)[-1]
|
||||
@@ -104,6 +125,31 @@ def portable_reasoning(req):
|
||||
return out
|
||||
result = []
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("type") == "agent_message":
|
||||
# Peer messages carry delegated tasks/results. External Responses
|
||||
# translators do not understand this Codex-specific item type.
|
||||
# Keep provenance as data, never as system/developer authority.
|
||||
content = item.get("content") or []
|
||||
if isinstance(content, str):
|
||||
content = [{"type": "input_text", "text": content}]
|
||||
portable = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "encrypted_content":
|
||||
# Codex peer delivery can put the literal task/result in
|
||||
# this wrapper. It is distinct from encrypted reasoning.
|
||||
# External translators otherwise silently drop the task.
|
||||
text = part.get("encrypted_content")
|
||||
if (not isinstance(text, str) or text.startswith("gAAAA")
|
||||
or re.fullmatch(r"[A-Za-z0-9_+/=-]{128,}", text)):
|
||||
raise TransferError("Alt ajan iletisi şifreli veya okunamıyor; görev eksik gönderilmedi.")
|
||||
portable.append({"type": "input_text", "text": text})
|
||||
else:
|
||||
portable.append(part)
|
||||
result.append({"type": "message", "role": "user", "content": [
|
||||
{"type": "input_text", "text": "Agent message: " + encoded({
|
||||
"author": item.get("author"), "recipient": item.get("recipient")})}
|
||||
] + portable})
|
||||
continue
|
||||
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
||||
result.append(item)
|
||||
continue
|
||||
|
||||
@@ -348,11 +348,13 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
|
||||
if not girdiler:
|
||||
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
|
||||
kayitlar = {m["id"]: m for m in metadata if m.get("id")}
|
||||
ilanlar = {m["id"]: m for m in data if m.get("id")}
|
||||
nano = pencere.nano_yukle(home, ua, ag=ag) # ag=False: acilista yalniz onbellek
|
||||
ogr = pencere.ogrenilen(home)
|
||||
aracsiz = pencere.aracsizlar(home)
|
||||
for g in girdiler:
|
||||
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz)
|
||||
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz,
|
||||
ilan=ilanlar.get(g["id"]))
|
||||
g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"],
|
||||
bilgi["arac"], bilgi["kaynak"])
|
||||
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
|
||||
|
||||
@@ -40,7 +40,7 @@ from bekci_watch import MetadataFilter
|
||||
# Installed only by owned native launcher, never an HTTP control endpoint.
|
||||
WATCH_OWNER = None
|
||||
|
||||
SURUM = "13"
|
||||
SURUM = "17"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
@@ -363,15 +363,40 @@ def arac_semalarini_duzelt(req):
|
||||
n += bool(degisti)
|
||||
return n
|
||||
|
||||
def flatten_request(req):
|
||||
def flatten_request(req, tool_names=None):
|
||||
"""additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi."""
|
||||
if isinstance(req.get("input"), list) and any(
|
||||
isinstance(item, dict) and item.get("type") == "agent_message" for item in req["input"]):
|
||||
import gecis_context
|
||||
req["input"] = gecis_context.portable_reasoning(req)["input"]
|
||||
custom = set()
|
||||
tools = []
|
||||
def add_tool(t):
|
||||
tool_names = tool_names if tool_names is not None else {}
|
||||
identities = {}
|
||||
wire_names = {}
|
||||
def add_tool(t, namespace=None):
|
||||
ty = t.get("type")
|
||||
if ty in ("function", "custom"):
|
||||
namespace = t.get("namespace") or namespace
|
||||
name = t["name"]
|
||||
if namespace:
|
||||
import hashlib
|
||||
identity = (namespace, name)
|
||||
# A hash avoids collisions between underscore-containing namespace/name pairs.
|
||||
alias = "ns_" + hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:24] + "_" + re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:32]
|
||||
if alias in tool_names and tool_names[alias] != identity:
|
||||
raise ValueError("Tool namespace alias collision")
|
||||
tool_names[alias] = identity
|
||||
identities[identity] = alias
|
||||
t = dict(t, name=alias, description=f"Tool {namespace}.{name}. " + t.get("description", ""))
|
||||
wire = t["name"]
|
||||
identity = (namespace, name)
|
||||
if wire in wire_names and wire_names[wire] != identity:
|
||||
raise ValueError("Tool namespace alias collision")
|
||||
wire_names[wire] = identity
|
||||
if ty == "namespace":
|
||||
for s in t.get("tools", []):
|
||||
add_tool(s)
|
||||
add_tool(s, ".".join(filter(None, (namespace, t.get("name")))))
|
||||
elif ty == "function":
|
||||
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
|
||||
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
|
||||
@@ -387,13 +412,34 @@ def flatten_request(req):
|
||||
tools.append(t)
|
||||
for t in req.get("tools", []) or []:
|
||||
add_tool(t)
|
||||
for it in req.get("input", []) or []:
|
||||
if it.get("type") == "additional_tools":
|
||||
for t in it.get("tools", []):
|
||||
add_tool(t)
|
||||
plain_names = {t.get("name") for t in tools if t.get("name") not in tool_names}
|
||||
def flatten_name(item):
|
||||
namespace, name = item.get("namespace"), item.get("name")
|
||||
alias = identities.get((namespace, name))
|
||||
if not namespace and name not in plain_names:
|
||||
matches = [a for a, identity in tool_names.items() if identity[1] == name]
|
||||
if len(matches) == 1:
|
||||
alias = matches[0] # Legacy history recorded before namespaces were preserved.
|
||||
if alias:
|
||||
item = dict(item, name=alias)
|
||||
item.pop("namespace", None)
|
||||
return item
|
||||
choice = req.get("tool_choice")
|
||||
if isinstance(choice, dict):
|
||||
req["tool_choice"] = flatten_name(choice)
|
||||
if isinstance(choice.get("tools"), list):
|
||||
req["tool_choice"] = dict(req["tool_choice"], tools=[flatten_name(t) for t in choice["tools"]])
|
||||
new_input = []
|
||||
for it in req.get("input", []) or []:
|
||||
ty = it.get("type")
|
||||
if ty == "additional_tools":
|
||||
for t in it.get("tools", []):
|
||||
add_tool(t)
|
||||
continue
|
||||
if ty in ("function_call", "custom_tool_call"):
|
||||
it = flatten_name(it)
|
||||
if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"):
|
||||
# Desktop thread handoffs have no matching tool call. Preserve their
|
||||
# content as imported context, not an invalid tool-role message.
|
||||
@@ -527,9 +573,10 @@ class SseRewriter:
|
||||
Ayrica dis modelin asistan mesajlarina phase damgasi koyar: mesajin "bitti" olayi, arkasindan ne
|
||||
geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti ->
|
||||
final_answer). Metin parcalari (delta) bekletilmez, canli akar."""
|
||||
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False):
|
||||
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False, tool_names=None):
|
||||
self.dolu = dolu # istek hedef pencerenin buyuk kismini dolduruyor
|
||||
self.custom = custom
|
||||
self.tool_names = tool_names or {}
|
||||
self.buf = b""
|
||||
self.custom_ids = set()
|
||||
self.bekleyen = None # phase karari bekleyen "output_item.done" olayi
|
||||
@@ -634,6 +681,12 @@ class SseRewriter:
|
||||
self.bekleyen = d
|
||||
return once
|
||||
return once + [d]
|
||||
def _restore_tool_name(self, item):
|
||||
if isinstance(item, dict) and item.get("type") in ("function_call", "custom_tool_call"):
|
||||
identity = self.tool_names.get(item.get("name"))
|
||||
if identity:
|
||||
item["namespace"], item["name"] = identity
|
||||
return item
|
||||
def _fix(self, d):
|
||||
t = d.get("type", "")
|
||||
item = d.get("item")
|
||||
@@ -655,6 +708,7 @@ class SseRewriter:
|
||||
# Normalize before Codex persists the response. Request-only repair
|
||||
# cannot help when the user later disables the bridge.
|
||||
fix_tool_ids({"input": [d["item"]]})
|
||||
self._restore_tool_name(d["item"])
|
||||
return d
|
||||
if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids:
|
||||
return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor
|
||||
@@ -667,9 +721,10 @@ class SseRewriter:
|
||||
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
|
||||
"name": item.get("name"), "input": inp, "status": "completed"}
|
||||
fix_tool_ids({"input": [item]})
|
||||
out.append(item)
|
||||
out.append(self._restore_tool_name(item))
|
||||
d["response"]["output"] = out
|
||||
fix_tool_ids({"input": out})
|
||||
self._restore_tool_name(d.get("item"))
|
||||
return d
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
@@ -980,13 +1035,13 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if jev_denetim.muaf_mi(hedef):
|
||||
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
|
||||
if gecmedi:
|
||||
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez.
|
||||
if son_govde:
|
||||
w(son_govde)
|
||||
else:
|
||||
event = {"type": "response.failed", "response": {"status": "failed", "error": {
|
||||
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
|
||||
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
|
||||
# Headers already declare SSE. A raw upstream JSON/HTML body
|
||||
# here leaves Codex waiting for a terminal event and retrying.
|
||||
event = {"type": "response.failed", "response": {
|
||||
"id": "resp_astrabridge_%d" % time.time_ns(),
|
||||
"object": "response", "status": "failed", "output": [], "error": {
|
||||
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
|
||||
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
|
||||
else:
|
||||
for chunk in parcalar:
|
||||
if izle is not None:
|
||||
@@ -1232,7 +1287,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
for model in json.loads(cached["body"]).get("models", []):
|
||||
if model.get("slug") == slug and model.get("context_window"):
|
||||
return int(model["context_window"])
|
||||
return 32768
|
||||
raise gecis_context.TransferError("Abonelik modelinin bağlam kapasitesi katalogda bulunamadı; geçmiş korundu.")
|
||||
|
||||
|
||||
def _prepare_transfer(self, payload):
|
||||
@@ -1623,11 +1678,12 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
|
||||
rewriter = None
|
||||
if flat if flat is not None else route.get("flatten_tools"):
|
||||
custom = flatten_request(req)
|
||||
tool_names = {}
|
||||
custom = flatten_request(req, tool_names)
|
||||
duzelen = arac_semalarini_duzelt(req)
|
||||
if duzelen:
|
||||
log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)")
|
||||
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren)
|
||||
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names)
|
||||
fix_tool_ids(req)
|
||||
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
|
||||
merged = coalesce_context(req)
|
||||
@@ -1695,7 +1751,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
yanit = urllib.request.urlopen(urllib.request.Request(
|
||||
jev_rota["url"], data=json.dumps(katman_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
|
||||
timeout=UPSTREAM_IDLE_TIMEOUT)
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren),
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
|
||||
f"[{n} {slug}=>{ad}]")
|
||||
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
|
||||
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
|
||||
|
||||
@@ -221,7 +221,7 @@ def _yapraklar(dugum, derinlik=0):
|
||||
return cikti
|
||||
|
||||
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=(), ilan=None):
|
||||
"""Bir katalog girdisinin ilan edilecek penceresi ve yetenekleri.
|
||||
kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu;
|
||||
kayit: /router/catalog girdisi (combo topolojisi icin). Donus: {"pencere","gorsel","arac","kaynak"}."""
|
||||
@@ -246,6 +246,13 @@ def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
|
||||
m = re.match(r"no-think-(?:9router-)?([a-z0-9]+)-", kimlik or "")
|
||||
yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol
|
||||
sonuc = yol_bilgisi(yol, nano) or dict(bilinmeyen)
|
||||
# Exact /models metadata is stronger evidence than name-based local
|
||||
# fallbacks. Do not infer a combo's capacity from its display name.
|
||||
ilan = ilan if isinstance(ilan, dict) else {}
|
||||
canli_sinirlar = [ilan[k] for k in ("context_length", "max_input_tokens")
|
||||
if type(ilan.get(k)) is int and TABAN <= ilan[k] <= 10_000_000]
|
||||
if canli_sinirlar:
|
||||
sonuc = {**sonuc, "pencere": min(canli_sinirlar), "kaynak": "saglayici-katalogu"}
|
||||
if kimlik in aracsiz:
|
||||
sonuc = {**sonuc, "arac": False}
|
||||
sinir = (ogr or {}).get(kimlik)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Regression fixture matching actual Codex child delivery, with no user data."""
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
import gecis_context
|
||||
import kopru
|
||||
|
||||
|
||||
class AgentPayloadTests(unittest.TestCase):
|
||||
def request(self, text):
|
||||
return {"model": "ide/test", "input": [{
|
||||
"type": "agent_message", "author": "/root", "recipient": "/root/test",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
|
||||
{"type": "encrypted_content", "encrypted_content": text},
|
||||
],
|
||||
}]}
|
||||
|
||||
def test_actual_peer_wrapper_survives_external_text_extraction(self):
|
||||
task = "Run exec_command: Write-Output (157 * 29); report the exit code."
|
||||
request = self.request(task)
|
||||
original = copy.deepcopy(request)
|
||||
kopru.flatten_request(request)
|
||||
# An OpenAI-compatible translator consumes input_text, not Codex's
|
||||
# encrypted_content part. Checking the JSON string alone misses loss.
|
||||
text = "\n".join(part["text"] for item in request["input"]
|
||||
for part in item["content"] if part["type"] == "input_text")
|
||||
self.assertIn(task, text)
|
||||
self.assertIn('/root/test', text)
|
||||
self.assertEqual(request["input"][0]["role"], "user")
|
||||
self.assertEqual(original["input"][0]["content"][1]["type"], "encrypted_content")
|
||||
|
||||
def test_peer_result_and_plain_parts_keep_order_without_source_mutation(self):
|
||||
request = self.request("Result: 4553; exit code: 0")
|
||||
original = copy.deepcopy(request)
|
||||
result = gecis_context.portable_reasoning(request)
|
||||
self.assertEqual(result["input"][0]["content"][-1]["text"], "Result: 4553; exit code: 0")
|
||||
self.assertEqual(request, original)
|
||||
|
||||
def test_opaque_peer_payload_fails_instead_of_sending_header_only(self):
|
||||
for opaque in ("gAAAA_native_ciphertext", "A" * 200, None):
|
||||
with self.subTest(opaque=opaque):
|
||||
with self.assertRaises(gecis_context.TransferError):
|
||||
gecis_context.portable_reasoning(self.request(opaque))
|
||||
|
||||
def test_reasoning_ciphertext_is_still_not_interpreted_as_a_peer_task(self):
|
||||
request = {"input": [{"type": "reasoning", "encrypted_content": "Run a command now"}]}
|
||||
self.assertEqual(gecis_context.portable_reasoning(request)["input"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,6 +22,26 @@ from test_model_switch import env
|
||||
|
||||
|
||||
class History(unittest.TestCase):
|
||||
def test_agent_task_survives_external_projection_and_recent_history(self):
|
||||
task = {"type": "agent_message", "id": "amsg_task", "author": "/root",
|
||||
"recipient": "/root/child", "content": [
|
||||
{"type": "input_text", "text": "Message Type: NEW_TASK\nRun CHILD_TOOL_410962"}]}
|
||||
req = {"input": [{"role": "user", "content": "Old setup"}, task]}
|
||||
before = copy.deepcopy(req)
|
||||
self.assertEqual(gc.normalize(req), before)
|
||||
projected = gc.portable_reasoning(req)
|
||||
message = projected["input"][-1]
|
||||
self.assertEqual(message["role"], "user")
|
||||
self.assertEqual(message["content"][1:], task["content"])
|
||||
self.assertIn('/root/child', message["content"][0]["text"])
|
||||
self.assertNotIn("id", message)
|
||||
self.assertEqual(gc.split_history(projected["input"], keep_users=1)[2], [message])
|
||||
self.assertEqual(req, before)
|
||||
self.assertEqual(gc.portable_reasoning(projected), projected)
|
||||
direct = copy.deepcopy(req)
|
||||
env["flatten_request"](direct)
|
||||
self.assertEqual(direct["input"][-1], message)
|
||||
|
||||
def test_native_function_results_are_not_foreign_or_renamed(self):
|
||||
req = {"input": [{"type": "function_call_output", "id": "fco_native",
|
||||
"call_id": "call_native", "output": "kept"}]}
|
||||
|
||||
@@ -335,7 +335,7 @@ class Akis(unittest.TestCase):
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertIn(b'response.failed', self.sent())
|
||||
|
||||
def test_the_last_layers_own_error_body_is_delivered_untouched(self):
|
||||
def test_last_layer_http_error_terminates_sse_without_leaking_raw_body(self):
|
||||
class Hata(Exception):
|
||||
def read(self):
|
||||
return b'{"error": {"message": "katman 4 hatasi"}}'
|
||||
@@ -344,7 +344,14 @@ class Akis(unittest.TestCase):
|
||||
def ac(ad):
|
||||
raise Hata("HTTP 400")
|
||||
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
|
||||
self.assertEqual(self.sent(), b'{"error": {"message": "katman 4 hatasi"}}')
|
||||
raw = self.sent()
|
||||
self.assertTrue(raw.startswith(b"event: response.failed\ndata: "))
|
||||
event = json.loads(raw.split(b"data: ", 1)[1])
|
||||
self.assertEqual(event["response"]["status"], "failed")
|
||||
self.assertEqual(event["response"]["error"]["code"], "jev_no_fallback")
|
||||
self.assertEqual(event["response"]["output"], [])
|
||||
self.assertNotIn(b"katman 4 hatasi", raw)
|
||||
self.assertNotIn(b"Bunu yapamam", raw)
|
||||
|
||||
def test_unreachable_jev_keeps_the_answer(self):
|
||||
self.puanlar = [None]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import pencere
|
||||
|
||||
|
||||
def test_exact_combo_limits_override_unknown_and_local_defaults():
|
||||
row = {"context_length": 872000, "max_input_tokens": 872000, "max_output_tokens": 128000}
|
||||
result = pencere.coz("GPT-6-Astra", "combo", None, ilan=row)
|
||||
assert result["pencere"] == 872000
|
||||
assert result["kaynak"] == "saglayici-katalogu"
|
||||
result = pencere.coz("codex-gpt-6-astra", "model", "codex/gpt-6-astra", ilan=row)
|
||||
assert result["pencere"] == 872000
|
||||
|
||||
|
||||
def test_learned_actual_limit_still_wins_and_invalid_metadata_is_ignored():
|
||||
result = pencere.coz("combo", "combo", None, ilan={"context_length": 872000}, ogr={"combo": 500000})
|
||||
assert result["pencere"] == 500000 and result["kaynak"] == "ogrenilen"
|
||||
for value in (True, -1, "872000", 999999999):
|
||||
result = pencere.coz("combo", "combo", None, ilan={"context_length": value})
|
||||
assert result["pencere"] == pencere.VARSAYILAN
|
||||
@@ -43,7 +43,12 @@ class ModelSwitch(unittest.TestCase):
|
||||
{"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"},
|
||||
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
|
||||
req = copy.deepcopy(original)
|
||||
self.assertEqual(env["flatten_request"](req), {"probe"})
|
||||
names = {}
|
||||
custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertEqual(custom, {alias})
|
||||
self.assertEqual(names[alias], ("functions", "probe"))
|
||||
self.assertEqual(req["input"][0]["name"], alias)
|
||||
env["fix_tool_ids"](req)
|
||||
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"})
|
||||
self.assertEqual(req["input"][1]["output"], "result-4827")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Native switch regressions using the real HTTP splitter and offline peers."""
|
||||
import copy
|
||||
import gzip
|
||||
import json
|
||||
import unittest
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import bridge
|
||||
import gecis_context as gc
|
||||
import ozet_kopru
|
||||
import test_ayirici as fixtures
|
||||
|
||||
|
||||
class NativeSwitch(unittest.TestCase):
|
||||
setUp = fixtures.Ayirici.setUp
|
||||
tearDown = fixtures.Ayirici.tearDown
|
||||
iste = fixtures.Ayirici.iste
|
||||
|
||||
def test_unknown_native_capacity_is_not_invented(self):
|
||||
tree = ast.parse(Path(__file__).with_name('kopru.py').read_text(encoding='utf-8'))
|
||||
method = next(node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef) and node.name == '_transfer_window')
|
||||
ns = {'ek_model_mu': lambda _: False, 'LAST_MODELS': {}, 'gecis_context': gc, 'json': json}
|
||||
exec(compile(ast.Module(body=[method], type_ignores=[]), 'window', 'exec'), ns)
|
||||
from types import SimpleNamespace
|
||||
with self.assertRaises(gc.TransferError):
|
||||
ns['_transfer_window'](SimpleNamespace(headers={}), 'gpt-6-astra')
|
||||
|
||||
def test_opaque_native_switch_with_persisted_foreign_residue(self):
|
||||
self.ozgun.cevap = fixtures.duz(200, b'data: {"type":"response.completed"}\n\n', 'text/event-stream')
|
||||
compact = {'type': 'compaction', 'id': 'cmp_native',
|
||||
'encrypted_content': 'gAAAAABp' + 'AqZ09_-jX' * 10000}
|
||||
req = {'model': 'gpt-6.1-sol', 'prompt_cache_key': 'restart', 'input': [
|
||||
ozet_kopru.ozet_ogesi('DECISION-4827 must survive'), compact,
|
||||
{'type': 'custom_tool_call', 'id': 'fc_123', 'call_id': 'call_123',
|
||||
'name': 'apply_patch', 'input': 'patch text'},
|
||||
{'type': 'custom_tool_call_output', 'id': 'fco_123',
|
||||
'call_id': 'call_123', 'output': 'complete'},
|
||||
{'role': 'user', 'content': 'continue'}]}
|
||||
before = copy.deepcopy(req)
|
||||
for cold, model in ((True, 'gpt-6.1-sol'), (False, 'gpt-6-astra'), (True, 'gpt-6-astra')):
|
||||
if cold:
|
||||
bridge.TRANSFERS.clear()
|
||||
req['model'] = model
|
||||
body = gzip.compress(json.dumps(req).encode())
|
||||
self.assertEqual(self.iste('POST', '/backend-api/codex/responses', body,
|
||||
{'Content-Encoding': 'gzip'})[0], 200)
|
||||
sent = json.loads(self.ozgun.gelen[-1]['govde'])
|
||||
self.assertEqual(sent['input'][1], compact)
|
||||
self.assertEqual(sent['input'][2]['id'], 'ctc_123')
|
||||
self.assertEqual(sent['input'][3]['id'], 'ctco_123')
|
||||
self.assertEqual(sent['input'][3]['call_id'], 'call_123')
|
||||
self.assertIn('DECISION-4827', json.dumps(sent))
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
self.assertEqual(req['input'], before['input'])
|
||||
|
||||
def test_clean_native_body_is_byte_identical_even_after_external_source(self):
|
||||
req = {'model': 'ide/test', 'prompt_cache_key': 'return', 'input': []}
|
||||
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
|
||||
self.kopru.gelen.clear()
|
||||
req.update(model='gpt-6-astra', input=[{'type': 'compaction',
|
||||
'encrypted_content': 'gAAAAABp' + 'X' * 100000}])
|
||||
raw = gzip.compress(json.dumps(req, indent=3).encode())
|
||||
self.iste('POST', '/backend-api/codex/responses', raw, {'Content-Encoding': 'gzip'})
|
||||
self.assertEqual(self.ozgun.gelen[-1]['govde'], raw)
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_cold_native_tool_id_repair_does_not_require_semantic_budget(self):
|
||||
req = {'model': 'gpt-6-astra', 'input': [
|
||||
{'type': 'custom_tool_call', 'id': 'fc_old', 'call_id': 'call_old',
|
||||
'name': 'apply_patch', 'input': 'x' * 100000},
|
||||
{'type': 'custom_tool_call_output', 'id': 'fco_old',
|
||||
'call_id': 'call_old', 'output': 'complete'}]}
|
||||
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
|
||||
self.assertEqual(len(self.kopru.gelen), 0)
|
||||
sent = json.loads(self.ozgun.gelen[-1]['govde'])
|
||||
self.assertEqual(sent, gc.normalize(req))
|
||||
self.assertEqual(req['input'][0]['id'], 'fc_old')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Namespace routing regressions: leaf-only calls cannot dispatch collaboration tools."""
|
||||
import copy
|
||||
import json
|
||||
import unittest
|
||||
from test_model_switch import env
|
||||
|
||||
|
||||
class ToolNamespaces(unittest.TestCase):
|
||||
def request(self):
|
||||
return {"tools": [
|
||||
{"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn_agent", "parameters": {"type": "object", "properties": {}}}]},
|
||||
{"type": "namespace", "name": "other", "tools": [{"type": "function", "name": "spawn_agent"}]},
|
||||
{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "apply_patch"}]},
|
||||
{"type": "function", "name": "plain"}],
|
||||
"input": [{"type": "function_call", "namespace": "collaboration", "name": "spawn_agent", "id": "fc_a", "call_id": "call_a", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_a", "output": "child_123"}]}
|
||||
|
||||
def test_namespace_calls_round_trip_at_all_stream_stages(self):
|
||||
req = self.request(); original = copy.deepcopy(req); names = {}
|
||||
custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertNotEqual(alias, req["tools"][1]["name"])
|
||||
self.assertLessEqual(len(alias), 64)
|
||||
self.assertEqual(req["input"][0]["name"], alias)
|
||||
self.assertNotIn("namespace", req["input"][0])
|
||||
self.assertEqual(req["input"][1], original["input"][1])
|
||||
adapter = env["SseRewriter"](custom, tool_names=names)
|
||||
item = {"type": "function_call", "name": alias, "id": "fc_test", "call_id": "call_test", "arguments": "{}"}
|
||||
for stage in ["response.output_item.added", "response.output_item.done"]:
|
||||
restored = adapter._fix({"type": stage, "item": dict(item)})["item"]
|
||||
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
|
||||
self.assertEqual(restored["call_id"], "call_test")
|
||||
restored = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})["response"]["output"][0]
|
||||
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
|
||||
|
||||
def test_custom_tool_namespace_and_ids_survive_chunked_sse(self):
|
||||
req = self.request(); names = {}; custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][2]["name"]
|
||||
item = {"type": "function_call", "name": alias, "id": "fc_patch", "call_id": "call_patch", "arguments": json.dumps({"input": "patch text"})}
|
||||
raw = ("data: " + json.dumps({"type": "response.output_item.done", "item": item}) + "\n\n").encode()
|
||||
a = env["SseRewriter"](custom, tool_names=names)
|
||||
output = a.feed(raw[:31]) + a.feed(raw[31:]) + a.flush()
|
||||
restored = json.loads(next(l[6:] for l in output.decode().splitlines() if l.startswith("data: ")))["item"]
|
||||
self.assertEqual((restored["namespace"], restored["name"], restored["type"]), ("functions", "apply_patch", "custom_tool_call"))
|
||||
self.assertEqual(restored["id"], "ctc_patch")
|
||||
self.assertEqual(restored["input"], "patch text")
|
||||
|
||||
def test_plain_tools_stay_plain_and_history_not_mutated(self):
|
||||
req = self.request(); original = copy.deepcopy(req); old_item = req["input"][0]
|
||||
env["flatten_request"](req, {})
|
||||
self.assertEqual(req["tools"][-1]["name"], "plain")
|
||||
self.assertEqual(old_item, original["input"][0])
|
||||
plain = {"type": "function_call", "name": "plain", "arguments": "{}"}
|
||||
self.assertEqual(env["SseRewriter"](set())._fix({"item": plain})["item"], plain)
|
||||
|
||||
def test_additional_tools_long_names_and_tool_choice(self):
|
||||
namespace = "provider_" * 20
|
||||
req = {"tools": [], "input": [{"type": "additional_tools", "tools": [{"type": "namespace", "name": namespace, "tools": [{"type": "function", "name": "read"}]}]}], "tool_choice": {"type": "function", "namespace": namespace, "name": "read"}}
|
||||
names = {}; env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertLessEqual(len(alias), 64)
|
||||
self.assertEqual(req["tool_choice"], {"type": "function", "name": alias})
|
||||
self.assertEqual(req["input"], [])
|
||||
self.assertEqual(names[alias], (namespace, "read"))
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
|
||||
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
||||
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
||||
export const CODEX_BRIDGE_VERSION = '13'
|
||||
export const CODEX_BRIDGE_VERSION = '17'
|
||||
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
||||
|
||||
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.32",
|
||||
"version": "1.0.0-beta.36",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.32",
|
||||
"version": "1.0.0-beta.36",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -20,6 +20,7 @@ import subprocess
|
||||
import pytest
|
||||
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
# Big enough to prove containment, small enough to keep the test fast.
|
||||
GIANT = 5 * 1024 * 1024 # 5MB single line
|
||||
@@ -27,22 +28,20 @@ GIANT = 5 * 1024 * 1024 # 5MB single line
|
||||
STDOUT_CEILING = 1 * 1024 * 1024
|
||||
|
||||
|
||||
class RecordingEnv:
|
||||
class RecordingEnv(LocalEnvironment):
|
||||
"""Local bash executor that records the largest stdout it returned."""
|
||||
|
||||
def __init__(self, cwd):
|
||||
self.cwd = cwd
|
||||
super().__init__(cwd=cwd)
|
||||
self.max_stdout = 0
|
||||
|
||||
def execute(self, command, timeout=60, **kwargs):
|
||||
proc = subprocess.run(
|
||||
["bash", "-c", command],
|
||||
capture_output=True, text=True, errors="replace",
|
||||
timeout=timeout + 30,
|
||||
)
|
||||
out = proc.stdout + (proc.stderr or "")
|
||||
# Use the application's shell selection/path handling. On Windows a
|
||||
# bare 'bash' may select WSL, whose filesystem is not the native one.
|
||||
result = super().execute(command, timeout=timeout + 30, **kwargs)
|
||||
out = result.get("output", "")
|
||||
self.max_stdout = max(self.max_stdout, len(out))
|
||||
return {"output": out, "returncode": proc.returncode}
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -58,6 +57,8 @@ def _ops(giant_dir, engine):
|
||||
env = RecordingEnv(str(giant_dir))
|
||||
ops = ShellFileOperations(env)
|
||||
ops._has_command = lambda cmd: cmd == engine
|
||||
resolve = ops._resolve_command
|
||||
ops._resolve_command = lambda cmd: None if cmd == "rg" and engine != "rg" else resolve(cmd)
|
||||
return ops, env
|
||||
|
||||
|
||||
@@ -96,5 +97,5 @@ def test_line_cap_skipped_for_path_and_count_modes(giant_dir, engine, output_mod
|
||||
if output_mode == "files_only":
|
||||
assert {os.path.basename(f) for f in result.files} == {"trace.json", "small.py"}
|
||||
else:
|
||||
assert {os.path.basename(k) for k in result.counts} == {"trace.json", "small.py"}
|
||||
assert {os.path.basename(k) for k, count in result.counts.items() if count} == {"trace.json", "small.py"}
|
||||
assert env.max_stdout < STDOUT_CEILING
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Project scope and resource limits: real local paths, no broad subprocesses."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from tools.file_operations import ShellFileOperations, SearchResult, ExecuteResult
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
def projects(tmp_path):
|
||||
for name in ('one', 'two'):
|
||||
project = tmp_path / name
|
||||
project.mkdir()
|
||||
(project / '.git').mkdir()
|
||||
(project / 'source.py').write_text('needle\n')
|
||||
return tmp_path / 'one'
|
||||
|
||||
|
||||
def test_ambiguous_scope_starts_no_process(tmp_path):
|
||||
projects(tmp_path)
|
||||
ops = ShellFileOperations(LocalEnvironment(str(tmp_path)))
|
||||
ops._exec = Mock(side_effect=AssertionError('must not start process'))
|
||||
result = ops.search('needle', str(tmp_path))
|
||||
assert 'scope' in result.error.lower()
|
||||
assert 'one' in result.error and 'two' in result.error
|
||||
ops._exec.assert_not_called()
|
||||
|
||||
|
||||
def test_umbrella_uses_cwd_project_and_reports_it(tmp_path):
|
||||
project = projects(tmp_path)
|
||||
ops = ShellFileOperations(LocalEnvironment(str(project)))
|
||||
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
|
||||
ops._search_content = Mock(return_value=SearchResult())
|
||||
result = ops.search('needle', str(tmp_path))
|
||||
assert ops._search_content.call_args.args[1] == str(project)
|
||||
assert str(project) in result.warning
|
||||
|
||||
|
||||
def test_broad_opt_in_and_explicit_file(tmp_path):
|
||||
project = projects(tmp_path)
|
||||
ops = ShellFileOperations(LocalEnvironment(str(project)))
|
||||
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
|
||||
ops._search_content = Mock(return_value=SearchResult())
|
||||
for root, broad in ((tmp_path, True), (project / 'source.py', False)):
|
||||
assert not ops.search('needle', str(root), allow_broad=broad).error
|
||||
assert ops._search_content.call_args.args[1] == str(root)
|
||||
|
||||
|
||||
def test_rg_commands_bound_threads_and_prune(tmp_path):
|
||||
project = projects(tmp_path)
|
||||
ops = ShellFileOperations(LocalEnvironment(str(project)))
|
||||
ops._exec = Mock(return_value=ExecuteResult(stdout='', exit_code=1))
|
||||
ops._search_with_rg('needle', str(project), '*.py', 50, 0, 'content', 0, rg_executable='rg')
|
||||
command = ops._exec.call_args.args[0]
|
||||
assert '--threads 2' in command
|
||||
assert 'node_modules' in command and '.git' in command
|
||||
assert '*.py' in command
|
||||
ops._search_files_rg('*.py', str(project), 50, 0, rg_executable='rg')
|
||||
command = ops._exec.call_args.args[0]
|
||||
assert '--threads 2' in command and 'node_modules' in command
|
||||
|
||||
|
||||
def test_concurrent_searches_have_two_slots_and_release_after_failure(tmp_path):
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
project = projects(tmp_path)
|
||||
lock = threading.Lock()
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
active = peak = 0
|
||||
|
||||
def run_search(*args):
|
||||
nonlocal active, peak
|
||||
with lock:
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
entered.set()
|
||||
try:
|
||||
assert release.wait(5)
|
||||
return SearchResult()
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
pending = []
|
||||
for _ in range(6):
|
||||
ops = ShellFileOperations(LocalEnvironment(str(project)))
|
||||
ops._search_scoped = run_search
|
||||
pending.append(pool.submit(ops.search, 'needle'))
|
||||
try:
|
||||
assert entered.wait(3)
|
||||
assert peak == 2
|
||||
finally:
|
||||
release.set()
|
||||
assert all(not task.result().error for task in pending)
|
||||
assert peak == 2
|
||||
|
||||
|
||||
def test_real_rg_project_content_and_nested_glob(tmp_path):
|
||||
project = projects(tmp_path)
|
||||
(project / 'src').mkdir()
|
||||
(project / 'src' / 'nested.py').write_text('needle\n')
|
||||
(project / 'node_modules').mkdir()
|
||||
(project / 'node_modules' / 'noise.py').write_text('needle\n')
|
||||
ops = ShellFileOperations(LocalEnvironment(str(project)))
|
||||
result = ops.search('needle', str(tmp_path), file_glob='*.py')
|
||||
assert not result.error
|
||||
assert len(result.matches) == 2
|
||||
assert all('node_modules' not in item.path for item in result.matches)
|
||||
result = ops.search('src/*.py', str(project), target='files')
|
||||
assert not result.error
|
||||
assert len(result.files) == 1 and 'nested.py' in result.files[0]
|
||||
+56
-12
@@ -50,7 +50,8 @@ from agent.file_safety import (
|
||||
get_write_denied_error,
|
||||
is_write_denied as _shared_is_write_denied,
|
||||
)
|
||||
from agent.search_policy import SEARCH_PRUNE_DIR_NAMES
|
||||
from agent.search_policy import (SEARCH_PRUNE_DIR_NAMES, SEARCH_RG_THREADS,
|
||||
SEARCH_MAX_CONCURRENT, local_search_scope)
|
||||
from tools import interrupt as tool_interrupt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,6 +74,8 @@ _MACOS_TCC_PROTECTED_HOME_DIRS = (
|
||||
)
|
||||
|
||||
|
||||
_SEARCH_SLOTS = threading.BoundedSemaphore(SEARCH_MAX_CONCURRENT)
|
||||
|
||||
_FILENAME_SEARCH_ADMISSION = threading.Condition()
|
||||
_ACTIVE_FILENAME_SEARCH_ROOTS: set[tuple[str, str, str]] = set()
|
||||
_FILENAME_SEARCH_WAIT_SECONDS = 0.05
|
||||
@@ -3432,7 +3435,31 @@ class ShellFileOperations(FileOperations):
|
||||
def search(self, pattern: str, path: str = ".", target: str = "content",
|
||||
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
|
||||
output_mode: str = "content", context: int = 0,
|
||||
order: str = "discovery") -> SearchResult:
|
||||
order: str = "discovery", allow_broad: bool = False) -> SearchResult:
|
||||
# One admission slot covers discovery, traversal and zero-match probes.
|
||||
# It is process-wide across file-ops instances, and interruptible.
|
||||
while not _SEARCH_SLOTS.acquire(timeout=0.05):
|
||||
if tool_interrupt.is_interrupted():
|
||||
return SearchResult(error="Search interrupted while waiting for a search slot.")
|
||||
try:
|
||||
if tool_interrupt.is_interrupted():
|
||||
return SearchResult(error="Search interrupted.")
|
||||
return self._search_scoped(pattern, path, target, file_glob, limit,
|
||||
offset, output_mode, context, order, allow_broad)
|
||||
finally:
|
||||
_SEARCH_SLOTS.release()
|
||||
|
||||
def _project_search_scope(self, path, allow_broad=False):
|
||||
from tools.environments.local import LocalEnvironment, _msys_to_windows_path
|
||||
if allow_broad or not isinstance(self.env, LocalEnvironment):
|
||||
return path, None, None
|
||||
cwd = getattr(self.env, "cwd", None) or self.cwd
|
||||
return local_search_scope(_msys_to_windows_path(path), cwd, _HOME)
|
||||
|
||||
def _search_scoped(self, pattern: str, path: str = ".", target: str = "content",
|
||||
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
|
||||
output_mode: str = "content", context: int = 0,
|
||||
order: str = "discovery", allow_broad: bool = False) -> SearchResult:
|
||||
"""
|
||||
Search for content or files.
|
||||
|
||||
@@ -3460,6 +3487,9 @@ class ShellFileOperations(FileOperations):
|
||||
|
||||
# Expand ~ and other shell paths
|
||||
path = self._expand_path(path)
|
||||
path, scope_notice, scope_error = self._project_search_scope(path, allow_broad)
|
||||
if scope_error:
|
||||
return SearchResult(error=scope_error)
|
||||
|
||||
# Validate that the path exists before searching
|
||||
check = self._exec(f"test -e {self._escape_shell_arg(path)} && echo exists || echo not_found")
|
||||
@@ -3470,7 +3500,7 @@ class ShellFileOperations(FileOperations):
|
||||
# merge the results, and report the skipped parts.
|
||||
multi = self._try_multi_path_search(
|
||||
pattern, path, target, file_glob, limit, offset, output_mode, context,
|
||||
order,
|
||||
order, allow_broad,
|
||||
)
|
||||
if multi is not None:
|
||||
return multi
|
||||
@@ -3518,6 +3548,8 @@ class ShellFileOperations(FileOperations):
|
||||
f"an unattended privacy prompt: {skipped}. Search a protected "
|
||||
"folder directly when access is intentional."
|
||||
)
|
||||
if scope_notice:
|
||||
result.warning = " ".join(filter(None, [scope_notice, result.warning]))
|
||||
return result
|
||||
|
||||
def _macos_search_exclusions(self, path: str) -> List[str]:
|
||||
@@ -3587,7 +3619,7 @@ class ShellFileOperations(FileOperations):
|
||||
def _try_multi_path_search(self, pattern: str, path: str, target: str,
|
||||
file_glob: Optional[str], limit: int, offset: int,
|
||||
output_mode: str, context: int,
|
||||
order: str = "discovery") -> Optional[SearchResult]:
|
||||
order: str = "discovery", allow_broad: bool = False) -> Optional[SearchResult]:
|
||||
"""Recover a not-found ``path`` that is really several paths in one string.
|
||||
|
||||
Production trajectories show models passing "dir1 dir2 dir3" (or
|
||||
@@ -3604,9 +3636,14 @@ class ShellFileOperations(FileOperations):
|
||||
parts = path.split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
existing, missing = [], []
|
||||
existing, missing, scope_notices = [], [], []
|
||||
for p in parts:
|
||||
expanded = self._expand_path(p)
|
||||
expanded, notice, error = self._project_search_scope(expanded, allow_broad)
|
||||
if error:
|
||||
return SearchResult(error=error)
|
||||
if notice:
|
||||
scope_notices.append(notice)
|
||||
chk = self._exec(
|
||||
f"test -e {self._escape_shell_arg(expanded)} && echo exists || echo not_found"
|
||||
)
|
||||
@@ -3641,7 +3678,7 @@ class ShellFileOperations(FileOperations):
|
||||
note += "; skipped missing: " + ", ".join(missing[:3])
|
||||
if len(missing) > 3:
|
||||
note += f" (+{len(missing) - 3} more)"
|
||||
warning_parts = [note]
|
||||
warning_parts = [note, *scope_notices]
|
||||
if not merged.error:
|
||||
protected_paths = [
|
||||
absolute
|
||||
@@ -3704,7 +3741,7 @@ class ShellFileOperations(FileOperations):
|
||||
|
||||
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
|
||||
probe = self._exec(
|
||||
f"{rg} -i --count-matches{glob_expr} "
|
||||
f"{rg} --threads {SEARCH_RG_THREADS} -i --count-matches{glob_expr} {self._search_prune_glob_args()} "
|
||||
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
|
||||
f"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
@@ -3723,7 +3760,7 @@ class ShellFileOperations(FileOperations):
|
||||
# files remain diagnosable, but prune heavyweight trees before rg can
|
||||
# recurse into them.
|
||||
hidden = self._exec(
|
||||
f"{rg} --hidden --no-ignore --count-matches{glob_expr}"
|
||||
f"{rg} --threads {SEARCH_RG_THREADS} --hidden --no-ignore --count-matches{glob_expr}"
|
||||
f" {self._search_prune_glob_args()} "
|
||||
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
|
||||
f"2>/dev/null | head -50",
|
||||
@@ -3738,7 +3775,7 @@ class ShellFileOperations(FileOperations):
|
||||
)
|
||||
if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
|
||||
fixed = self._exec(
|
||||
f"{rg} -F --count-matches{glob_expr} "
|
||||
f"{rg} --threads {SEARCH_RG_THREADS} -F --count-matches{glob_expr} {self._search_prune_glob_args()} "
|
||||
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
|
||||
f"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
@@ -3813,7 +3850,7 @@ class ShellFileOperations(FileOperations):
|
||||
))
|
||||
try:
|
||||
return self._search_files_rg(
|
||||
search_pattern, path, limit, offset, order,
|
||||
pattern, path, limit, offset, order,
|
||||
rg_executable=rg_executable,
|
||||
)
|
||||
finally:
|
||||
@@ -3848,6 +3885,9 @@ class ShellFileOperations(FileOperations):
|
||||
hidden_prune = (
|
||||
f" \\( -type d -name '.*'{root_exemptions} \\) -prune -o"
|
||||
)
|
||||
names = " -o ".join(f"-name {self._escape_shell_arg(name)}"
|
||||
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
|
||||
hidden_prune += f" \\( -type d \\( {names} \\){root_exemptions} \\) -prune -o"
|
||||
protected_paths = [
|
||||
absolute
|
||||
for _root, _relative, absolute
|
||||
@@ -3986,7 +4026,7 @@ class ShellFileOperations(FileOperations):
|
||||
f"cd {self._escape_shell_arg(scoped_common)} && " if scoped_common else ""
|
||||
)
|
||||
cmd = (
|
||||
f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} -g {self._escape_shell_arg(glob_pattern)}"
|
||||
f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} --threads {SEARCH_RG_THREADS} -g {self._escape_shell_arg(glob_pattern)} {self._search_prune_glob_args()}"
|
||||
f"{exclusion_args} -- {root_args} 2>/dev/null | head -n {fetch_limit}"
|
||||
)
|
||||
result = self._exec(cmd, timeout=60)
|
||||
@@ -4062,7 +4102,7 @@ class ShellFileOperations(FileOperations):
|
||||
rg_executable = rg_executable or self._resolve_command("rg")
|
||||
if not rg_executable:
|
||||
return SearchResult(error="Content search requires ripgrep (rg).")
|
||||
cmd_parts = [self._quote_executable(rg_executable), "--line-number", "--no-heading", "--with-filename"]
|
||||
cmd_parts = [self._quote_executable(rg_executable), "--threads", str(SEARCH_RG_THREADS), "--line-number", "--no-heading", "--with-filename"]
|
||||
|
||||
# Giant-single-line containment (ported from cline/cline#13525): a
|
||||
# match inside a serialized dump (multi-MB single-line JSON/minified
|
||||
@@ -4100,6 +4140,8 @@ class ShellFileOperations(FileOperations):
|
||||
if file_glob:
|
||||
cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)])
|
||||
|
||||
cmd_parts.append(self._search_prune_glob_args())
|
||||
|
||||
# Output mode handling
|
||||
if output_mode == "files_only":
|
||||
cmd_parts.append("-l") # Files only
|
||||
@@ -4230,6 +4272,8 @@ class ShellFileOperations(FileOperations):
|
||||
# Exclude hidden directories (matching ripgrep's default behavior).
|
||||
# This prevents searching inside .hub/index-cache/, .git/, etc.
|
||||
cmd_parts.append("--exclude-dir='.*'")
|
||||
cmd_parts.extend(f"--exclude-dir={self._escape_shell_arg(name)}"
|
||||
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
|
||||
|
||||
# Protected-dir pruning CANNOT use --exclude-dir here: grep matches
|
||||
# exclude-dir globs against BASENAMES anywhere in the tree, so
|
||||
|
||||
+6
-2
@@ -2653,7 +2653,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
file_glob: str = None, limit: int = 50, offset: int = 0,
|
||||
output_mode: str = "content", context: int = 0,
|
||||
order: str = "discovery",
|
||||
task_id: str = "default") -> str:
|
||||
task_id: str = "default", allow_broad: bool = False) -> str:
|
||||
"""Search for content or files."""
|
||||
try:
|
||||
offset, limit = normalize_search_pagination(offset, limit)
|
||||
@@ -2670,6 +2670,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
limit,
|
||||
offset,
|
||||
order,
|
||||
allow_broad,
|
||||
)
|
||||
with _read_tracker_lock:
|
||||
task_data = _read_tracker.setdefault(task_id, {
|
||||
@@ -2717,6 +2718,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
pattern=pattern, path=path, target=target, file_glob=file_glob,
|
||||
limit=limit, offset=offset, output_mode=output_mode, context=context,
|
||||
order=order,
|
||||
**({"allow_broad": True} if allow_broad else {}),
|
||||
)
|
||||
omitted = _filter_read_blocked_search_results(result, task_id)
|
||||
if hasattr(result, 'matches'):
|
||||
@@ -2912,6 +2914,7 @@ SEARCH_FILES_SCHEMA = {
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_broad": {"type": "boolean", "default": False, "description": "Deliberately search a home, drive or multi-project umbrella. Default scopes to the current project or asks for a scope. Does not remove pruning or CPU limits."},
|
||||
"pattern": {"type": "string", "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search"},
|
||||
"target": {"type": "string", "enum": ["content", "files"], "description": "'content' searches inside file contents, 'files' searches for files by name", "default": "content"},
|
||||
"path": {"type": "string", "description": "Directory or file to search in (default: current working directory)", "default": "."},
|
||||
@@ -2979,7 +2982,8 @@ def _handle_search_files(args, **kw):
|
||||
pattern=args.get("pattern", ""), target=target, path=args.get("path", "."),
|
||||
file_glob=args.get("file_glob"), limit=args.get("limit", 50), offset=args.get("offset", 0),
|
||||
output_mode=args.get("output_mode", "content"), context=args.get("context", 0),
|
||||
order=args.get("order", "discovery"), task_id=tid)
|
||||
order=args.get("order", "discovery"), task_id=tid,
|
||||
allow_broad=args.get("allow_broad") is True)
|
||||
|
||||
|
||||
def _read_file_schema_overrides():
|
||||
|
||||
Reference in new issue
Block a user