From 2cca6a66a6e39ca885981513c8f6920d98c26c52 Mon Sep 17 00:00:00 2001 From: yilsem Date: Tue, 13 Oct 2026 04:54:52 +0300 Subject: [PATCH] Preserve namespaced Codex tools across external model calls --- apps/desktop/assets/codex-bridge/bridge.py | 2 +- apps/desktop/assets/codex-bridge/kopru.py | 72 ++++++++++++++++--- .../assets/codex-bridge/test_model_switch.py | 7 +- .../codex-bridge/test_tool_namespaces.py | 66 +++++++++++++++++ apps/desktop/electron/codex-link-config.ts | 2 +- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 7 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/assets/codex-bridge/test_tool_namespaces.py diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index afa2df4..88e6793 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -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 = "14" # 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] diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index e3cc44d..816068d 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -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 = "14" HERE = os.path.dirname(os.path.abspath(__file__)) try: with open(os.path.abspath(__file__), "rb") as _f: @@ -363,15 +363,36 @@ 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.""" 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 +408,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 +569,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 +677,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 +704,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 +717,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 @@ -1623,11 +1674,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) diff --git a/apps/desktop/assets/codex-bridge/test_model_switch.py b/apps/desktop/assets/codex-bridge/test_model_switch.py index 9ef512f..301d877 100644 --- a/apps/desktop/assets/codex-bridge/test_model_switch.py +++ b/apps/desktop/assets/codex-bridge/test_model_switch.py @@ -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") diff --git a/apps/desktop/assets/codex-bridge/test_tool_namespaces.py b/apps/desktop/assets/codex-bridge/test_tool_namespaces.py new file mode 100644 index 0000000..0cf96ce --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_tool_namespaces.py @@ -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() diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index 1351259..56d843a 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -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 = '14' 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' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 263fa9a..230fcaa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.32", + "version": "1.0.0-beta.33", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/package-lock.json b/package-lock.json index 65ba976..4b2dcc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.32", + "version": "1.0.0-beta.33", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",