Preserve namespaced Codex tools across external model calls
Server CI and Windows package / verify (push) Successful in 2m11s
Server CI and Windows package / windows-package (push) Successful in 10m44s

This commit is contained in:
yilsem committed 2026-10-13 04:54:52 +03:00
1 parent a68d3e2272
commit 2cca6a66a6
7 files changed
+138 -15

No files matched your search

+1 -1
View File
@@ -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]
+62 -10
View File
@@ -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)
@@ -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,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()
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",