新增: EnvSnapshot 环境感知注入 system_prompt
- EnvSnapshot 结构: OS/shell/工具版本/编码/路径分隔符 - OnceLock 全局缓存,启动时 spawn_blocking 探测一次 - to_prompt() 生成环境摘要注入 agentic loop system_prompt - 探测: Windows 注册表/macOS sw_vers/Linux os-release + chcp 编码 - 工具版本: python/node/rust/go/docker/git 逐个探测,失败不阻塞 - 8 个单测覆盖版本号解析 + 平台 fallback
This commit is contained in:
432
crates/df-execute/src/env_snapshot.rs
Normal file
432
crates/df-execute/src/env_snapshot.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
||||
.await
|
||||
.unwrap_or_else(|_| EnvSnapshot::fallback());
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
|
||||
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "pwsh".to_string();
|
||||
}
|
||||
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "powershell".to_string();
|
||||
}
|
||||
return "cmd".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
|
||||
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
|
||||
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
||||
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
Reference in New Issue
Block a user