Files
DevFlow/src-tauri/src/commands/ai/prompt.rs
T
lxy 31510d3555 新增: 工具结果落盘瘦身与空轮自愈
- 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔)
- read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回
- 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退)
- 零 token 收敛信号透传(非截断语义分离)
- 上下文清单描述按字符截断, 防清单体量无限膨胀
2026-10-11 07:33:27 +08:00

1121 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 系统提示词构建 + 活跃提供商获取
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::commands::err_str;
use crate::state::{AppState, SettingsNorm};
/// 获取当前活跃提供商配置。
///
/// **停用须生效(2026-10-10 用户报「模型配置停用无效」根因)**:原实现显式活跃 provider 直接返回、
/// 默认 provider 只按 `is_default` 挑,**全程不看 `enabled`** → 用户在设置里停用某 provider 后
/// 会话仍照用(停用形同虚设)。现两条路径都收敛到「启用集合」:显式活跃项被停用 → 改按启用集合解析
/// (留 warn 便于定位);无任何启用 provider → 明确报错而非静默使用被停用项。
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
let session = state.ai_session.lock().await;
if let Some(ref pid) = session.active_provider_id {
if let Some(provider) = state.ai_providers.get_by_id(pid).await.map_err(err_str)? {
if provider.enabled {
return Ok(provider);
}
tracing::warn!(
provider_id = %pid,
provider_name = %provider.name,
"[ai] 活跃 provider 已被停用,改按启用集合解析默认 provider"
);
} else {
tracing::warn!(provider_id = %pid, "[ai] 活跃 provider 不存在,改按启用集合解析");
}
}
drop(session);
resolve_enabled_provider(state).await
}
/// 在**启用**的 provider 中解析:`is_default` 优先,否则取列表首个(与旧行为同序,仅多一道 enabled 过滤)。
async fn resolve_enabled_provider(state: &AppState) -> Result<AiProviderRecord, String> {
let providers = state
.ai_providers
.list_all()
.await
.map_err(err_str)?;
let enabled: Vec<AiProviderRecord> = providers.into_iter().filter(|p| p.enabled).collect();
enabled
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| enabled.into_iter().next())
.ok_or_else(|| "未启用任何 AI 提供商,请在设置中启用一个(或新增)".to_string())
}
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
/// L1 环境感知(治 kms seq26-52 引号地狱):教 AI 避命令行 -c 内联,复杂脚本写文件执行。
///
/// - 日期:LLM 无法自行获取,不注入则写文件日期全靠猜
/// - OS + shell:平台对应 shell(Windows=PowerShell / Linux·macOS=bash),AI 写命令需对齐
/// - 执行姿势:复杂脚本(多行/含引号/$变量)写 .ps1/.py 文件用 `powershell -File`/`python` 执行,
/// 避免命令行 -c 内联(cmd/PowerShell 引号转义易出错,kms 实证 20+ 次失败)
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
///
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
///
/// pub(crate):子代理 system prompt 同源注入(见 sub_agent::sub_agent_system_prompt),
/// 子代理同样需要日期(不能自行获取)与 shell 执行姿势。
pub(crate) fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
format!("当前日期: {today} | {}", env_profile_stable_body())
}
/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。
///
/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息),
/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。
fn env_profile_stable_body() -> String {
let (os, shell, interp) = env_os_shell_interp();
format!(
"运行环境: {os} | shell: {shell}\n\
执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n"
)
}
/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。
fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) {
if cfg!(target_os = "windows") {
("Windows", "PowerShell", "python(无 python3)、node")
} else if cfg!(target_os = "macos") {
("macOS", "bash/zsh", "python3、node")
} else if cfg!(target_os = "linux") {
("Linux", "bash", "python3、node")
} else {
("Unknown", "sh", "python3、node")
}
}
/// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。
fn env_profile_date_volatile() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
format!("今天是 {today}")
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)。
///
/// prefix 内的 read_symbol 优先与重复调用检测两条经 [`read_symbol_guidance`] /
/// [`duplicate_call_guidance`] 注入(与子代理工具纪律段同源,零复制)。
fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
match lang {
"en" => (
format!(
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
- Create/query projects, tasks, and ideas\n\
- Run workflows\n\
- Read file contents, list directories, create/write files\n\
- {}\n\
- Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\
## Guidelines\n\
- Briefly explain your intent before executing actions\n\
- Ask for clarification if the user's intent is unclear\n\
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
- {}\n\
- {}\n\
- {}\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
- Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
verification_fast_check_guidance(lang),
),
"\n## Current Projects\n",
"\n## Current Tasks\n",
),
_ => (
format!(
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
你可以通过工具调用执行以下操作:\n\
- 创建/查询项目、任务、灵感\n\
- 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\
- {}\n\
- 获取文件元信息(大小、行数、二进制检测)\n\
- 追加写入文件、按名称模式搜索文件\n\n\
## 行为准则\n\
- 执行操作前简要说明你的意图\n\
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- {}\n\
- {}\n\
- {}\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
verification_fast_check_guidance(lang),
),
"\n## 当前项目\n",
"\n## 当前任务\n",
),
}
}
/// 文档探索策略段(中/英):引导 AI 收到 URL 时走「嗅探→识别→提取→验证→应用」,
/// 而非盲目对 URL 发 http_request 瞎试。
///
/// 机制治症状:实测 AI 收到产品/文档 URL 时,会直接 http_request 打原始 URL(HTML 噪声爆 token、
/// 也调不出真正 API 端点),应先用 fetch_url 转成可读 markdown 理解文档,提取 base_url/鉴权/
/// 端点路径后,再用 http_request 打正确端点验证。
///
/// - fetch_url = 读文档(URL→markdown,去 HTML 噪声);http_request = 调 API(POST/鉴权/原始响应)
/// - 不瞎试 URL:先读文档提取端点,再验证;杜绝未读文档即对原始 URL 盲发请求
///
/// 末尾追加 obscura 安装引导子段:fetch_url render=true 未装 obscura 时,结果含
/// `render_mode=static_fallback` + `hint` 字段(引导链接 github.com/h4ckf0r0day/obscura)。
/// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。
///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
///
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
fn doc_exploration_strategy_section(lang: &str) -> String {
format!(
"{}{}",
doc_strategy_core_section(lang),
doc_strategy_obscura_install_section(lang)
)
}
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
fn read_symbol_guidance(lang: &str) -> &'static str {
match lang {
"en" => "To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found",
_ => "理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file",
}
}
/// 重复调用检测指引(中/英,不含行首 "- ")。同源拼接见 read_symbol_guidance。
fn duplicate_call_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing",
_ => "**重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行",
}
}
/// 文件读取纪律指引(中/英,不含行首 "- ")。狗粮实证(2026-10-04):67+ 次 run_command
/// PowerShell/python 切片绕道读文件,而 read_file 本身支持 offset/limit 分页+search 参数。
/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。
fn file_reading_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**File reading discipline**: read_file supports offset/limit pagination and the search parameter (search matching lines inside a file) — use them to read large files in pages or locate content directly. Never use run_command (PowerShell/python scripts) to slice or read files — it is slower, bypasses path authorization and binary detection, and pollutes context; run_command is for running tests/builds/binaries only",
_ => "**文件读取纪律**:read_file 自带 offset/limit 分页与 search 参数(文件内容行级搜索)——大文件用它分页续读、定位内容直接传 search。严禁用 run_command(PowerShell/python 脚本)切片或读取文件——更慢、绕过路径授权与二进制检测、且污染上下文;run_command 仅用于跑测试/构建/二进制等命令执行场景",
}
}
/// 验证快检纪律(中/英,不含行首 "- ")。狗粮实证(2026-10-04):验证阶段多次全量
/// cargo check(单次数十秒)烧轮。开发中途单文件/单包改动用窄域快检,全量验证只留
/// 给阶段收官一次。
fn verification_fast_check_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**Fast-check discipline**: during development, verify with narrow-scope checks first — `cargo check -p <package>` for one crate, read back the edited snippet for a single-file change. Full-workspace `cargo check`/`cargo test` is for final acceptance ONLY (run once per milestone, not per patch); repeated full checks burn minutes per round",
_ => "**验证快检纪律**:开发中途优先窄域验证——单包改动用 `cargo check -p <包名>`,单文件改动回读编辑片段即可。全量 cargo check/cargo test 只留给阶段收官(每个里程碑一次,不要每个小 patch 跑一遍);反复全量验证每轮烧数十秒",
}
}
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n\
- {}\n\
- {}\n\
{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
verification_fast_check_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
}
/// 文档探索策略核心段(中/英):URL 三工具分工 + 嗅探→识别→提取→验证→应用流程 +
/// 禁臆造 URL + render=true 失败重试铁律。父会话与子代理同源拼接块。
fn doc_strategy_core_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Documentation Exploration Strategy\n\
### Pick the right URL tool first (do NOT mix them up)\n\
Three URL tools exist — using the wrong one fails and wastes round-trips:\n\
- `fetch_url`: read a URL and convert to markdown (**HTML docs only**; refuses binary/images/files). For web pages, API docs, blog posts.\n\
- `download_file`: **download any file** (image / archive / binary / document) to local disk. Streaming, binary-safe, never goes into the prompt.\n\
- `http_request`: call an API (POST / auth / raw JSON response). For REST endpoints, webhooks, structured payloads.\n\
Hard rule by URL kind:\n\
1. **Image / archive / binary file URL** (e.g. `.png` `.jpg` `.zip` `.pdf` `.exe` `.tar.gz`) → use `download_file`. Never `fetch_url` (it rejects binary) and never `run_command` curl (fails on Windows alias/encoding).\n\
2. **Web page / doc URL** (HTML the browser would render) → use `fetch_url`.\n\
3. **API endpoint URL** (returns JSON / needs auth / POST body) → use `http_request`.\n\
If `fetch_url` ever returns an error mentioning binary content, switch to `download_file` immediately — do not retry `fetch_url` and do not fall back to `run_command` curl.\n\
\n\
When the user gives you a URL (product page / API docs / SDK guide), follow this flow instead of blindly firing http_request at the raw URL:\n\
1. **Sniff**: call `fetch_url` to turn the URL into clean markdown (strips HTML noise) and read what the page actually says.\n\
2. **Identify intent**: is it an API doc (endpoints/auth), a product capability page, a config spec, or a code sample?\n\
3. **Extract keys**: pull out base_url, provider_type, auth scheme, model list, endpoint paths — write them down explicitly before calling anything.\n\
4. **Verify**: only now use `http_request` against the correct endpoint you extracted (e.g. `GET {base_url}/v1/models` with the auth header) to confirm it works.\n\
5. **Apply**: configure the Provider / write code / transcribe into a file via `write_file`.\n\
Rule: `fetch_url` is for *reading* a URL (docs/web/API description, markdown output); `http_request` is for *calling* an API (POST/auth/raw response). Never guess an endpoint — read the doc first, extract, then verify.\n\
\n\
### Extract real links — NEVER fabricate URLs\n\
After a `fetch_url`/`http_request` returns content, do **not** invent URLs from memory or convention (no guessing `#hash` anchors, no speculating `/path` routes, no trying 'what the URL probably is'). Guessed URLs are ~99% dead (404/403) and waste round-trips. Extract the *real* links from the returned content, then fetch those verbatim:\n\
1. **Pull real links out of the response**: markdown `[text](url)`, HTML `<a href>` / `<script src>` / `<link href>`, JSON `url` / `endpoint` / `href` fields — these are the only URLs you may fire. A URL that does not appear in the returned content does not exist for you.\n\
2. **Fetch extracted URLs verbatim**: use the extracted URL exactly as-is — keep query params, do not strip the path, do not add a `#hash`, do not 'correct' the route. The link in the content is the link you fetch.\n\
3. **No explicit link → do not blind-fire**: never assemble a URL from hunches like 'it should be /api/xxx' or 'probably #download'. Instead, find an entry in the returned content (navigation / index page / official doc root), or ask the user for the exact link.\n\
4. **API endpoints are the same**: read base_url / path precisely from the doc or response fields (`base_url` / `endpoint` / `api_base`). Do not speculate routes like `/v1/chat/completions` — different vendors use different paths, and a guess is almost always wrong.\n\
Rule: send a request only when there is a sourced link; never fabricate a URL out of thin air.\n\
\n\
### render=true failure-retry rule (stop the AI hammering one URL)\n\
`fetch_url render=true` (JS render) failure signals: `html=0` (empty content), timeout, or an `obscura_failed` response. Observed: the AI repeatedly re-fires render=true at the same URL (each call times out at 30s; 8 retries = 4 minutes wasted, 8% success rate) — it **refuses to give up or switch tactics**. Mandatory rules:\n\
1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\
2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\
3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\
\n",
_ => "\n\
## 文档探索策略\n\
### 先选对 URL 工具(三者别混用)\n\
有三个 URL 工具——用错就失败、纯浪费往返:\n\
- `fetch_url`:读 URL 转 markdown(**仅 HTML 文档**;二进制/图片/文件一律拒)。用于网页、API 文档、博客。\n\
- `download_file`:**下载任意文件**(图片/压缩包/二进制/文档)到本地。流式写,二进制安全,内容不进 prompt。\n\
- `http_request`:调 API(POST/鉴权/原始 JSON 响应)。用于 REST 端点、webhook、结构化请求体。\n\
按 URL 类型选工具的铁律:\n\
1. **图片/压缩包/二进制文件 URL**(如 `.png` `.jpg` `.zip` `.pdf` `.exe` `.tar.gz`)→ 用 `download_file`。绝不用 `fetch_url`(它拒二进制),也不用 `run_command` curl(Windows 上 alias/编码会失败)。\n\
2. **网页/文档 URL**(浏览器会渲染的 HTML)→ 用 `fetch_url`。\n\
3. **API 端点 URL**(返回 JSON/需鉴权/POST body)→ 用 `http_request`。\n\
若 `fetch_url` 返回的错误信息提到二进制内容,立即改用 `download_file`——不要重试 `fetch_url`,也不要回退到 `run_command` curl。\n\
\n\
收到用户给的 URL(产品页/API 文档/SDK 说明)时,按以下流程走,不要盲目对原始 URL 发 http_request 瞎试:\n\
1. **嗅探**:先调 `fetch_url` 把 URL 转成干净的 markdown(剥离 HTML 噪声),读明白页面到底写了什么。\n\
2. **识别意图**:是 API 文档(端点/鉴权)、产品能力页、配置规格,还是代码示例?\n\
3. **提取关键**:把 base_url、provider_type、鉴权方式、模型清单、端点路径显式列出来,再决定调什么。\n\
4. **验证**:此时才用 `http_request` 打你提取出的正确端点(如带鉴权头 `GET {base_url}/v1/models`)确认可用。\n\
5. **应用**:配置 Provider / 编码 / 转写到文件(write_file)。\n\
铁律:`fetch_url` 用于**读** URL(文档/网页/API 说明,输出 markdown);`http_request` 用于**调** API(POST/鉴权/原始响应)。严禁猜端点——先读文档提取,再验证。\n\
\n\
### 精确提取链接,严禁臆造 URL\n\
`fetch_url`/`http_request` 拿到返回内容后,**不要**凭印象/惯例臆造 URL(不猜 `#hash` 锚点、不推测 `/path` 路由、不试「这 URL 大概是啥」)。猜出来的 URL 99% 是死链(404/403),纯浪费往返。要从返回内容里提取**真实链接**,再原样 fetch:\n\
1. **从返回内容提取真实链接**:markdown `[文本](url)`、HTML `<a href>`/`<script src>`/`<link href>`、JSON 的 `url`/`endpoint`/`href` 字段——这些才是你能发的 URL。返回内容里没出现的 URL,对你而言不存在。\n\
2. **原样 fetch 提取到的 URL**:提取到的 URL 一字不改地用——保留查询参数,不删 path、不补 `#hash`、不「修正」路由。内容里的链接是啥,你就 fetch 啥。\n\
3. **没有明确链接 → 不盲试**:绝不凭「应该是 /api/xxx」「大概是 #download」拼 URL。回内容里找入口(导航/索引页/官方文档根),或直接问用户要准确链接。\n\
4. **API 端点同理**:base_url/path 从文档/响应字段精确读出(`base_url`/`endpoint`/`api_base`)。不推测 `/v1/chat/completions` 之类路由——不同厂商路径不同,猜必错。\n\
铁律:有据可查才发请求,无据绝不臆造 URL。\n\
\n\
### render=true 失败重试铁律(治 AI 死磕同一 URL)\n\
`fetch_url render=true`(JS 渲染)失败信号:`html=0`(空内容)、超时、或返回 `obscura_failed`。实测 AI 会反复对同一 URL 重发 render=true(单次 30s 超时,连发 8 次=浪费 4 分钟,成功率 8%),**不认输、不换方案**。强制规则:\n\
1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
\n",
}
}
/// obscura 安装引导段(中/英,父侧专用):涉用户交互(征得同意帮装)与 run_command 高风险
/// 工具,子代理无用户交互面,不进子代理工具纪律段([`sub_agent_tool_discipline_section`])。
/// 与核心段拼接 = 原 [`doc_exploration_strategy_section`] 完整段(逐字节等价)。
fn doc_strategy_obscura_install_section(lang: &str) -> &'static str {
match lang {
// 注:核心段末尾已保留空行(…tactic.\n\n / …换方案。\n\n),本段自标题起拼,
// 拼接 = 原完整段逐字节等价,不再补前导换行。
"en" => "### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "### JS 渲染文档与 obscura 安装引导\n\
`fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\
2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\
3. **同意即装**:用户同意后,你自己用 `run_command` 执行安装并流式输出(让用户看到进度),按用户环境选合适路径:\n\
- 有 Rust 工具链:`cargo install obscura`\n\
- 否则:从 github.com/h4ckf0r0day/obscura releases 下载对应平台的预编译二进制,放到 PATH 上\n\
(`run_command` 是高风险、需用户批准的工具——上面用户的「同意」即是批准;执行前仍要把确切命令告知用户。)\n\
4. **重试**:装成功后告诉用户「obscura 就绪」,并用 `render=true` 重试 `fetch_url` 拿到渲染完整的 markdown。\n\
这是纯对话式引导——没有专门的 UI 按钮,靠你识别 hint 字段、在对话中驱动安装。\n",
}
}
/// 查 DevFlow 自身配置引导段(中/英):教 AI 查 provider/model/agent 设置时用 get_app_config
/// 工具,不要 run_command 执行 PowerShell 内联 node/python 脚本查 db。
///
/// 治症状:实测 AI 想看「当前 provider/model/agent 配置」时,因没原生查配置工具,只能
/// run_command 执行 PowerShell 内联 node 脚本查 db,命令行引号三重嵌套(PowerShell 单引号
/// 包 node -e 内含 JS 模板字符串/双引号/db 路径)必失败,复盘实证反复踩坑。get_app_config
/// 工具一调即得(只读 + api_key 脱敏),根治绕行。
///
/// 与 doc_exploration_strategy_section 同款「机制治症状」风格:正向指令(用 X)+ 禁令
/// (不要 Y)+ 原因(引号嵌套易失败),而非空泛说教。
fn app_config_query_guidance_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Querying DevFlow's Own Configuration\n\
When you need to inspect DevFlow's own current configuration (which AI provider/model is active, agent iteration/retry limits, concurrency, approval timeout, whether the API key is set), use the `get_app_config` tool — it returns the live config as JSON in one call (read-only, api_key masked). Do **NOT** use `run_command` to run an inline PowerShell node/python script against the database: triple-nested quotes (PowerShell single-quote wrapping node `-e` containing JS template strings / double quotes / db paths) consistently fail. `get_app_config` is the correct path; `run_command` for config inspection is a known failure mode.\n",
_ => "\n\
## 查询 DevFlow 自身配置\n\
需要查看 DevFlow 自身当前配置(当前用哪个 AI provider/model、agent 迭代/重试上限、并发数、审批超时、api_key 是否配置)时,用 `get_app_config` 工具——一次调用即返当前生效配置 JSON(只读,api_key 已脱敏)。**严禁**用 `run_command` 执行 PowerShell 内联 node/python 脚本查数据库:命令行引号三重嵌套(PowerShell 单引号包 node `-e` 内含 JS 模板字符串/双引号/db 路径)必失败。`get_app_config` 是查配置的正解;`run_command` 查配置是已知的反复失败模式。\n",
}
}
// ── BE3system prompt 会话内缓存 ──
//
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
//
// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃
// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接
// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。
//
// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串,
// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增
// AppState 字段,进程内单例,懒初始化。
//
// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」,
// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。
static SYSTEM_PROMPT_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
let conn = db.conn().clone();
let result = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard.prepare(sql).ok()?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let updated: String = row.get(1)?;
Ok(format!("{}@{};", id, updated))
})
.ok()?;
let mut out = String::new();
for r in rows {
out.push_str(&r.ok()?);
}
Some(out)
})
.await;
result.ok().flatten()
}
/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。
///
/// 指纹覆盖全部依赖输入:
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
lang: &str,
excl_proj: &[String],
excl_task: &[String],
) -> Option<String> {
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let custom = state
.settings
.get("custom_prompt")
.await
.ok()
.flatten()
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
)
.await?;
let task_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL",
)
.await?;
let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}");
for id in excl_proj {
key.push_str("|p:");
key.push_str(id);
}
for id in excl_task {
key.push_str("|t:");
key.push_str(id);
}
Some(key)
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
/// 供 LLM 知道当前存在哪些实体并拿到各自 id(清单含 id,引用时用 id 而非名称)。
///
/// **被@实体的精准投影**(含 path 脱敏、剥 frontmatter 的 skill 正文)由 chat.rs 的
/// augmentation 层(`ResolverRegistry::resolve_all` + `build_augmentation_segment`)处理,
/// 非 build_system_prompt 职责 —— 两套并行:全局清单(全貌)+ augmentation(用户本次引用)。
///
/// 注入克制:仅各取最近 20 条,防 context 膨胀。
///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
///
/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走
/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
#[allow(dead_code)]
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
build_system_prompt_with_excluded(state, lang, &[], &[]).await
}
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
///
/// 与 [`build_system_prompt`] 的差异(机制而非说教):
/// 1. **去重**:被 @ 的 project/task 已由 augmentation 层精准投影(含 path/正文),清单中
/// 再现会造成同一实体两次入 prompt(实测 build f64dee94 会话 list_projects 8 次 /
/// list_tasks 20 次)。此处把 `excluded_project_ids`/`excluded_task_ids` 命中的条目
/// 从全局清单循环中跳过,被@实体只保留 augmentation 一次。
/// 2. **注明语**:清单末尾按语言追加一行,告知 LLM 「项目已全部列出无需再调 list_projects」/
/// 「任务仅最近 20 条,全量查询请用 list_tasks」,从机制层降低工具与清单重复调用
/// (而非在行为准则里反复说教)。
///
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
/// - 注入克制策略不变:仍各 take(20)。
pub(crate) async fn build_system_prompt_with_excluded(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
) -> String {
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
if let Some(key) = &fp_key {
let cache = system_prompt_cache();
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
return cached.clone();
}
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line();
prompt.push_str(&prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(&doc_exploration_strategy_section(lang));
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang));
// 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。
prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await);
prompt.push_str(&build_custom_section(state).await);
// BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。
if let Some(key) = fp_key {
let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SYSTEM_PROMPT_CACHE_MAX {
cache.clear();
}
cache.insert(key, prompt.clone());
}
prompt
}
/// 项目/任务清单单条描述截断上限(字符数,中文一字一符)。
///
/// 防清单体量随数据无限增长:描述可粘贴长文档,20 项目 + 20 任务 × 长描述可达数万字符,
/// 截断后接「…」。技能清单同类截断见 agentic/mod.rs `SKILL_DESC_MAX_CHARS`(80)。
const CONTEXT_DESC_MAX_CHARS: usize = 120;
/// 清单描述截断(纯函数):超 `max` 字符按 Unicode 标量截断并追加「…」,否则原样返回。
///
/// 按字符而非字节计数(中文描述一字一符,按字节会截断 UTF-8 序列产生乱码)。
fn truncate_desc(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段
/// [`build_system_prompt_parts`] 共用)。
///
/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。
/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。
async fn build_context_lists(
state: &AppState,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
lang: &str,
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() {
out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
continue;
}
out.push_str(&format!(
"- {} (id: {}) ({}): {}\n",
p.name,
p.id,
p.status.as_str(),
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS)
));
if let Some(ref dir) = p.path {
out.push_str(&format!(" 目录: {}\n", dir));
}
}
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
out.push_str(&projects_listed_note(lang, 20));
}
}
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() {
out.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) {
if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue;
}
out.push_str(&format!(
"- {} (id: {}) ({}): {}\n",
tk.title,
tk.id,
tk.status.as_str(),
truncate_desc(&tk.description, CONTEXT_DESC_MAX_CHARS)
));
}
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
out.push_str(&tasks_listed_note(lang));
}
}
out
}
/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String {
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。
let clean = state
.settings
.get_string_norm("custom_prompt")
.await
.unwrap_or_default();
if !clean.trim().is_empty() {
return format!("\n## 自定义指令\n{}\n", clean.trim());
}
String::new()
}
/// 当前 provider 是否支持 prompt caching 拆分(同一 settings 开关下的两类受益方):
/// - anthropic / anthropic_compat:显式缓存(system+消息级 cache_control 断点);
/// - openai_compat:自动前缀缓存(DeepSeek/GLM/Qwen 等,无需 cache_control 标记,
/// 只要求前缀字节稳定,拆分后易变段挪到消息流末尾即命中)。
pub(crate) fn cache_split_provider_type(provider_type: &str) -> bool {
matches!(
provider_type,
"anthropic" | "anthropic_compat" | "openai_compat"
)
}
/// 解析 prompt caching 拆分开关原始值为布尔(纯函数,默认开)。
///
/// 值语义(见 [`cache_enabled`]):缺省/空(从未设置过)→ 默认开;
/// 显式 "false"/"0"(设置页开关关)→ 关 = 用户回退;
/// "true"/"1" 沿用旧识别;其余杂值保守按关(与历史默认关行为一致)。
/// 前端落库为 JSON 布尔字符串("true"/"false",无引号),直接等值比较即可。
fn parse_cache_flag(v: &str) -> bool {
v.is_empty() || v == "true" || v == "1"
}
/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认开)
/// 并同步 df-ai 进程级显式缓存开关。
///
/// 值语义见 [`parse_cache_flag`]:默认开——拆分后易变段(日期/清单)移出稳定前缀,
/// anthropic 系显式缓存与 openai_compat 自动前缀缓存命中率提升;调用方另按
/// provider_type gate(`cache_split_provider_type`),不支持的端点仍走逐字等价旧路径,
/// 默认开对它们零影响。回退:设置页「AI Prompt 缓存」开关关(落库 "false")即整链回旧行为。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-anthropic-cache")
.await
.ok()
.flatten()
.unwrap_or_default();
let enabled = parse_cache_flag(&v);
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
enabled
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`]
/// 逐字等价(零行为变更,兼容非官方网关)。
/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段
/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
cache_enabled: bool,
) -> (String, String) {
if !cache_enabled {
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
return (full, String::new());
}
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(&prefix);
stable.push_str(&doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
let volatile = if lists.is_empty() {
env_profile_date_volatile()
} else {
format!("{}\n\n{}", env_profile_date_volatile(), lists)
};
(stable, volatile)
}
/// 项目清单尾部注明语(中/英)。
///
/// 机制层降重复:直接告知「已全部列出」,LLM 知道再调 list_projects 是冗余。
/// `shown` 取实际注入条数上限 20(避免传运态 list_active 长度,只描述清单本身)。
fn projects_listed_note(lang: &str, shown: usize) -> String {
match lang {
"en" => format!(
"(Listed {} active projects above; the active set is shown, no need to call list_projects again. Each entry carries its id — reference projects by id, not by name.)\n",
shown
),
_ => format!(
"(以上为活跃项目清单,无需重复调用 list_projects;每条已含 id,引用项目时用 id 而非名称)\n"
),
}
}
/// 任务清单尾部注明语(中/英)。
///
/// 机制层降重复:明确「仅最近 20 条 + 全量查询走 list_tasks」,避免 LLM 误以为清单即全量
/// 而反复调 list_tasks 校验(实测单会话 20 次)。
fn tasks_listed_note(lang: &str) -> String {
match lang {
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool. Each entry carries its id — reference tasks by id, not by title/name.)\n".to_string(),
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具;每条已含 id,引用任务时用 id 而非名称)\n".to_string(),
}
}
/// 上下文压缩 system prompt 模板(F-15 §4.2,纯函数无 IO)。
///
/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留
/// 旧消息语义。调用方把返回的 system prompt 放在消息列表首位,后接被压缩的
/// active 消息原文(user/assistant/tool)喂给 `provider.complete()`:
///
/// ```ignore
/// let mut msgs = vec![ChatMessage::system(compress_prompt(lang))];
/// msgs.extend(active_msgs);
/// let resp = provider.complete(CompletionRequest { messages: msgs, .. }).await?;
/// ```
///
/// `lang` 与系统提示词保持一致取值集合("en" 英文,其余一律中文)。
//
// 调用方:compress.rs:85(compress_via_llm 内构造 system 消息)。
pub(crate) fn compress_prompt(lang: &str) -> &'static str {
match lang {
"en" => "You are a conversation summarizer. Compress the following conversation \
into a structured summary that preserves the essential context for \
continuing the work. Output exactly four sections:\n\
\n\
## Intent\n\
The user's core intent and requirements (what they are trying to achieve).\n\
\n\
## Decisions\n\
Key decisions already made (which approach/option was chosen, why alternatives \
were rejected).\n\
\n\
## Files\n\
Files modified/created/inspected, with a one-line description of the change in each.\n\
\n\
## Constraints\n\
Important constraints, preferences, or open issues to respect going forward.\n\
\n\
Rules:\n\
- Be concise; prefer bullet points.\n\
- Drop small talk and transient pleasantries; keep only technically load-bearing facts.\n\
- Preserve file paths, identifiers, and error messages verbatim.\n\
- Must preserve core topic words, entity names, and technical terms the user \
repeatedly mentions; they are anchors for continuing the conversation and losing \
them breaks the context thread.\n\
- MUST preserve rejection/failed facts verbatim: failed tool calls with error \
messages, proposals or approaches the user rejected (with stated reasons), and \
forbidden operations — losing them invites blindly retrying failed paths.\n\
- Do NOT invent facts not present in the conversation.\n\
- Output the four sections only, no preamble or extra commentary.",
_ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\
严格按以下四段输出:\n\
\n\
## 意图\n\
用户的核心意图和需求(用户想要达成什么)。\n\
\n\
## 决策\n\
已做出的关键决策(选了哪种方案/选项,为何排除其他方案)。\n\
\n\
## 文件\n\
已修改/创建/查看过的文件,每条一行简述其变更内容。\n\
\n\
## 约束\n\
重要约束、偏好或待解决的问题,后续需要继续遵守或留意。\n\
\n\
要求:\n\
- 简洁,优先用要点。\n\
- 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\
- 文件路径、标识符、错误信息等照原样保留。\n\
- 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\
- 必须原样保留「被拒/失败」事实:失败的工具调用及其错误信息、用户明确否决的方案及理由、明令禁止的操作——丢失会导致压缩后盲目重走失败路径。\n\
- 不要编造对话中没有的事实。\n\
- 只输出上述四段内容,不要前言、解释或额外评论。",
}
}
#[cfg(test)]
mod tests {
use super::*;
// 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段
#[test]
fn system_prompt_zh_has_focus_section() {
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(
prefix.contains("聚焦准则"),
"中文 system prompt 应含聚焦准则段,实际: {}",
prefix
);
// 独立段标题,非稀释在行为准则里
assert!(prefix.contains("## 聚焦准则"));
// 核心条款抽样
assert!(prefix.contains("始终围绕用户当前请求的核心目标"));
assert!(prefix.contains("也绝不谎报成功"));
}
// 改进1 系统提示聚焦段:英文版含「Focus」独立段
#[test]
fn system_prompt_en_has_focus_section() {
let (prefix, _, _) = system_prompt_parts("en");
assert!(
prefix.contains("Focus"),
"英文 system prompt 应含 Focus 段,实际: {}",
prefix
);
assert!(prefix.contains("## Focus"));
assert!(prefix.contains("never falsely report success"));
assert!(prefix.contains("core goal of the user's current request"));
}
// 兜底:lang 未匹配回落中文,聚焦段仍存在
#[test]
fn system_prompt_unknown_lang_falls_back_zh_with_focus() {
let (prefix, _, _) = system_prompt_parts("fr");
assert!(prefix.contains("聚焦准则"));
}
// 文档探索策略段(中/英)存在且含关键引导
#[test]
fn doc_exploration_strategy_section_present_zh_en() {
let zh = doc_exploration_strategy_section("zh-CN");
assert!(zh.contains("## 文档探索策略"));
assert!(zh.contains("fetch_url"));
assert!(zh.contains("http_request"));
assert!(zh.contains("嗅探"));
assert!(zh.contains("识别意图"));
assert!(zh.contains("提取关键"));
assert!(zh.contains("验证"));
assert!(zh.contains("应用"));
let en = doc_exploration_strategy_section("en");
assert!(en.contains("## Documentation Exploration Strategy"));
assert!(en.contains("Sniff"));
assert!(en.contains("Identify intent"));
assert!(en.contains("Extract keys"));
assert!(en.contains("Verify"));
assert!(en.contains("Apply"));
// 工具职责区分子段(中/英)——图片/二进制走 download_file 非 fetch_url(治会话 01f05167 卡死)
assert!(zh.contains("先选对 URL 工具"));
assert!(zh.contains("download_file"));
assert!(zh.contains("仅 HTML 文档"));
assert!(zh.contains("下载任意文件"));
assert!(zh.contains("图片/压缩包/二进制文件 URL"));
assert!(zh.contains("立即改用 `download_file`"));
assert!(en.contains("Pick the right URL tool first"));
assert!(en.contains("download_file"));
assert!(en.contains("HTML docs only"));
assert!(en.contains("download any file"));
assert!(en.contains("Image / archive / binary file URL"));
assert!(en.contains("switch to `download_file` immediately"));
// obscura 安装引导子段(中/英)——看 hint 字段自主提示 + 帮装 + 重试
// 核心:hint 字段是安装信号(非只看 render_mode),同意即用 run_command 装,装完重试 render=true。
assert!(zh.contains("obscura"));
assert!(zh.contains("hint"));
assert!(zh.contains("static_fallback"));
assert!(zh.contains("render_mode"));
assert!(zh.contains("run_command"));
assert!(zh.contains("cargo install obscura"));
assert!(zh.contains("github.com/h4ckf0r0day/obscura"));
assert!(zh.contains("render=true"));
assert!(en.contains("obscura"));
assert!(en.contains("hint"));
assert!(en.contains("static_fallback"));
assert!(en.contains("render_mode"));
assert!(en.contains("run_command"));
assert!(en.contains("cargo install obscura"));
assert!(en.contains("github.com/h4ckf0r0day/obscura"));
assert!(en.contains("render=true"));
// render=true 失败重试铁律(中/英)——失败 2 次换方案 + 成功即收手 + 不重复 render
assert!(zh.contains("render=true 失败重试铁律"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("第 1 次成功即收手"));
assert!(zh.contains("同 URL 不重复 render=true"));
assert!(zh.contains("html=0"));
assert!(zh.contains("obscura_failed"));
assert!(en.contains("render=true failure-retry rule"));
assert!(en.contains("Switch after 2 failures"));
assert!(en.contains("Stop after the first success"));
assert!(en.contains("Never re-render the same URL"));
assert!(en.contains("html=0"));
assert!(en.contains("obscura_failed"));
// 精确提取链接子段(中/英)——治 AI 凭印象臆造 URL(猜 #hash/推测路由)
// 核心:从返回内容提取真实链接原样 fetch,无据不臆造,有据才发请求。
assert!(zh.contains("精确提取链接,严禁臆造 URL"));
assert!(zh.contains("从返回内容提取真实链接"));
assert!(zh.contains("原样 fetch 提取到的 URL"));
assert!(zh.contains("没有明确链接 → 不盲试"));
assert!(zh.contains("API 端点同理"));
assert!(zh.contains("有据可查才发请求,无据绝不臆造 URL"));
assert!(en.contains("Extract real links"));
assert!(en.contains("NEVER fabricate URLs"));
assert!(en.contains("Pull real links out of the response"));
assert!(en.contains("Fetch extracted URLs verbatim"));
assert!(en.contains("No explicit link"));
assert!(en.contains("API endpoints are the same"));
assert!(en.contains("never fabricate a URL"));
}
#[test]
fn compress_prompt_unaffected_by_focus_addition() {
// 压缩 prompt 是独立函数,聚焦段改动不应波及
let zh = compress_prompt("zh-CN");
assert!(zh.contains("意图"));
let en = compress_prompt("en");
assert!(en.contains("Intent"));
}
// 改进3 A: 压缩 prompt 主题保留段(锚点词防上下文断裂)
#[test]
fn compress_prompt_zh_keeps_topic_anchor_clause() {
let zh = compress_prompt("zh-CN");
assert!(
zh.contains("主题词"),
"中文 compress_prompt 应含主题词/锚点保留要求,实际: {}",
zh
);
assert!(zh.contains("锚点"));
assert!(zh.contains("上下文断裂"));
}
#[test]
fn compress_prompt_en_keeps_topic_anchor_clause() {
let en = compress_prompt("en");
assert!(
en.contains("anchors"),
"英文 compress_prompt 应含 anchors 保留要求,实际: {}",
en
);
assert!(en.contains("topic words"));
assert!(en.contains("breaks the context"));
}
// B-2(2026-10-05 拍板): 压缩 prompt 被拒/失败事实保留条款(防压缩后盲目重走失败路径)
#[test]
fn compress_prompt_keeps_rejection_facts_clause() {
let zh = compress_prompt("zh-CN");
assert!(
zh.contains("被拒/失败"),
"中文 compress_prompt 应含被拒/失败事实保留条款,实际: {}",
zh
);
assert!(zh.contains("盲目重走失败路径"));
let en = compress_prompt("en");
assert!(
en.contains("preserve rejection/failed facts"),
"英文 compress_prompt 应含 rejection/failed facts 保留条款,实际: {}",
en
);
assert!(en.contains("blindly retrying failed paths"));
}
// 查 DevFlow 自身配置引导段(中/英)存在且含关键引导
#[test]
fn app_config_query_guidance_section_present_zh_en() {
let zh = app_config_query_guidance_section("zh-CN");
assert!(zh.contains("查询 DevFlow 自身配置"));
assert!(zh.contains("get_app_config"));
assert!(zh.contains("run_command"));
assert!(zh.contains("引号三重嵌套"));
assert!(zh.contains("严禁"));
let en = app_config_query_guidance_section("en");
assert!(en.contains("Querying DevFlow's Own Configuration"));
assert!(en.contains("get_app_config"));
assert!(en.contains("run_command"));
assert!(en.contains("triple-nested quotes"));
assert!(en.contains("Do **NOT**"));
}
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
#[test]
fn sub_agent_tool_discipline_section_shares_parent_text() {
let zh = sub_agent_tool_discipline_section("zh-CN");
assert!(zh.contains("## 工具纪律"));
assert!(zh.contains(read_symbol_guidance("zh-CN")), "read_symbol 指引须与父会话同源");
assert!(zh.contains(duplicate_call_guidance("zh-CN")), "重复调用检测须与父会话同源");
assert!(zh.contains("## 文档探索策略"), "URL 纪律核心段须注入");
assert!(zh.contains("严禁臆造 URL"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("get_app_config"));
assert!(
!zh.contains("要我帮你装吗"),
"obscura 安装引导为用户交互条款,子代理不得注入: {}",
zh
);
assert!(!zh.contains("## 聚焦准则"), "会话面准则不进子代理");
// 父会话 prefix 与共享函数同字同源(DRY 锁:改共享函数父侧同步生效)
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(prefix.contains(read_symbol_guidance("zh-CN")));
assert!(prefix.contains(duplicate_call_guidance("zh-CN")));
// 父侧完整文档探索段 = 核心 + obscura 引导(拆分不丢段)
let parent_doc = doc_exploration_strategy_section("zh-CN");
assert!(parent_doc.contains("失败 2 次必换方案"));
assert!(parent_doc.contains("要我帮你装吗"));
}
// 项目/任务清单注明语(中/英)存在
#[test]
fn listed_notes_present_zh_en() {
let zh_proj = projects_listed_note("zh-CN", 20);
assert!(zh_proj.contains("无需重复调用 list_projects"));
let en_proj = projects_listed_note("en", 20);
assert!(en_proj.contains("no need to call list_projects"));
let zh_task = tasks_listed_note("zh-CN");
assert!(zh_task.contains("仅显示最近 20 条任务"));
assert!(zh_task.contains("list_tasks"));
let en_task = tasks_listed_note("en");
assert!(en_task.contains("20 most recent tasks"));
assert!(en_task.contains("list_tasks"));
}
// 缓存拆分开关默认值断言(看板 939 ⑧:默认关→默认开)
#[test]
fn parse_cache_flag_defaults_on_and_honors_explicit_off() {
// 缺省/空(从未设置过)→ 默认开
assert!(parse_cache_flag(""), "缺省(未设置)应默认开");
assert!(parse_cache_flag("true"));
assert!(parse_cache_flag("1"));
// 显式回退:设置页开关关落库 JSON 布尔 "false"
assert!(!parse_cache_flag("false"), "显式 false 应为关(用户回退通道)");
assert!(!parse_cache_flag("0"));
// 杂值保守按关(与历史默认关行为一致,不因脏数据意外开启)
assert!(!parse_cache_flag("garbage"));
}
// 清单描述截断(纯函数):短原样 / 恰好上限不截 / 超长截断加 … / 中文按字符非字节
#[test]
fn truncate_desc_short_exact_and_cjk() {
// 短描述与空串原样
assert_eq!(truncate_desc("hello", 120), "hello");
assert_eq!(truncate_desc("", 120), "");
// 恰好等于上限不截断(无省略号)
let exact = "字".repeat(120);
assert_eq!(truncate_desc(&exact, 120), exact);
// 超长:截到 max 字符 + …
assert_eq!(truncate_desc("abcdef", 5), "abcde…");
// 中文一字一符(按 Unicode 标量计数,非 UTF-8 字节,不产生乱码截断)
let long = "汉".repeat(130);
let got = truncate_desc(&long, 5);
assert_eq!(got, "汉汉汉汉汉…");
// 常量绑定:实际清单截断上限 120(防止常量被误改无人知)
assert_eq!(CONTEXT_DESC_MAX_CHARS, 120);
let got120 = truncate_desc(&long, CONTEXT_DESC_MAX_CHARS);
assert_eq!(got120.chars().count(), 121);
assert!(got120.ends_with('…'));
}
}