ot-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """OpenTerminal: 自然语言终端 Agent。"""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,39 @@
1
+ """agent 域(阶段二 Task 14 重组;终审修复改惰性再导出)。
2
+
3
+ runner.py——TaskRunner 任务执行与超时看护;build.py——智能体组装
4
+ (build_agent/_with_skills);middleware.py——审批/拒绝与异常重试中间件;
5
+ prompt.py——系统提示词;model.py——聊天模型工厂;backend.py——
6
+ PtyShellBackend(PTY 桥接 deepagents 工具后端);token_est.py——token
7
+ 计数;sysprobe.py——系统档案探测/缓存与手动预设。
8
+
9
+ __init__ 用 PEP 562 惰性转发旧 ``agent`` 模块全部公开名——按名字路由到
10
+ 所属子模块,且不可急切导入 runner:deepagents/langchain 导入约 4.6s,
11
+ 而 core/pipeline.py 模块级 ``from ..agent.sysprobe import ...`` 必先执行
12
+ 本 __init__,急切导入会把 AI 栈拖进每次 CLI/Web 启动(击穿 _load_agent_stack
13
+ 惰性机制)。sysprobe/token_est 为独立子模块直接导入,不进 __getattr__。
14
+
15
+ 实现注记:转发必须经 importlib.import_module,不能 ``from . import
16
+ runner``——后者的 fromlist 处理对未知名会再触发本 __getattr__,
17
+ 形成递归(PEP 562 已知坑)。
18
+ """
19
+
20
+ from importlib import import_module
21
+ from typing import Any
22
+
23
+ # 公开名 → 所属子模块;未登记的名字(TaskRunner/TaskEvent/AGENT_STALL_*
24
+ # 等)默认落在 runner。显式登记保证访问 build_agent 只导入 build.py,
25
+ # 不把 4.6s 的 runner(deepagents/langchain)拖进惰性路径。
26
+ _NAME_MODULE: dict[str, str] = {
27
+ "build_agent": ".build",
28
+ "_with_skills": ".build",
29
+ "DenyMiddleware": ".middleware",
30
+ "ToolErrorRetryMiddleware": ".middleware",
31
+ "build_system_prompt": ".prompt",
32
+ "_DIALECT_TABLE": ".prompt",
33
+ "build_chat_model": ".model",
34
+ }
35
+
36
+
37
+ def __getattr__(name: str) -> Any:
38
+ module = import_module(_NAME_MODULE.get(name, ".runner"), __name__)
39
+ return getattr(module, name)
@@ -0,0 +1,180 @@
1
+ """把 ShellSession 适配成 deepagents 的 SandboxBackendProtocol。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from collections.abc import Awaitable, Callable
8
+ from pathlib import Path
9
+
10
+ from deepagents.backends import LocalShellBackend
11
+ from deepagents.backends.protocol import (
12
+ DeleteResult,
13
+ EditResult,
14
+ ExecuteResponse,
15
+ GlobResult,
16
+ GrepResult,
17
+ LsResult,
18
+ ReadResult,
19
+ WriteResult,
20
+ )
21
+
22
+ from ..session.base import ShellSession
23
+
24
+ # 带盘符的路径段(`C:`、`/C:/...`、`\\?\C:\...`):deepagents 的路径拼接遇到它
25
+ # 会换锚,把结果带出 root
26
+ _DRIVE_SEG_RE = re.compile(r"(?:^|[/\\])[A-Za-z]:")
27
+
28
+
29
+ def _local_root(root_dir: str | None, session: ShellSession,
30
+ remote_target: bool) -> Path:
31
+ """read_file/write_file 等文件工具的**本机**根目录(虚拟路径 /x 的锚点)。
32
+
33
+ 只有本机真实存在的绝对目录才配当根:SSH(含嵌套 ssh)会话的 cwd 是远端
34
+ 路径,拿来当根会在本机解析出影子目录——Windows 上 ``Path("/tmp")`` 是无
35
+ 盘符路径,resolve 出 ``<当前盘>:\\tmp``——甚至按远端路径树在本机凭空
36
+ mkdir。远端目标一律回退到用户主目录:「下载到我本机」的落点,且
37
+ /Downloads、/Desktop、/Documents 都在根内可写。
38
+ """
39
+ if root_dir is not None:
40
+ # 显式指定照办(测试/调用方自知其意),只做规范化,不改语义
41
+ return Path(root_dir).expanduser().resolve()
42
+ cand = "" if remote_target else (getattr(session, "cwd", "") or "")
43
+ if cand:
44
+ try:
45
+ p = Path(cand).expanduser()
46
+ if p.is_absolute() and p.is_dir():
47
+ return p.resolve()
48
+ except (OSError, RuntimeError, ValueError):
49
+ pass
50
+ return Path.home()
51
+
52
+
53
+ class PtyShellBackend(LocalShellBackend):
54
+ """文件操作沿用 LocalShellBackend;execute 走持久 PTY 会话。
55
+
56
+ 文件根是**本机**目录,构造时定死、此后不再跟随 shell 的 cd:虚拟路径
57
+ /x 的含义必须全程稳定;且 execute 回报的 cwd 可能来自远端 shell,覆盖根
58
+ 会把根打成无盘符/远端路径,之后所有文件工具在 ``relative_to`` 判据下集体
59
+ 报「outside root directory」(真机「write_file ×失败」根因)。
60
+
61
+ SSH 模式下注意:内置文件工具作用于本机,Agent 系统提示词中已要求
62
+ 远程文件操作一律走 execute。
63
+ """
64
+
65
+ def __init__(self, session: ShellSession, root_dir: str | None = None, *,
66
+ remote_target: bool = False) -> None:
67
+ super().__init__(root_dir=_local_root(root_dir, session, remote_target),
68
+ inherit_env=True)
69
+ self.session = session
70
+ # CLI 在任务开始前设置、结束后清空(rich console 实时打印)
71
+ self.on_output: Callable[[str], None] | None = None
72
+ # 命令实际开始执行(审批通过之后)/结束(带 exit code)时的同步回调,
73
+ # 供展示层打印“执行”面板、按成败切换详述模式
74
+ self.on_start: Callable[[str], None] | None = None
75
+ self.on_finish: Callable[[str, int], None] | None = None
76
+ # 命令注入前的异步门闩:worker 用它等前端把流底分析卡结账垫满
77
+ # (ai_boundary/boundary_settled 往返),保证命令回显在 PTY 里落在
78
+ # pad 空行之后——回显先于空行进缓冲会把卡的连续空白区打断=永久盖帽
79
+ self.on_boundary: Callable[[], Awaitable[None]] | None = None
80
+
81
+ def _resolve_path(self, key: str) -> Path:
82
+ """越界路径判据补一层:带盘符的段直接判越界。
83
+
84
+ deepagents 的 `root / key` 拼接遇 `C:/...` 会换锚跳走,工具层
85
+ validate_path 只挡 `^[a-zA-Z]:` 开头,挡不住 `/C:/Users/x` 这类「过了
86
+ 校验、解析却越界」的路径(docs/bug.md 上传场景那类)。根落在 C: 盘时
87
+ 它会真逃出沙箱——实测能列出/写入真实桌面。这里与越界路径同口径拒绝,
88
+ 由调用方包成 error 结果回模型。
89
+ """
90
+ if _DRIVE_SEG_RE.search(str(key)):
91
+ raise ValueError(
92
+ f"Path:{key} outside root directory: {self.cwd}")
93
+ return super()._resolve_path(key)
94
+
95
+ # --- 文件操作:路径 ValueError 不上抛,包成 error 结果 ---
96
+ # deepagents 的 FilesystemBackend._resolve_path 对越界/穿越路径抛
97
+ # ValueError,而 read/write 等只兜 (OSError, RuntimeError);工具层的
98
+ # validate_path 又拦不住 `/C:/...` 这类「过了校验、解析却越界」的路径
99
+ # (Windows 桌面文件上传场景的「任务失败:ValueError」根因)。backend
100
+ # 契约是返回 error 结果而非抛异常——这里补上,模型看到错误文本后自行
101
+ # 换方案;其余未预期异常交给 ToolErrorRetryMiddleware 统一收口。
102
+
103
+ def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
104
+ try:
105
+ return super().read(file_path, offset=offset, limit=limit)
106
+ except ValueError as e:
107
+ return ReadResult(error=f"Error reading file '{file_path}': {e}")
108
+
109
+ def write(self, file_path: str, content: str) -> WriteResult:
110
+ try:
111
+ return super().write(file_path, content)
112
+ except ValueError as e:
113
+ return WriteResult(error=f"Error writing file '{file_path}': {e}")
114
+
115
+ def edit(self, file_path: str, old_string: str, new_string: str,
116
+ replace_all: bool = False) -> EditResult:
117
+ try:
118
+ return super().edit(file_path, old_string, new_string, replace_all)
119
+ except ValueError as e:
120
+ return EditResult(error=f"Error editing file '{file_path}': {e}")
121
+
122
+ def delete(self, file_path: str) -> DeleteResult:
123
+ try:
124
+ return super().delete(file_path)
125
+ except ValueError as e:
126
+ return DeleteResult(error=f"Error deleting '{file_path}': {e}")
127
+
128
+ def ls(self, path: str) -> LsResult:
129
+ try:
130
+ return super().ls(path)
131
+ except ValueError as e:
132
+ return LsResult(error=f"Cannot list '{path}': {e}", entries=None)
133
+
134
+ def glob(self, pattern: str, path: str | None = None) -> GlobResult:
135
+ try:
136
+ return super().glob(pattern, path=path)
137
+ except ValueError as e:
138
+ return GlobResult(error=f"Error globbing '{pattern}': {e}")
139
+
140
+ def grep(self, pattern: str, path: str | None = None, glob: str | None = None,
141
+ *, max_count: int | None = None, context_lines: int = 0) -> GrepResult:
142
+ try:
143
+ return super().grep(pattern, path=path, glob=glob,
144
+ max_count=max_count, context_lines=context_lines)
145
+ except ValueError as e:
146
+ return GrepResult(error=f"Error searching '{pattern}': {e}", matches=[])
147
+
148
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
149
+ if self.on_boundary is not None:
150
+ await self.on_boundary()
151
+ if self.on_start is not None:
152
+ self.on_start(command)
153
+ try:
154
+ result = await self.session.run(
155
+ command, timeout=timeout, on_output=self.on_output
156
+ )
157
+ except BaseException:
158
+ # 取消/异常路径也要收束输出块括号(term_close),否则前端
159
+ # _cmdOut 悬空,后续终端字节会被误写进僵尸输出块
160
+ if self.on_finish is not None:
161
+ self.on_finish(command, 130)
162
+ raise
163
+ # 这里刻意不动 self.cwd:文件根构造时定死(见类文档)。曾经的
164
+ # `self.cwd = Path(result.cwd)` 会把远端 shell 的 cwd(/tmp 这类无盘符
165
+ # 路径)灌成本机根,随后每个文件工具都因 relative_to 判据失败而报
166
+ # 「outside root directory: \\tmp」——真机 write_file 连续 ×失败 的根因。
167
+ if self.on_finish is not None:
168
+ self.on_finish(command, result.exit_code)
169
+ return ExecuteResponse(
170
+ output=result.output,
171
+ exit_code=result.exit_code,
172
+ truncated=result.truncated,
173
+ )
174
+
175
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
176
+ try:
177
+ asyncio.get_running_loop()
178
+ except RuntimeError:
179
+ return asyncio.run(self.aexecute(command, timeout=timeout))
180
+ raise RuntimeError("同步 execute() 不能在事件循环内调用,请用 aexecute()")
@@ -0,0 +1,115 @@
1
+ """agent 装配:组件黏合成可执行的 LangGraph 智能体(从 runner.py 拆出)。
2
+
3
+ build_agent 编排 prompt/model/middleware/backend → create_deep_agent;
4
+ _with_skills 给文件后端挂代理 Skills。只在真正构建 agent 时 import。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from deepagents import create_deep_agent
12
+ from deepagents.backends import CompositeBackend, FilesystemBackend
13
+ from deepagents.middleware.skills import SkillsMiddleware
14
+ from langchain.agents.middleware import (
15
+ HumanInTheLoopMiddleware,
16
+ ModelRetryMiddleware,
17
+ )
18
+ from langgraph.checkpoint.memory import MemorySaver
19
+
20
+ from ..config import Config
21
+ from ..policy import Policy
22
+ from ..skills import skill_routes, skill_sources
23
+ from .backend import PtyShellBackend
24
+ from .middleware import DenyMiddleware, ToolErrorRetryMiddleware
25
+ from .model import build_chat_model
26
+ from .prompt import build_system_prompt
27
+ from .sysprobe import SystemProfile
28
+
29
+
30
+ def build_agent(
31
+ profile: SystemProfile,
32
+ session,
33
+ cfg: Config,
34
+ policy: Policy | None = None,
35
+ *,
36
+ model: Any = None,
37
+ remote_target: bool = False,
38
+ ):
39
+ """返回 (compiled_agent, session_allowed_commands)。
40
+
41
+ remote_target:目标是 SSH/远端 shell——此时 session.cwd 是远端路径,不能当
42
+ 本机文件根(见 backend._local_root),文件工具回退到用户主目录。
43
+ """
44
+ policy = policy or Policy(
45
+ mode=cfg.policy.mode,
46
+ auto_extra=cfg.policy.auto_extra,
47
+ approve_extra=cfg.policy.approve_extra,
48
+ deny_extra=cfg.policy.deny_extra,
49
+ )
50
+ allowed: set[str] = set()
51
+ backend = PtyShellBackend(session, remote_target=remote_target)
52
+
53
+ def _when(req) -> bool:
54
+ cmd = req.tool_call.get("args", {}).get("command", "")
55
+ return policy.needs_approval(cmd, allowed)
56
+
57
+ def _description(tc, state, runtime) -> str:
58
+ cmd = tc.get("args", {}).get("command", "")
59
+ reasons = ";".join(policy.classify(cmd).reasons) or "高危命令"
60
+ return f"高危命令需要审批\n\n目标主机:{profile.host}\n风险点:{reasons}\n命令:{cmd}"
61
+
62
+ middleware = [
63
+ # 最外层:任何工具链异常先在这里收口成 ToolMessage,不再打死任务
64
+ ToolErrorRetryMiddleware(max_retries=3),
65
+ DenyMiddleware(policy),
66
+ ]
67
+
68
+ def _model_failure(exc: Exception) -> str:
69
+ # on_failure 只拿到异常;文案对齐「任务失败」口径,成为最终回复
70
+ return (f"任务失败:模型调用重试 3 次仍未成功"
71
+ f"({type(exc).__name__}: {exc})。"
72
+ f"请稍后重试;若持续失败请检查模型网关配置。")
73
+
74
+ # 模型 API 侧(网关抖动/限流/超时)独立重试,不与工具层重试混计
75
+ middleware.append(ModelRetryMiddleware(
76
+ max_retries=3, on_failure=_model_failure,
77
+ ))
78
+ middleware.append(HumanInTheLoopMiddleware(interrupt_on={"execute": {
79
+ "allowed_decisions": ["approve", "edit", "reject"],
80
+ "when": _when,
81
+ "description": _description,
82
+ }}))
83
+
84
+ agent = create_deep_agent(
85
+ model=model or build_chat_model(cfg.model),
86
+ backend=_with_skills(backend, middleware),
87
+ # 本机文件根一并告知模型:虚拟路径落地在本机哪里必须能向用户交代,
88
+ # 否则「下载到我本机」写完只报 /x,用户在磁盘上找不到文件
89
+ system_prompt=build_system_prompt(profile, str(backend.cwd)),
90
+ middleware=middleware,
91
+ checkpointer=MemorySaver(),
92
+ )
93
+ return agent, allowed, backend
94
+
95
+
96
+ def _with_skills(backend: PtyShellBackend, middleware: list):
97
+ """给 agent 挂上内置/用户两层 Agent Skills(渐进式披露)。
98
+
99
+ skill 目录在文件沙箱(shell cwd)之外,故经 CompositeBackend 以
100
+ /skills/... 虚拟前缀挂进文件系统:SkillsMiddleware 把 skill 清单
101
+ 注入系统提示词,模型按需 read_file 加载 SKILL.md 全文;用户目录
102
+ 后挂,同名 skill 覆盖内置。backend 本身原样返回给调用方(回调、
103
+ cwd 语义不变),composite 只作为 agent 的文件/命令入口。
104
+ """
105
+ routes = skill_routes()
106
+ if not routes:
107
+ return backend
108
+ agent_backend: Any = CompositeBackend(
109
+ default=backend,
110
+ routes={p: FilesystemBackend(root_dir=str(d)) for p, d in routes.items()},
111
+ )
112
+ middleware.append(
113
+ SkillsMiddleware(backend=agent_backend, sources=skill_sources())
114
+ )
115
+ return agent_backend
@@ -0,0 +1,111 @@
1
+ """agent 中间件:审批/拒绝(DenyMiddleware)与异常重试(ToolErrorRetryMiddleware)。
2
+
3
+ 从 runner.py 拆出(一事一文件)。两类都做 wrap_tool_call 包装,挂进
4
+ create_deep_agent 的工具链;纯工具层逻辑,不依赖任务执行。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from langchain.agents.middleware import AgentMiddleware
10
+ from langchain_core.messages import ToolMessage
11
+ from langgraph.errors import GraphBubbleUp
12
+
13
+ from ..policy import Policy
14
+
15
+
16
+ class DenyMiddleware(AgentMiddleware):
17
+ """灾难命令:不执行,直接把拒绝原因作为 tool 错误回模型。"""
18
+
19
+ def __init__(self, policy: Policy) -> None:
20
+ self.policy = policy
21
+
22
+ async def awrap_tool_call(self, request, handler):
23
+ tc = request.tool_call
24
+ if tc.get("name") == "execute":
25
+ cmd = tc.get("args", {}).get("command", "")
26
+ d = self.policy.classify(cmd)
27
+ if d.level == "deny":
28
+ return ToolMessage(
29
+ content=f"命令被 OpenTerminal 安全策略拒绝:{cmd}\n原因:"
30
+ f"{';'.join(d.reasons)}。请换一个方案,不要换皮重试。",
31
+ tool_call_id=tc["id"],
32
+ status="error",
33
+ )
34
+ return await handler(request)
35
+
36
+
37
+ class ToolErrorRetryMiddleware(AgentMiddleware):
38
+ """工具执行异常→错误 ToolMessage 回模型重试;用尽后宣告任务失败让模型总结。
39
+
40
+ 约定(产品诉求):异常不得直接打死任务。前 max_retries 次失败把异常
41
+ 信息交给模型,由它换方案重试;第 max_retries+1 次失败返回「任务失败」
42
+ 并要求模型停止调用工具、总结失败原因。langgraph 控制流(HITL 中断等)
43
+ 原样上抛,不吞。
44
+ """
45
+
46
+ def __init__(self, max_retries: int = 3) -> None:
47
+ self.max_retries = max_retries
48
+ self._failures = 0
49
+ self._gave_up = False
50
+
51
+ def before_agent(self, state, runtime) -> dict | None:
52
+ # 计数按任务(一次 run/resume 驱动)清零,跨任务不累积
53
+ self._failures = 0
54
+ self._gave_up = False
55
+ return None
56
+
57
+ async def abefore_agent(self, state, runtime) -> dict | None:
58
+ self._failures = 0
59
+ self._gave_up = False
60
+ return None
61
+
62
+ def _reject(self, tc: dict) -> ToolMessage:
63
+ return ToolMessage(
64
+ content="任务失败:重试次数已用尽。请勿再调用工具,"
65
+ "直接向用户总结失败原因(已尝试的操作、错误信息和建议)。",
66
+ tool_call_id=tc["id"],
67
+ name=tc.get("name", "tool"),
68
+ status="error",
69
+ )
70
+
71
+ def _feedback(self, tc: dict, exc: Exception) -> ToolMessage:
72
+ name = tc.get("name", "tool")
73
+ self._failures += 1
74
+ detail = f"{type(exc).__name__}: {exc}"
75
+ if self._failures <= self.max_retries:
76
+ content = (
77
+ f"工具 {name} 执行出错(第 {self._failures}/{self.max_retries} 次失败):"
78
+ f"{detail}\n请根据错误信息调整参数或换一种方案后重试。"
79
+ )
80
+ else:
81
+ self._gave_up = True
82
+ content = (
83
+ f"任务失败:已重试 {self.max_retries} 次仍无法完成,不再重试。\n"
84
+ f"最后一次错误:{detail}\n"
85
+ f"请停止尝试,向用户总结失败原因(已尝试的操作、错误信息和建议)。"
86
+ )
87
+ return ToolMessage(
88
+ content=content, tool_call_id=tc["id"], name=name, status="error",
89
+ )
90
+
91
+ def wrap_tool_call(self, request, handler):
92
+ tc = request.tool_call
93
+ if self._gave_up:
94
+ return self._reject(tc)
95
+ try:
96
+ return handler(request)
97
+ except GraphBubbleUp:
98
+ raise
99
+ except Exception as e: # noqa: BLE001 - 异常转 tool 错误回模型
100
+ return self._feedback(tc, e)
101
+
102
+ async def awrap_tool_call(self, request, handler):
103
+ tc = request.tool_call
104
+ if self._gave_up:
105
+ return self._reject(tc)
106
+ try:
107
+ return await handler(request)
108
+ except GraphBubbleUp:
109
+ raise
110
+ except Exception as e: # noqa: BLE001 - 异常转 tool 错误回模型
111
+ return self._feedback(tc, e)
@@ -0,0 +1,41 @@
1
+ """聊天模型构造(从 runner.py 拆出)。
2
+
3
+ build_chat_model 独依赖 ModelConfig;anthropic 分支的 ChatAnthropic 在
4
+ 模块级导入(同原 runner.py),openai 分支在分支内惰性导入。model 只在
5
+ 真正构建 agent 时被 import(build.py 或测试直引),不落在 core 启动热路径。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ from langchain_anthropic import ChatAnthropic
14
+
15
+ from ..config import ModelConfig
16
+
17
+
18
+ def build_chat_model(cfg: ModelConfig) -> Any:
19
+ """按 cfg.provider 构造 langchain 聊天模型。
20
+
21
+ anthropic → ChatAnthropic(缺省,走 base_url 网关);openai → ChatOpenAI,
22
+ 任何 OpenAI 兼容协议网关(base_url 留空则用 SDK 官方端点)。langchain_openai
23
+ 在分支内惰性导入:anthropic 配置既不付导入耗时也不要求安装该包。
24
+ """
25
+ # .env 直配的 key 优先(项目 .env > ~/.openterminal/.env);否则走环境变量
26
+ api_key = cfg.api_key or os.environ.get(cfg.api_key_env, "not-set")
27
+ if cfg.provider == "openai":
28
+ from langchain_openai import ChatOpenAI
29
+
30
+ return ChatOpenAI(
31
+ model=cfg.model,
32
+ base_url=cfg.base_url or None,
33
+ api_key=api_key,
34
+ max_tokens=4096,
35
+ )
36
+ return ChatAnthropic(
37
+ model=cfg.model,
38
+ base_url=cfg.base_url,
39
+ api_key=api_key,
40
+ max_tokens=4096,
41
+ )
@@ -0,0 +1,63 @@
1
+ """系统提示词构建(从 runner.py 拆出)。
2
+
3
+ 方言映射表与 build_system_prompt 只依赖 SystemProfile(sysprobe 纯数据
4
+ 结构),不触碰 deepagents/langchain 运行时,可安全独立导入。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .sysprobe import SystemProfile
10
+
11
+ _DIALECT_TABLE = """
12
+ | 意图 | debian/Ubuntu | rhel CentOS7 | rhel Rocky/Fedora | alpine | darwin |
13
+ |---|---|---|---|---|---|
14
+ | 安装软件 jq | sudo apt-get install -y jq | sudo yum install -y jq | sudo dnf install -y jq | sudo apk add jq | brew install jq |
15
+ | 重启 nginx | sudo systemctl restart nginx | 同左 | 同左 | sudo rc-service nginx restart | brew services restart nginx |
16
+ | 查监听端口 | ss -ltnp | netstat -ltnp | ss -ltnp | netstat -ltnp | lsof -i |
17
+ """
18
+
19
+
20
+ def build_system_prompt(p: SystemProfile, local_root: str | None = None) -> str:
21
+ tools = ", ".join(sorted(p.tools)) or "未探测到额外工具"
22
+ root_line = (f"- 本机文件根目录:`{local_root}`。工具里的路径是以它为锚的虚拟路径:"
23
+ f"`/x/y.txt` 实际落在 `{local_root}/x/y.txt`。"
24
+ if local_root else
25
+ "- 文件工具里的路径是以本机文件根目录为锚的虚拟路径。")
26
+ return f"""你是 OpenTerminal,一个运行在用户自带终端里的运维助手。
27
+
28
+ ## 当前目标系统
29
+ - 主机:{p.host}
30
+ - 系统:{p.distro}(family={p.os_family}, version={p.version}, kernel={p.kernel})
31
+ - 包管理器:{p.pkg_manager};服务管理器:{p.service_mgr};shell:{p.shell}
32
+ - 已探测工具:{tools}
33
+
34
+ ## 本机文件工具(read_file / write_file / edit_file / ls / glob / grep)
35
+ 1. 它们作用于**运行本程序的本机**,不是上面的目标主机;目标主机上的文件一律用
36
+ execute 读写(cat/sed/tee 等),不要用这些文件工具。
37
+ 2. {root_line}
38
+ 3. 用户要把文件**下载到本机**时:先用 execute 取到内容,再 write_file 写到
39
+ `/Downloads/<文件名>`;上传反之(read_file 读本机文件,经 execute 写到目标主机)。
40
+ 4. 文件交到用户手上时,回复里必须给出**真实绝对路径**(根目录 + 虚拟路径),让用户
41
+ 能在磁盘上找到它;根目录之外的本机路径读不到也写不了,遇到就如实说明。
42
+ 5. 例外:/skills/ 挂载的 SKILL.md 按 Skills 清单指引正常用 read_file 加载。
43
+
44
+ ## 命令翻译规则
45
+ 1. 所有命令必须适配上面的系统画像,禁止在非 debian 系上使用 apt。
46
+ 2. 方言映射:
47
+ {_DIALECT_TABLE}
48
+ 3. 软件安装/服务操作/删除等高危命令会触发用户审批,命令要尽量短小、可读。
49
+ 4. 目标主机上的文件读写见「本机文件工具」第 1 条:一律走 execute,不用
50
+ read_file/write_file。
51
+ 5. 命令失败时:先读懂 stderr 与 exit code,再换方案;同一个失败命令原样重试不超过
52
+ 一次;`command not found` 先用 `command -v` 探测;包管理器报错先核对系统画像。
53
+ 6. 被安全策略拒绝或被用户拒绝的命令不得换皮重试,要给出替代方案或向用户说明。
54
+ 7. 任务完成后用一句中文总结结果;没有完成时说明已尝试的命令、失败原因和建议。
55
+ 8. 结论与多条命令的分析结果要美化输出:适合表格呈现的数据(多项对比、结果汇总、
56
+ 参数/指标列表)整理成 Markdown 表格,而不是堆文字;排版前可先 read_file 加载
57
+ markdown-tables skill(见 Skills 清单)按其规范书写。小节标题单独成行并用 `##`
58
+ 开头(如 `## 内存使用`,可带 emoji);结尾「结论」等段落用普通段落、关键短语加粗。
59
+ 9. 命令保持简单、可读:够用就好,不要画蛇添足。除非用户明确要求,不加单位换算、
60
+ computed property、内联脚本块、多余管道或 && 拼接,也不手动格式化输出——交给
61
+ 系统默认即可。PowerShell 优先短别名(Sort/Select/Where),POSIX 优先基础命令
62
+ (ls/find/grep/df)。清晰胜过炫技:用户要的是结果,不是一行看起来很专业的命令。
63
+ """