重构: 后端编排拆解与DRY债收敛及命名纠偏

阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
This commit is contained in:
lxy
2026-10-01 19:35:19 +08:00
parent 3861e13b32
commit 75c5e23523
40 changed files with 2988 additions and 1964 deletions
+310 -1
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{LazyLock, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -40,10 +41,226 @@ pub struct AiTool {
pub definition: ToolDefinition,
/// 风险级别
pub risk_level: RiskLevel,
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
pub meta: ToolMeta,
/// 执行处理器
pub handler: AiToolHandler,
}
// ============================================================
// 工具元数据(DRY 债 #5)
//
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
//
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
// - readonly:只读幂等工具(结果可缓存)
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
// - entity_action:数据变更联动刷新的 (entity, action)
// - approval_hint:审批卡文案模板 + 关键参数键
// - display_verb:轻量中文动作标签
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化)
// ============================================================
/// 取值风格(goal 维度):从参数值到展示文本的取法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalStyle {
/// 取路径末段文件名(与旧实现一致按 / 切分)
FileName,
/// 原样
Raw,
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
Truncate,
}
/// 目标描述元数据:infer_goal_from_tool_calls 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GoalMeta {
/// 动词前缀(如 "修改"/"查看"/"搜索")
pub verb: &'static str,
/// 参数键,按序取第一个存在的键
pub keys: &'static [&'static str],
/// 取值风格
pub style: GoalStyle,
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
pub fallback: Option<&'static str>,
}
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigValKind {
/// 字符串取值(as_str,缺失为空串)
Str,
/// Value::to_string 形态(数字 50 -> "50",缺失为空串)
Json,
}
/// 漂移检测签名元数据:tool_call_signature 消费
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SigMeta {
/// 有序参数组;每组内多键表示取值回退序(如 pattern 缺失落 query)
pub groups: &'static [(&'static [&'static str], SigValKind)],
}
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ToolMeta {
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
pub path_args: &'static [&'static str],
/// 只读幂等工具(结果可安全缓存)
pub readonly: bool,
/// 结果缓存 key 只取 path(目录级去重)
pub cache_path_key: bool,
/// 路径未授权时硬拒(不弹授权窗)
pub deny_unauthorized: bool,
/// 数据变更联动 (entity, action)
pub entity_action: Option<(&'static str, &'static str)>,
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
/// 轻量中文动作标签
pub display_verb: Option<&'static str>,
/// 目标描述元数据
pub goal: Option<GoalMeta>,
/// 漂移检测签名元数据
pub sig: Option<SigMeta>,
}
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// 按工具名查元数据(查全局表,未注册返回 None)
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
GLOBAL_TOOL_META
.read()
.expect("全局工具元数据表锁")
.get(name)
.copied()
}
/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空)
#[doc(hidden)]
pub fn meta_table_is_empty() -> bool {
GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty()
}
/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具)
pub fn path_arg_keys(name: &str) -> &'static [&'static str] {
tool_meta(name).map(|m| m.path_args).unwrap_or(&[])
}
/// 是否只读幂等工具(未注册返回 false,即不缓存)
pub fn is_readonly(name: &str) -> bool {
tool_meta(name).map(|m| m.readonly).unwrap_or(false)
}
/// 结果缓存 key 是否只取 path(未注册返回 false)
pub fn cache_path_key_only(name: &str) -> bool {
tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false)
}
/// 路径未授权是否硬拒(未注册返回 false,走弹窗)
pub fn denies_unauthorized(name: &str) -> bool {
tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false)
}
/// 数据变更联动 (entity, action)(未注册返回 None,不 emit)
pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> {
tool_meta(name).and_then(|m| m.entity_action)
}
/// 审批文案模板(未注册返回 None,调用方走 fallback)
pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> {
tool_meta(name).and_then(|m| m.approval_hint)
}
/// 轻量中文动作标签(未注册返回 None)
pub fn display_verb(name: &str) -> Option<&'static str> {
tool_meta(name).and_then(|m| m.display_verb)
}
/// 目标描述元数据(未注册返回 None)
pub fn goal_of(name: &str) -> Option<GoalMeta> {
tool_meta(name).and_then(|m| m.goal)
}
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
pub fn sig_of(name: &str) -> Option<SigMeta> {
tool_meta(name).and_then(|m| m.sig)
}
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
keys.iter()
.filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string()))
.collect()
}
/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。
pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
let value = args
.and_then(|a| goal.keys.iter().find_map(|k| a.get(k)))
.and_then(|v| v.as_str());
let rendered = value.map(|v| match goal.style {
GoalStyle::FileName => {
// 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐)
let f = v.rsplit('/').next().unwrap_or(v);
format!("{} {}", goal.verb, f)
}
GoalStyle::Raw => format!("{} {}", goal.verb, v),
GoalStyle::Truncate => {
let mut end = 25.min(v.len());
while end > 0 && !v.is_char_boundary(end) {
end -= 1;
}
format!("{} {}", goal.verb, &v[..end])
}
});
rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
}
/// 签名体:每组渲染 k=v,组间逗号连接。标签恒为首键(与旧实现 pair("pattern", ...) 一致,
/// 取值来自回退键时标签不变)。Str 组取组内首个字符串值键的值,全非字符串落末键;
/// Json 组按 Value::to_string(缺失为空串)。
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
let arg_str = |key: &str| -> String {
args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default()
};
sig.groups
.iter()
.map(|(keys, kind)| {
let value = match kind {
SigValKind::Json => args.get(keys[0]).map(|v| v.to_string()).unwrap_or_default(),
SigValKind::Str => {
let key = keys
.iter()
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
.unwrap_or(&keys[keys.len() - 1]);
arg_str(key)
}
};
format!("{}={}", keys[0], value)
})
.collect::<Vec<_>>()
.join(",")
}
/// 工具调用签名:有 SigMeta 元数据按组渲染,未注册回退「工具名 + 全参 JSON」保守兜底。
pub fn tool_signature(name: &str, args: &Value) -> String {
match sig_of(name) {
Some(sig) => format!("{}:{}", name, sig_body(sig, args)),
None => format!("{}:name={},args={}", name, name, args.to_string()),
}
}
/// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
pub type AiToolHandler =
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
@@ -65,7 +282,7 @@ impl AiToolRegistry {
}
}
/// 注册一个工具
/// 注册一个工具(无元数据,ToolMeta::default)
pub fn register(
&mut self,
name: impl Into<String>,
@@ -73,19 +290,47 @@ impl AiToolRegistry {
parameters: Value,
risk_level: RiskLevel,
handler: AiToolHandler,
) {
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
}
/// 注册一个工具并写入元数据。
///
/// 元数据在同一调用点写入两处,无双源漂移:
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
/// registry 句柄的自由函数按工具名查询)
pub fn register_meta(
&mut self,
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
risk_level: RiskLevel,
meta: ToolMeta,
handler: AiToolHandler,
) {
let name_str = name.into();
let definition = ToolDefinition::function(&name_str, description, parameters);
GLOBAL_TOOL_META
.write()
.expect("全局工具元数据表锁")
.insert(name_str.clone(), meta);
self.tools.insert(
name_str,
AiTool {
definition,
risk_level,
meta,
handler,
},
);
}
/// 查询工具元数据条目(持有 registry 实例的消费方用)
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
self.tools.get(name).map(|t| t.meta)
}
/// 获取所有工具定义(发送给 LLM 的 tools 参数)
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.definition.clone()).collect()
@@ -236,4 +481,68 @@ mod tests {
assert_eq!(required.len(), 1);
assert_eq!(required[0], "name");
}
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
#[test]
fn register_meta_stores_entry_and_global() {
let mut reg = AiToolRegistry::new();
let meta = ToolMeta {
path_args: &["from", "to"],
readonly: true,
entity_action: Some(("task", "update")),
..Default::default()
};
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
assert_eq!(reg.meta("meta_probe"), Some(meta));
assert_eq!(tool_meta("meta_probe"), Some(meta));
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
assert!(is_readonly("meta_probe"));
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
// 未注册名保守 default:无路径键/非只读/无联动
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
assert!(!is_readonly("never_registered"));
assert_eq!(entity_action("never_registered"), None);
// 老签名 register 等价 default 元数据
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
}
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
#[test]
fn goal_text_styles_and_fallback() {
use serde_json::json;
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
let long = json!({"command": "a".repeat(40)});
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
let multibyte = json!({"command": "构建数据库连接池"});
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
assert!(out.starts_with("执行 构建数据库"));
// 键缺失走兜底;无兜底返 None
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
assert_eq!(goal_text(file_name, None), None);
// 多键回退序:首键缺失取次键
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
}
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
#[test]
fn sig_body_groups_and_kinds() {
use serde_json::json;
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
// 未注册工具签名回退全参 JSON
let sig = tool_signature("custom_tool", &json!({"x": 1}));
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
}
}
+69 -4
View File
@@ -54,16 +54,47 @@
/// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
///
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口,
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
#[macro_export]
macro_rules! declare_tool {
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
meta: $meta:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register_meta(
$name, $desc, $schema, $risk, $meta,
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
(
$registry:expr,
$capture:ident : $cap_ty:ty,
@@ -74,8 +105,8 @@ macro_rules! declare_tool {
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register(
$name, $desc, $schema, $risk,
$registry.register_meta(
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
@@ -151,4 +182,38 @@ mod tests {
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true);
}
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
#[tokio::test]
async fn declare_tool_with_and_without_meta() {
use crate::ai_tools::ToolMeta;
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"meta_variant_tool",
"带元数据声明的工具",
RiskLevel::Low,
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"plain_variant_tool",
"不带元数据声明的工具",
RiskLevel::Low,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
assert_eq!(with.path_args, &["path"]);
assert!(with.readonly);
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
assert_eq!(plain, ToolMeta::default());
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
}
}