|
|
@@ -15,7 +15,9 @@ import getpass
|
|
|
import os
|
|
|
import subprocess
|
|
|
import sys
|
|
|
+from collections.abc import Callable
|
|
|
from datetime import datetime
|
|
|
+from typing import Literal
|
|
|
from uuid import uuid4
|
|
|
|
|
|
from langgraph.types import Command
|
|
|
@@ -57,6 +59,30 @@ def _build_utf8_reexec_command(arguments: list[str]) -> list[str]:
|
|
|
]
|
|
|
|
|
|
|
|
|
+def _run_utf8_child(
|
|
|
+ arguments: list[str],
|
|
|
+ environment: dict[str, str],
|
|
|
+ *,
|
|
|
+ runner: Callable[..., subprocess.CompletedProcess] | None = None,
|
|
|
+) -> int:
|
|
|
+ """运行 UTF-8 子进程,并把父进程收到的 Ctrl+C 转成标准退出码。
|
|
|
+
|
|
|
+ Windows 终端会把 Ctrl+C 同时广播给等待中的父进程和实际执行 Agent 的
|
|
|
+ 子进程。子进程可以优雅处理审批取消,但父进程的 subprocess.run() 仍可能
|
|
|
+ 收到 KeyboardInterrupt;这里统一转换为 130,避免父层再次打印 Traceback。
|
|
|
+ """
|
|
|
+ runner = runner or subprocess.run
|
|
|
+ try:
|
|
|
+ completed = runner(
|
|
|
+ _build_utf8_reexec_command(arguments),
|
|
|
+ env=environment,
|
|
|
+ check=False,
|
|
|
+ )
|
|
|
+ except KeyboardInterrupt:
|
|
|
+ return 130
|
|
|
+ return completed.returncode
|
|
|
+
|
|
|
+
|
|
|
def _ensure_utf8_mode() -> None:
|
|
|
"""
|
|
|
避免 Windows 将 Deep Agents 子进程的 UTF-8 输出按 GBK 解码。
|
|
|
@@ -70,12 +96,11 @@ def _ensure_utf8_mode() -> None:
|
|
|
environment = os.environ.copy()
|
|
|
environment["PYTHONUTF8"] = "1"
|
|
|
environment["PYTHONIOENCODING"] = "utf-8"
|
|
|
- completed = subprocess.run(
|
|
|
- _build_utf8_reexec_command(sys.argv[1:]),
|
|
|
- env=environment,
|
|
|
- check=False,
|
|
|
+ exit_code = _run_utf8_child(
|
|
|
+ sys.argv[1:],
|
|
|
+ environment,
|
|
|
)
|
|
|
- raise SystemExit(completed.returncode)
|
|
|
+ raise SystemExit(exit_code)
|
|
|
|
|
|
|
|
|
def _print_final_answer(result) -> None:
|
|
|
@@ -111,6 +136,23 @@ def _approval_outcome_message(
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _read_approval_decision(
|
|
|
+ input_func: Callable[[str], str] | None = None,
|
|
|
+) -> Literal["approve", "reject", "cancel"]:
|
|
|
+ """读取人工审批输入,并把终端中断与业务拒绝明确分开。
|
|
|
+
|
|
|
+ 只有显式输入 y 才表示 approve;普通的 n 或空输入表示 reject。Ctrl+C 和
|
|
|
+ stdin 关闭说明用户没有完成审批,返回 cancel,调用方不得把它记录成拒绝。
|
|
|
+ input_func 参数用于单元测试注入,不需要启动真实交互终端。
|
|
|
+ """
|
|
|
+ input_func = input_func or input
|
|
|
+ try:
|
|
|
+ raw_decision = input_func("批准执行?[y/N] ")
|
|
|
+ except (KeyboardInterrupt, EOFError):
|
|
|
+ return "cancel"
|
|
|
+ return "approve" if raw_decision.strip().lower() == "y" else "reject"
|
|
|
+
|
|
|
+
|
|
|
def _print_trace_url(settings: Settings, thread_id: str, *, client=None) -> str | None:
|
|
|
"""查询并打印本次 Trace 链接;失败时不影响业务流程。"""
|
|
|
try:
|
|
|
@@ -223,10 +265,19 @@ def main() -> None:
|
|
|
)
|
|
|
if trace_url:
|
|
|
print("请先打开以上链接检查执行过程,再决定是否批准。")
|
|
|
- approved = input("批准执行?[y/N] ").strip().lower() == "y"
|
|
|
+ decision = _read_approval_decision()
|
|
|
+ if decision == "cancel":
|
|
|
+ # cancel 不是业务拒绝:没有恢复工作流,也不写审批审计记录。
|
|
|
+ # 使用 130(128 + SIGINT)让脚本或 CI 能识别本次运行由用户取消。
|
|
|
+ print(
|
|
|
+ "\n已取消本次人工审批:未作出批准或拒绝决定,"
|
|
|
+ "提交工具未执行。报告已保留,重新运行时会自动归档。"
|
|
|
+ )
|
|
|
+ raise SystemExit(130)
|
|
|
+
|
|
|
# 用户只需显式输入 y 才会 approve;空输入、n 和其他内容均按 reject
|
|
|
# 处理,符合有副作用操作的 fail-closed 原则。
|
|
|
- decision = "approve" if approved else "reject"
|
|
|
+ approved = decision == "approve"
|
|
|
handled_at = datetime.now().astimezone()
|
|
|
request_args = request["args"]
|
|
|
report_path = resolve_report_path(
|