Skip to content

Commit 4133b5f

Browse files
wu1wcursoragent
andcommitted
Unify Rust court authority and make chat stop/end UX match mainstream agents.
Collapse extra_roots, mcp_*, and session grants into the host court; keep partial replies on stop, list only deliverable artifacts, and reuse a detached backend only when jwt_fp matches. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 81581cc commit 4133b5f

71 files changed

Lines changed: 2973 additions & 524 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/agent/exit_reasons.py‎

Lines changed: 90 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,60 +11,78 @@
1111
# code → (title, body, recovery_hint, severity)
1212
_EXIT_CATALOG: dict[str, tuple[str, str, str, str]] = {
1313
"budget_grace": (
14-
"迭代预算已用尽(宽限终答)",
15-
"本段迭代配额已满,系统已强制生成最终回复,未再调用工具。",
14+
"本轮步数已用完",
15+
"本轮步数已经用完,已根据现有结果给出答复。",
1616
"可提高 agent_max_iterations / 自动续段,或拆小任务后重试;"
1717
"进程若仍 suspended 可在控制台「恢复」。",
1818
"warn",
1919
),
2020
"budget_exhausted": (
21-
"迭代预算耗尽",
22-
"运行在耗尽迭代配额后停止,未能进入宽限终答。",
21+
"本轮步数已用完",
22+
"本轮步数已经用完,没有再继续调用工具。",
2323
"提高迭代上限或缩小目标后重开会话;检查 Goal 自动续段设置。",
2424
"error",
2525
),
2626
# Token process budget (charge_tokens) — MUST NOT reuse budget_exhausted title
2727
"kernel_token_budget_exhausted": (
28-
"Token 预算耗尽",
29-
"本进程 token 额度已用尽,运行中断(这不是迭代轮次上限)。",
28+
"额度已用完",
29+
"这一轮的用量额度已经用完,所以停在这里。",
3030
"主会话/CEO:系统会自动 chat_elastic top_up(上限 agent_chat_budget_hard_cap);"
3131
"若仍中断多半已达天花板或 host 不可用。编制工单:crew_steward top_up / 提高 token_budget。",
3232
"error",
3333
),
3434
"kernel_iteration_exhausted": (
35-
"内核迭代预算耗尽",
36-
"Rust policy 侧 iteration 预算已耗尽,已触发优雅收尾。",
35+
"本轮步数已用完",
36+
"本轮步数已经用完,已根据现有结果收束。",
3737
"在控制台查看 /kernel/policy/{process_id};必要时 resume 或新开 run。",
3838
"warn",
3939
),
4040
"kernel_budget_precheck": (
41-
"Token 预算不足(事前中断)",
42-
"进程 token 预算不足以支撑下一次 LLM 调用,已避免烧穿预算。",
41+
"额度不足",
42+
"剩余额度不够支撑下一轮,已提前停下以免空转。",
4343
"提高进程 token_budget / top_up,或收窄任务范围后重试。",
4444
"error",
4545
),
4646
"kernel_gate_stop": (
47-
"内核门闩停止",
48-
"运行被 kernel 仲裁门(stop/挂起超时)中断。",
47+
"运行已中断",
48+
"这一轮被系统中断了。",
4949
"若进程为 suspended:POST /kernel/processes/{id}/resume 恢复;"
5050
"否则查看 decision_trail。",
5151
"info",
5252
),
5353
"doom_loop": (
54-
"工具空转熔断(doom loop)",
55-
"同一工具与相近参数连续重复,系统已熔断并改为直接作答。",
54+
"已停止重复操作",
55+
"同样的步骤重复了多次,已改为直接作答。",
5656
"换参数/换工具,或人工指定下一步;可在权限中调整 doom_loop 策略。",
5757
"warn",
5858
),
5959
"thrash": (
60-
"工具空转熔断",
61-
"检测到重复工具调用,已停止工具轮次。",
60+
"已停止重复操作",
61+
"检测到重复操作,已停止继续调用工具。",
6262
"根据已有结果作答,或换一种工具路径;避免同一命令连点。",
6363
"warn",
6464
),
65+
"empty_content_thrash": (
66+
"没有生成回复",
67+
"这一轮没有生成可见回复。直接发送下一条即可。",
68+
"重试同一问题,或换一种说法。",
69+
"warn",
70+
),
71+
"max_tool_rounds": (
72+
"本轮步数已用完",
73+
"本轮步数已经用完,已根据现有结果给出答复。",
74+
"发送「请继续」或缩小任务后重试。",
75+
"warn",
76+
),
77+
"max_segment_budget": (
78+
"本轮步数已用完",
79+
"本轮步数已经用完,已根据现有结果给出答复。",
80+
"发送「请继续」可接着做。",
81+
"warn",
82+
),
6583
"stopped_by_user": (
66-
"用户停止",
67-
"运行被用户手动停止。",
84+
"已停止",
85+
"已按你的操作停止生成。",
6886
"可重新发送消息继续;挂起进程可用 resume。",
6987
"info",
7088
),
@@ -110,15 +128,44 @@
110128
}
111129

112130

131+
# Chat-channel next step (no API paths / process ids — matches Cursor/ChatGPT tone)
132+
_CHAT_NEXT: dict[str, str] = {
133+
"budget_grace": "需要的话,发送「请继续」。",
134+
"budget_exhausted": "发送「请继续」可以接着做。",
135+
"kernel_iteration_exhausted": "发送「请继续」可以接着做。",
136+
"kernel_token_budget_exhausted": "缩小范围后再试,或在设置里提高上限。",
137+
"kernel_budget_precheck": "缩小任务后再试。",
138+
"kernel_gate_stop": "发送新消息即可继续。",
139+
"doom_loop": "若还要改,直接说下一步即可。",
140+
"thrash": "若还要改,直接说下一步即可。",
141+
"stopped_by_user": "",
142+
"completed": "",
143+
"empty_content_thrash": "",
144+
"max_tool_rounds": "发送「请继续」可以接着做。",
145+
"max_segment_budget": "发送「请继续」可以接着做。",
146+
"goal_stalled": "目标一段时间没有推进。直接说下一步,或发送「请继续」。",
147+
}
148+
149+
# Overlay when catalog body still names kernel/host internals
150+
_CHAT_BODY: dict[str, str] = {
151+
"host_down": "系统暂时连不上,请稍后再试。",
152+
"host_abi_mismatch": "系统组件需要更新后才能继续。",
153+
"sandbox_missing": "当前环境无法安全执行这项操作。",
154+
"intent_denied": "这次操作没有获得所需权限。",
155+
"resource_denied": "用量达到上限,这一轮先停在这里。",
156+
"empty_content_thrash": "这一轮没有生成可见回复。直接发送下一条即可。",
157+
}
158+
159+
113160
def describe_exit_reason(code: str | None) -> dict[str, Any]:
114-
"""Return structured UX payload for a loop exit code."""
161+
"""Return structured UX payload for a loop exit code (API / console)."""
115162
c = (code or "").strip() or "completed"
116163
if c in _EXIT_CATALOG:
117164
title, body, hint, sev = _EXIT_CATALOG[c]
118165
else:
119166
title, body, hint, sev = (
120-
f"结束原因:{c}",
121-
f"运行以代码 {c} 结束。",
167+
"这一轮已结束",
168+
"这一轮已经结束。",
122169
"查看 decision_trail 与 process meta;必要时 resume 或新开 run。",
123170
"info",
124171
)
@@ -135,18 +182,29 @@ def describe_exit_reason(code: str | None) -> dict[str, Any]:
135182
}
136183

137184

138-
def format_exit_user_message(code: str | None, *, process_id: str | None = None) -> str:
185+
def format_exit_user_message(
186+
code: str | None,
187+
*,
188+
process_id: str | None = None,
189+
for_operator: bool = False,
190+
) -> str:
191+
"""User-chat copy by default. Operator/console keeps API hints when requested."""
139192
d = describe_exit_reason(code)
140-
pid = (process_id or "").strip()
141-
lines = [
142-
f"[{d['title']}]",
143-
d["message"],
144-
]
145-
if d.get("recovery_hint"):
146-
lines.append(f"恢复建议:{d['recovery_hint']}")
147-
if pid:
148-
lines.append(f"process_id={pid} · 控制台可 resume / 查看策略与决策轨迹。")
149-
return "\n".join(lines)
193+
if for_operator:
194+
pid = (process_id or "").strip()
195+
lines = [f"[{d['title']}]", d["message"]]
196+
if d.get("recovery_hint"):
197+
lines.append(f"恢复建议:{d['recovery_hint']}")
198+
if pid:
199+
lines.append(f"process_id={pid} · 控制台可 resume / 查看策略与决策轨迹。")
200+
return "\n".join(lines)
201+
c = str(d.get("code") or "")
202+
body = _CHAT_BODY.get(c) or str(d.get("message") or "").strip()
203+
lines = [body]
204+
nxt = _CHAT_NEXT.get(c, "")
205+
if nxt:
206+
lines.append(nxt)
207+
return "\n".join(x for x in lines if x)
150208

151209

152210
__all__ = ["describe_exit_reason", "format_exit_user_message"]

‎backend/agent/grant_store.py‎

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def _prune_expired_locked() -> int:
196196
# MCP 管理与运行时(动态 mcp_* 走 tool_matches 前缀规则)
197197
"manage_mcp": "manage_mcp",
198198
"manage_skill": "manage_skill",
199+
"generate_ppt": "file_rw",
199200
}
200201

201202

@@ -229,6 +230,9 @@ def sync_catalog_from_kernel(kernel: Any | None = None) -> bool:
229230
TOOL_TO_CREW_CAP.setdefault("clarify", "crew_steward")
230231
TOOL_TO_CREW_CAP.setdefault("manage_mcp", "manage_mcp")
231232
TOOL_TO_CREW_CAP.setdefault("manage_skill", "manage_skill")
233+
TOOL_TO_CREW_CAP.setdefault("generate_ppt", "file_rw")
234+
TOOL_TO_CREW_CAP.setdefault("current_time", "current_time")
235+
TOOL_TO_CREW_CAP.setdefault("result_load", "file_read")
232236
logger.info("grant_store catalog synced from rust (%s entries)", len(TOOL_TO_CREW_CAP))
233237
return True
234238
except Exception as e:
@@ -270,11 +274,65 @@ def allow_signature(tool: str, arguments: dict[str, Any] | None = None) -> str:
270274
return tool
271275

272276

277+
def _rust_grant_rpc(method: str, params: dict[str, Any], kernel: Any | None = None) -> dict[str, Any] | None:
278+
"""Best-effort live court map. Old hosts without these RPCs are ignored."""
279+
try:
280+
k = kernel
281+
if k is None:
282+
from backend.kernel_rust.client import is_rust_host_available
283+
284+
if not is_rust_host_available():
285+
return None
286+
from backend.kernel import get_kernel
287+
288+
k = get_kernel()
289+
if not hasattr(k, "_call"):
290+
return None
291+
r = k._call(method, params)
292+
return r if isinstance(r, dict) else None
293+
except Exception as e:
294+
logger.debug("rust grant rpc %s skip: %s", method, e)
295+
return None
296+
297+
298+
def rehydrate_session_grants_to_kernel(kernel: Any | None = None) -> int:
299+
"""Push persisted grants into the live Rust SessionGrantStore."""
300+
_ensure_loaded()
301+
n = 0
302+
with _grants_lock:
303+
items = [(sid, sorted(sigs)) for sid, sigs in _session_grants.items() if sigs]
304+
for sid, sigs in items:
305+
r = _rust_grant_rpc(
306+
"session_grant_add",
307+
{"session_id": sid, "sigs": sigs},
308+
kernel=kernel,
309+
)
310+
if r is not None:
311+
n += 1
312+
if n:
313+
logger.info("session grants rehydrated to rust sessions=%s", n)
314+
return n
315+
316+
273317
def has_session_grant(session_id: str | None, tool: str, arguments: dict[str, Any] | None = None) -> bool:
274318
if not session_id:
275319
return False
276320
_ensure_loaded()
277321
sid = str(session_id)
322+
sig = allow_signature(tool, arguments)
323+
rust = _rust_grant_rpc(
324+
"session_grant_has",
325+
{"session_id": sid, "sig": sig},
326+
)
327+
if isinstance(rust, dict) and rust.get("has") is True:
328+
return True
329+
if rust is not None and tool != sig:
330+
whole = _rust_grant_rpc(
331+
"session_grant_has",
332+
{"session_id": sid, "sig": tool},
333+
)
334+
if isinstance(whole, dict) and whole.get("has") is True:
335+
return True
278336
with _grants_lock:
279337
# Lazy TTL check
280338
ttl = _grant_ttl_seconds()
@@ -289,7 +347,7 @@ def has_session_grant(session_id: str | None, tool: str, arguments: dict[str, An
289347
if not grants:
290348
return False
291349
# 细粒度签名(command:rm)或整工具名(本员工/本会话放行 command)
292-
if allow_signature(tool, arguments) in grants:
350+
if sig in grants:
293351
return True
294352
if tool in grants:
295353
return True
@@ -321,6 +379,10 @@ def add_session_grant(
321379
bucket.add(tool)
322380
_session_grant_ts[sid] = time.time()
323381
_persist()
382+
sigs = [sig]
383+
if whole_tool and tool != sig:
384+
sigs.append(tool)
385+
_rust_grant_rpc("session_grant_add", {"session_id": sid, "sigs": sigs})
324386
logger.info(
325387
"grant session allow session=%s sig=%s whole=%s",
326388
sid[:8],
@@ -447,17 +509,26 @@ def clear_session_grants(session_id: str | None) -> None:
447509
_session_grant_ts.pop(sid, None)
448510
if had:
449511
_persist()
512+
_rust_grant_rpc("session_grant_clear", {"session_id": str(session_id)})
450513

451514

452515
def prune_expired_session_grants() -> int:
453516
"""Drop grants past TTL. Returns number removed."""
454517
_ensure_loaded()
455518
with _grants_lock:
519+
dead = [
520+
sid
521+
for sid, ts in list(_session_grant_ts.items())
522+
if _grant_ttl_seconds() > 0
523+
and (time.time() - float(ts or 0)) > _grant_ttl_seconds()
524+
]
456525
n = _prune_expired_locked()
457526
if n:
458527
_persist()
459528
logger.info("session grants TTL pruned count=%s", n)
460-
return n
529+
for sid in dead:
530+
_rust_grant_rpc("session_grant_clear", {"session_id": sid})
531+
return n
461532

462533

463534
def prune_orphan_session_grants(live_session_ids: set[str] | list[str] | None) -> int:
@@ -478,7 +549,9 @@ def prune_orphan_session_grants(live_session_ids: set[str] | list[str] | None) -
478549
len(dead),
479550
len(_session_grants),
480551
)
481-
return len(dead)
552+
for sid in dead:
553+
_rust_grant_rpc("session_grant_clear", {"session_id": sid})
554+
return len(dead)
482555

483556

484557
async def prune_session_grants_startup() -> dict[str, int]:
@@ -560,3 +633,5 @@ def reset_for_tests() -> None:
560633
p.unlink()
561634
except OSError:
562635
pass
636+
# Do not session_grant_clear the live host: pytest may share a desktop
637+
# tevarn-kernel-host. Court tests clear their own session ids explicitly.

0 commit comments

Comments
 (0)