Skip to content

Commit 698ebe5

Browse files
wu1wcursoragent
andcommitted
Fix python/web_search/configure_tevarn regressions and land chat inspector composer.
Windows SelectorEventLoop left the python tool dead; LLM integer strings failed schema; topic=status fell through to overview. Also keep composer menus and the inspector out of overflow clipping. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4133b5f commit 698ebe5

36 files changed

Lines changed: 1953 additions & 478 deletions

‎backend/agent/grant_store.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ def _prune_expired_locked() -> int:
197197
"manage_mcp": "manage_mcp",
198198
"manage_skill": "manage_skill",
199199
"generate_ppt": "file_rw",
200+
"configure_tevarn": "manage_skill",
201+
"update_config": "manage_skill",
202+
"get_system_status": "current_time",
203+
"list_available_models": "current_time",
200204
}
201205

202206

@@ -233,6 +237,10 @@ def sync_catalog_from_kernel(kernel: Any | None = None) -> bool:
233237
TOOL_TO_CREW_CAP.setdefault("generate_ppt", "file_rw")
234238
TOOL_TO_CREW_CAP.setdefault("current_time", "current_time")
235239
TOOL_TO_CREW_CAP.setdefault("result_load", "file_read")
240+
TOOL_TO_CREW_CAP.setdefault("configure_tevarn", "manage_skill")
241+
TOOL_TO_CREW_CAP.setdefault("update_config", "manage_skill")
242+
TOOL_TO_CREW_CAP.setdefault("get_system_status", "current_time")
243+
TOOL_TO_CREW_CAP.setdefault("list_available_models", "current_time")
236244
logger.info("grant_store catalog synced from rust (%s entries)", len(TOOL_TO_CREW_CAP))
237245
return True
238246
except Exception as e:

‎backend/agent/loop_cluster.py‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,64 @@
1414
_tools_bootstrap_lock: asyncio.Lock | None = None
1515

1616

17+
def _try_coerce_int(val: Any) -> int | None:
18+
if isinstance(val, bool) or val is None:
19+
return None
20+
if isinstance(val, int):
21+
return int(val)
22+
if isinstance(val, float):
23+
return int(val) if val.is_integer() else None
24+
if isinstance(val, str):
25+
s = val.strip()
26+
if not s:
27+
return None
28+
try:
29+
if s.lstrip("+-").isdigit():
30+
return int(s)
31+
f = float(s)
32+
if f.is_integer():
33+
return int(f)
34+
except (TypeError, ValueError, OverflowError):
35+
return None
36+
return None
37+
38+
39+
def _try_coerce_number(val: Any) -> int | float | None:
40+
if isinstance(val, bool) or val is None:
41+
return None
42+
if isinstance(val, int):
43+
return int(val)
44+
if isinstance(val, float):
45+
return val
46+
if isinstance(val, str):
47+
s = val.strip()
48+
if not s:
49+
return None
50+
try:
51+
n = float(s)
52+
except (TypeError, ValueError, OverflowError):
53+
return None
54+
if n.is_integer() and "." not in s and "e" not in s.lower() and "E" not in s:
55+
try:
56+
return int(s)
57+
except (TypeError, ValueError, OverflowError):
58+
return n
59+
return n
60+
return None
61+
62+
63+
def _try_coerce_bool(val: Any) -> bool | None:
64+
if isinstance(val, bool):
65+
return val
66+
if isinstance(val, str):
67+
s = val.strip().lower()
68+
if s in {"true", "yes", "on", "y"}:
69+
return True
70+
if s in {"false", "no", "off", "n"}:
71+
return False
72+
return None
73+
74+
1775
def _tools_bootstrap_lock_get() -> asyncio.Lock:
1876
global _tools_bootstrap_lock
1977
if _tools_bootstrap_lock is None:
@@ -320,6 +378,8 @@ def _validate_tool_args(self, schema: dict | None, arguments: dict) -> dict:
320378

321379
if not schema:
322380
return cleaned
381+
# LLM JSON 常把整数写成 "3";先按 schema 类型软转换,再 clamp / validate
382+
cleaned = self._coerce_tool_args(schema, cleaned)
323383
# 软夹紧:数值 min/max 越界时 clamp,减少「max_results=3 < min 5」类无意义失败
324384
cleaned = self._clamp_tool_args(schema, cleaned)
325385
try:
@@ -332,6 +392,44 @@ def _validate_tool_args(self, schema: dict | None, arguments: dict) -> dict:
332392
raise ValueError(f"Invalid tool arguments: {e.message}") from e
333393
return cleaned
334394

395+
@staticmethod
396+
def _schema_prop_types(spec: dict) -> set[str]:
397+
t = spec.get("type")
398+
if isinstance(t, str) and t:
399+
return {t}
400+
if isinstance(t, (list, tuple)):
401+
return {str(x) for x in t if x}
402+
return set()
403+
404+
def _coerce_tool_args(self, schema: dict, cleaned: dict) -> dict:
405+
"""把 LLM 常见的字符串标量收成 schema 声明的 integer/number/boolean。"""
406+
props = schema.get("properties") if isinstance(schema, dict) else None
407+
if not isinstance(props, dict) or not cleaned:
408+
return cleaned
409+
out = dict(cleaned)
410+
for key, spec in props.items():
411+
if key not in out or not isinstance(spec, dict):
412+
continue
413+
types = self._schema_prop_types(spec)
414+
if not types:
415+
continue
416+
val = out[key]
417+
if "integer" in types:
418+
coerced = _try_coerce_int(val)
419+
if coerced is not None:
420+
out[key] = coerced
421+
continue
422+
if "number" in types:
423+
coerced = _try_coerce_number(val)
424+
if coerced is not None:
425+
out[key] = coerced
426+
continue
427+
if "boolean" in types:
428+
coerced = _try_coerce_bool(val)
429+
if coerced is not None:
430+
out[key] = coerced
431+
return out
432+
335433
def _clamp_tool_args(self, schema: dict, cleaned: dict) -> dict:
336434
"""按 JSON Schema properties 的 minimum/maximum/minLength 做软夹紧(不改类型)。"""
337435
props = schema.get("properties") if isinstance(schema, dict) else None

‎backend/core/safe_subprocess.py‎

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -239,22 +239,36 @@ async def kill_process_tree(proc: asyncio.subprocess.Process | None) -> None:
239239
try:
240240
if sys.platform == "win32" and pid:
241241
# /T = tree; /F = force. Avoids orphaned cmd/python grandchildren.
242-
killer = await asyncio.create_subprocess_exec(
243-
"taskkill",
244-
"/PID",
245-
str(pid),
246-
"/T",
247-
"/F",
248-
stdout=asyncio.subprocess.DEVNULL,
249-
stderr=asyncio.subprocess.DEVNULL,
250-
)
251-
try:
252-
await asyncio.wait_for(killer.wait(), timeout=5.0)
253-
except Exception:
242+
# SelectorEventLoop cannot create_subprocess_exec — same Popen fallback.
243+
if _windows_selector_loop():
244+
def _taskkill() -> None:
245+
import subprocess as _sp
246+
247+
_sp.run(
248+
["taskkill", "/PID", str(pid), "/T", "/F"],
249+
stdout=_sp.DEVNULL,
250+
stderr=_sp.DEVNULL,
251+
check=False,
252+
)
253+
254+
await asyncio.to_thread(_taskkill)
255+
else:
256+
killer = await asyncio.create_subprocess_exec(
257+
"taskkill",
258+
"/PID",
259+
str(pid),
260+
"/T",
261+
"/F",
262+
stdout=asyncio.subprocess.DEVNULL,
263+
stderr=asyncio.subprocess.DEVNULL,
264+
)
254265
try:
255-
killer.kill()
266+
await asyncio.wait_for(killer.wait(), timeout=5.0)
256267
except Exception:
257-
pass
268+
try:
269+
killer.kill()
270+
except Exception:
271+
pass
258272
else:
259273
# start_new_session=True below → process group id == pid
260274
if pid and hasattr(os, "killpg"):
@@ -389,6 +403,32 @@ def _popen_thread_process(
389403
return _ThreadSubprocess(popen)
390404

391405

406+
def _popen_thread_argv(
407+
argv: list[str],
408+
*,
409+
cwd: str | None,
410+
env: dict[str, str] | None,
411+
) -> _ThreadSubprocess:
412+
import subprocess as _sp
413+
414+
if not argv:
415+
raise ValueError("empty argv")
416+
creationflags = 0
417+
if sys.platform == "win32":
418+
creationflags = int(getattr(_sp, "CREATE_NEW_PROCESS_GROUP", 0) or 0)
419+
popen = _sp.Popen( # type: ignore[call-overload, arg-type]
420+
argv,
421+
shell=False,
422+
cwd=cwd or None,
423+
env=env,
424+
stdin=_sp.DEVNULL,
425+
stdout=_sp.PIPE,
426+
stderr=_sp.PIPE,
427+
creationflags=creationflags,
428+
)
429+
return _ThreadSubprocess(popen)
430+
431+
392432
async def create_process(
393433
command: str,
394434
*,
@@ -486,6 +526,48 @@ async def create_process(
486526
)
487527

488528

529+
async def create_process_exec(
530+
*argv: str,
531+
cwd: str | None = None,
532+
env: dict[str, str] | None = None,
533+
) -> Any:
534+
"""Spawn argv without a shell (python.exe + script, etc.).
535+
536+
Windows SelectorEventLoop: threaded Popen — asyncio subprocess is
537+
NotImplemented there (bare exception → empty ``[Error]`` for python tool).
538+
"""
539+
argv_l = [str(a) for a in argv if str(a)]
540+
if not argv_l:
541+
raise ValueError("empty argv")
542+
run_env: dict[str, str] | None = None
543+
if env is not None:
544+
run_env = {
545+
str(k): str(v) for k, v in env.items() if k is not None and v is not None
546+
}
547+
if _windows_selector_loop():
548+
return await asyncio.to_thread(
549+
_popen_thread_argv, argv_l, cwd=cwd, env=run_env
550+
)
551+
spawn = _spawn_kwargs()
552+
try:
553+
return await asyncio.create_subprocess_exec(
554+
*argv_l,
555+
cwd=cwd or None,
556+
env=run_env,
557+
stdout=asyncio.subprocess.PIPE,
558+
stderr=asyncio.subprocess.PIPE,
559+
**spawn,
560+
)
561+
except NotImplementedError:
562+
logger.warning(
563+
"asyncio exec NotImplemented; falling back to threaded Popen argv0=%s",
564+
argv_l[0][:80],
565+
)
566+
return await asyncio.to_thread(
567+
_popen_thread_argv, argv_l, cwd=cwd, env=run_env
568+
)
569+
570+
489571
async def run_capture(
490572
command: str,
491573
*,

‎backend/services/tools/executors.py‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2053,22 +2053,21 @@ def _cmd_quote(s: str) -> str:
20532053

20542054
proc = None # audit-fix: spawn 本身失败时 Timeout 分支不得引用未绑定变量
20552055
try:
2056-
from backend.core.safe_subprocess import kill_process_tree
2056+
from backend.computer.text_decode import decode_process_bytes
2057+
from backend.core.safe_subprocess import create_process_exec
20572058

2058-
# stdin DEVNULL + process group so outer cancel can tree-kill
2059-
_spawn: dict = {
2060-
"stdout": asyncio.subprocess.PIPE,
2061-
"stderr": asyncio.subprocess.PIPE,
2062-
"stdin": asyncio.subprocess.DEVNULL,
2063-
}
2064-
if sys.platform != "win32":
2065-
_spawn["start_new_session"] = True
2066-
proc = await asyncio.create_subprocess_exec(py, script_path, **_spawn)
2059+
# Windows SelectorEventLoop: asyncio.create_subprocess_exec raises
2060+
# bare NotImplementedError → empty ``[Error] `` and a dead python tool.
2061+
try:
2062+
timeout_s = float(timeout or 30)
2063+
except (TypeError, ValueError):
2064+
timeout_s = 30.0
2065+
proc = await create_process_exec(py, script_path)
20672066
stdout, stderr = await asyncio.wait_for(
2068-
proc.communicate(), timeout=timeout
2067+
proc.communicate(), timeout=timeout_s
20692068
)
2070-
out = stdout.decode("utf-8", errors="replace").strip()
2071-
err = stderr.decode("utf-8", errors="replace").strip()
2069+
out = decode_process_bytes(stdout or b"").strip()
2070+
err = decode_process_bytes(stderr or b"").strip()
20722071
if err:
20732072
return f"[Exit {proc.returncode}]\nstdout:\n{out}\n\nstderr:\n{err}"
20742073
return out or "[No output]"
@@ -2095,7 +2094,8 @@ def _cmd_quote(s: str) -> str:
20952094
pass
20962095
raise
20972096
except Exception as e:
2098-
return f"[Error] {e}"
2097+
msg = str(e).strip() or type(e).__name__
2098+
return f"[Error] {type(e).__name__}: {msg}"
20992099
finally:
21002100
if script_path:
21012101
try:

‎backend/skills/builtins/configure_tevarn_skill.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@
2121

2222
_LOW_RISK = {c["key"] for c in SETTINGS_CATALOG if c["risk"] == "low"}
2323
_HIGH_RISK = {c["key"] for c in SETTINGS_CATALOG if c["risk"] == "high"}
24+
_ACTION_NAMES = frozenset({
25+
"guide",
26+
"status",
27+
"list_settings",
28+
"set_setting",
29+
"checklist",
30+
"search",
31+
"topics",
32+
})
2433

2534

2635
class ConfigureTevarnSkill(BaseSkill):
@@ -94,6 +103,11 @@ async def execute(
94103
**kwargs: Any,
95104
) -> str:
96105
action = (action or "guide").strip().lower()
106+
# 模型常把 action 写进 topic(topic=status),resolve_topic 会落到 overview。
107+
topic_key = (topic or "").strip().lower()
108+
if action in ("", "guide") and topic_key in _ACTION_NAMES and topic_key != "guide":
109+
action = topic_key
110+
topic = None
97111

98112
if action == "topics":
99113
lines = ["可用手册主题:"]

‎backend/tests/security/test_shell_injection.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,31 @@ async def test_windows_env_backtick_injection_blocked(command: str) -> None:
141141
with pytest.raises(ValueError) as ei:
142142
await ss.create_process(command)
143143
assert "injection risk" in str(ei.value)
144+
145+
146+
@pytest.mark.asyncio
147+
async def test_create_process_exec_falls_back_on_not_implemented(monkeypatch) -> None:
148+
async def _boom(*_a, **_k):
149+
raise NotImplementedError()
150+
151+
monkeypatch.setattr(ss.asyncio, "create_subprocess_exec", _boom)
152+
monkeypatch.setattr(ss, "_windows_selector_loop", lambda: False)
153+
proc = await ss.create_process_exec(sys.executable, "-c", "print(42)")
154+
out, _err = await proc.communicate()
155+
assert b"42" in out
156+
157+
158+
@pytest.mark.asyncio
159+
async def test_create_process_exec_selector_skips_asyncio_subprocess(monkeypatch) -> None:
160+
called = []
161+
162+
async def _boom(*_a, **_k):
163+
called.append(1)
164+
raise AssertionError("asyncio subprocess must not run on SelectorEventLoop path")
165+
166+
monkeypatch.setattr(ss, "_windows_selector_loop", lambda: True)
167+
monkeypatch.setattr(ss.asyncio, "create_subprocess_exec", _boom)
168+
proc = await ss.create_process_exec(sys.executable, "-c", "print(42)")
169+
out, _err = await proc.communicate()
170+
assert b"42" in out
171+
assert called == []

‎backend/tests/test_agent_grant_short_circuit.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def test_tool_matches_command_cap():
2727
assert tool_matches_crew_caps("command", ["command", "file_rw"])
2828
assert tool_matches_crew_caps("python", ["command"])
2929
assert not tool_matches_crew_caps("command", ["file_rw"])
30+
assert tool_matches_crew_caps("configure_tevarn", ["manage_skill"])
31+
assert tool_matches_crew_caps("get_system_status", ["current_time"])
3032

3133

3234
def test_session_whole_tool_covers_any_command_head():

‎backend/tests/test_cap_grant.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ def test_tool_to_cap_map():
5858
assert crew_cap_for_tool("command") == "command"
5959
assert crew_cap_for_tool("file_write") == "file_rw"
6060
assert crew_cap_for_tool("web_search") == "web_search"
61+
assert crew_cap_for_tool("configure_tevarn") == "manage_skill"
62+
assert crew_cap_for_tool("update_config") == "manage_skill"
63+
assert crew_cap_for_tool("get_system_status") == "current_time"
64+
assert crew_cap_for_tool("list_available_models") == "current_time"
6165

6266

6367
def test_auto_grant_eligibility():

0 commit comments

Comments
 (0)