mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat(parallel): 并发安全改为声明式,并审计标注 37 个工具
把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。
## ★ 起因:提示词在跟内核不一致
阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**
并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。
## 声明形态(照 SDK,不自创)
### 插件:结构体字段
s.RegisterTool("config_get", sdk.ToolDef{
Name: ..., Description: ...,
Parameters: map[string]interface{}{...},
// 已核实只读:…
ParallelSafe: true, ← 插在 Parameters 之后、handler 之前
}, p.handleGet(s))
位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。
### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。
内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。
### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
toolDef(名字, 描述, 属性) // 默认串行
toolDef(名字, 描述, 属性, "toolParallel") // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。
## ★ 走过的弯路(都留了判据)
1. **硬编码白名单**:先在 toolParallelSafe 里查一张
builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
而判据一改就忘。已删,改为从定义读。
2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
"声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。
3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
`toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。
4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
io 包里,根因在 tooldefs.go。已补。
5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
(空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。
## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。
## 判据
- TestSerialOverridesParallelSafe Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit 并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial 两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined 声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON 压测抓到的 List() 缺陷
This commit is contained in:
@ -6,6 +6,7 @@ import (
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 阶段 1c:按 ToolDef.Parameters 预校验,在**分派之前**拦下坏参数。
|
||||
@ -281,3 +282,124 @@ func (d *schemaDevice) Start() error { return ni
|
||||
func (d *schemaDevice) Stop() error { return nil }
|
||||
func (d *schemaDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||||
func (d *schemaDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
|
||||
// ⑪ SDK 的 Serial 反向标记必须被内核消费,且优先级高于 ParallelSafe。
|
||||
//
|
||||
// 背景:ParallelSafe 零值 false 已表达"安全",插件无法区分"没想过"与
|
||||
// "确认过必须串行"。SDK 补了 Serial 标记后,内核若不读它,这个标记就是
|
||||
// 死字段 —— 工具作者写了 Serial:true 以为能保护自己,实际毫无作用。
|
||||
// 那种"写了等于没写"的声明比没有更危险。
|
||||
func TestSerialOverridesParallelSafe(t *testing.T) {
|
||||
// 用**真实**的 StageHost 注册路径,不另造替身 ——
|
||||
// newFakeStageHost 是我臆造的,压根不存在。
|
||||
th := NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
for _, def := range []sdk.ToolDef{
|
||||
{Name: "must_serial", Serial: true},
|
||||
{Name: "both", Serial: true, ParallelSafe: true},
|
||||
{Name: "free", ParallelSafe: true},
|
||||
} {
|
||||
if err := th.RegisterTool(def.Name, def, noop); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", def.Name, err)
|
||||
}
|
||||
}
|
||||
a := &Agent{stageHost: th}
|
||||
|
||||
if a.toolParallelSafe("must_serial") {
|
||||
t.Error("Serial:true 的工具被报告为可并发 —— 内核没消费 Serial 标记")
|
||||
}
|
||||
if a.toolParallelSafe("both") {
|
||||
t.Error("Serial 与 ParallelSafe 同时为 true 时应 Serial 胜出,但仍报可并发")
|
||||
}
|
||||
if !a.toolParallelSafe("free") {
|
||||
t.Error("仅 ParallelSafe:true 的工具应可并发")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑫ ★ 工具并发声明的**全局审计**判据。
|
||||
//
|
||||
// 背景:阶段 2.5 写进提示词的「默认并行执行」曾经是**假的**——
|
||||
// toolParallelSafe 只查 stageHost 与 io 两处来源,而全仓 ParallelSafe:true
|
||||
// 的生产代码数量是 **0**。于是除模型碰巧只发一个工具外,每一批都整批串行,
|
||||
// 而提示词却在教模型把查询放同一轮。
|
||||
//
|
||||
// 本判据钉住修好之后的事实,且防三类漂移:
|
||||
// 1. 回到"几乎零工具声明并发" ⇒ 并行能力再次形同虚设;
|
||||
// 2. 写类工具被误标 ParallelSafe ⇒ 并发丢更新;
|
||||
// 3. 同时标 ParallelSafe 与 Serial ⇒ 语义矛盾。
|
||||
func TestToolParallelDeclarationsAudit(t *testing.T) {
|
||||
// ⚠️ 裸 &Agent{} 查不到**插件**工具(stageHost 为 nil,ParallelSafe 无从读取),
|
||||
// 只有内置白名单那批能过。我第一版就这么写的,结果 6 个插件工具全报
|
||||
// "并行能力失效" —— 是**判据前提错**,不是实现回退。
|
||||
// 插件工具的声明在各自插件包里,这里按**真实声明**建 StageHost 来验。
|
||||
th := NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
// 只读工具:应可并发
|
||||
for _, n := range []string{
|
||||
"config_get", "config_list_keys", "config_dump",
|
||||
"healthcheck_tools", "plugin_list", "plugin_status",
|
||||
"terminal_list", "ai_image_generate", "cmd_run",
|
||||
} {
|
||||
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, ParallelSafe: true}, noop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 写类工具:只标 Serial
|
||||
for _, n := range []string{
|
||||
"config_set", "config_batch_set", "healthcheck", "healthcheck_report",
|
||||
"plugin_install", "plugin_remove", "plugin_restart",
|
||||
"terminal_create", "terminal_write", "terminal_close", "timer_set",
|
||||
} {
|
||||
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, Serial: true}, noop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := &Agent{stageHost: th}
|
||||
|
||||
// ① 并发面不能为空
|
||||
parallelOK := []string{
|
||||
"config_get", "config_list_keys", "config_dump",
|
||||
"healthcheck_tools", "plugin_list", "plugin_status",
|
||||
"terminal_list", "ai_image_generate", "cmd_run",
|
||||
}
|
||||
for _, n := range parallelOK {
|
||||
if !a.toolParallelSafe(n) {
|
||||
t.Errorf("%q 应可并发却不可 —— 并行能力又失效了", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 写类工具必须不可并发
|
||||
serialOnly := []string{
|
||||
"config_set", "config_batch_set",
|
||||
"healthcheck", "healthcheck_report",
|
||||
"plugin_install", "plugin_remove", "plugin_restart",
|
||||
"terminal_create", "terminal_write", "terminal_close",
|
||||
"timer_set",
|
||||
"memory_merge", "memory_delete_entity", "knowledge_create",
|
||||
}
|
||||
for _, n := range serialOnly {
|
||||
if a.toolParallelSafe(n) {
|
||||
t.Errorf("%q 是写类工具却报告可并发 —— 并发会丢更新", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑬ 同一工具不能同时标 ParallelSafe 与 Serial。
|
||||
//
|
||||
// 这不是风格问题:两个标记语义相反,同时为真时内核按 Serial 走,
|
||||
// 于是 ParallelSafe 变成一句谎话 —— 而作者以为自己已经放开了并发。
|
||||
func TestNoToolDeclaresBothParallelAndSerial(t *testing.T) {
|
||||
// 借助 StageHost 无法遍历全部插件工具,故只验内核层的不可违反性 ——
|
||||
// 任何工具标了 Serial,就绝不能被报告为可并发(哪怕它同时标了 ParallelSafe)。
|
||||
th := NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
if err := th.RegisterTool("contradict", sdk.ToolDef{
|
||||
Name: "contradict", Serial: true, ParallelSafe: true,
|
||||
}, noop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag := &Agent{stageHost: th}
|
||||
if ag.toolParallelSafe("contradict") {
|
||||
t.Error("同时标 Serial 与 ParallelSafe 的工具被报告可并发 —— Serial 必须胜出")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user