Files
HomeAgent/internal/agent/core/argvalidate.go
JianFeeeee 2232d5483c 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() 缺陷
2026-09-27 15:15:57 +08:00

334 lines
11 KiB
Go
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.

package core
import (
"fmt"
"strconv"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 阶段 1c:按 ToolDef.Parameters 预校验。
//
// 存在的理由:`required` 在仓内被声明了 69 处,却**没有任何消费方**
// (内核从不读它)。校验散落在每个工具内部手写成中文字符串
// ("path is required"),要等工具**真被调用**才暴露——而模型看到这类
// 与真因无关的报错只会原样重试(实测 cmd_run 失败率 34%~48% 的成因)。
//
// ⚠️ 第一要务是**不误伤**。工具内部的 getter 是宽松解析的
// (见 utils.go:`getBool` 注释写明"实际调用里 bool/string/float 三种都出现过",
//
// `getFloat` 接受 int 与 float64)。若校验比工具本身更严,
//
// 就是内核自己制造新的失败——那比不校验更糟。
// 因此本校验器**只拦真正无法解析的形态**,对宽松等价形态一律放行。
func validateToolArgs(args map[string]interface{}, schema map[string]interface{}) *sdk.ToolError {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]interface{})
required := schemaRequired(schema)
// ① required 检查:**键必须存在**,且值不得是空字符串。
// ⚠️ 判据是「键的存在性」而非「值是否为 nil」——显式 null 是模型
// 有意传的零值,不能当缺失;而键真的没传才是缺失。
for _, name := range required {
if name == "" {
continue
}
v, present := args[name]
if !present || isBlankArg(v) {
return newToolError(ErrReasonRequired, name,
fmt.Sprintf("缺少必填参数 %s", name),
requiredHint(name, props[name]))
}
}
// ② 类型检查:只对**已提供**的 required 字段做,且只拦真正对不上的。
for _, name := range required {
if name == "" {
continue
}
v, present := args[name]
if !present {
continue // 已在 ① 报过
}
if ve := checkArgType(v, propType(props[name])); ve != nil {
ve.Field = name
return ve
}
}
return nil
}
// schemaRequired 取出 required 列表,两种声明形态都认。
func schemaRequired(schema map[string]interface{}) []string {
switch v := schema["required"].(type) {
case []string:
return v
case []interface{}:
out := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
// propType 取某个属性的声明类型(没有 properties 时返回空串 = 不检查)。
func propType(prop interface{}) string {
m, ok := prop.(map[string]interface{})
if !ok {
return ""
}
t, _ := m["type"].(string)
return t
}
// isBlankArg 报告一个**已提供**的值是否为空(只有空字符串算)。
//
// nil 不在此判定:显式 null 是模型有意传的零值,工具侧按零值处理
// (getString→""、getBool→false、map 取键→nil),把它当缺失会误伤。
// 真正的「没传」由 required 检查里的**键存在性**判定,不靠值。
func isBlankArg(v interface{}) bool {
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
// requiredHint 为缺失的必填参数生成**可执行**的改法。
// 带上属性描述——那是作者写给模型的说明,比"参数不能为空"有用得多。
func requiredHint(name string, prop interface{}) string {
desc := ""
if m, ok := prop.(map[string]interface{}); ok {
desc, _ = m["description"].(string)
}
if desc != "" {
return fmt.Sprintf("请补上 %s 参数(%s)。该参数为必填,"+
"不要重复本次调用——先补参数再调用。", name, desc)
}
return fmt.Sprintf("请补上 %s 参数(必填)。该参数为必填,"+
"不要重复本次调用——先补参数再调用。", name)
}
// checkArgType 校验单个值的类型,**只拦真正无法解析的形态**。
//
// 放行清单(依据 utils.go 的宽松解析约定与实测的模型输出形态):
//
// · boolean:true/false、"true"/"false"/"1"/"0"/"yes"/"no"、0/1
// · integer:int、int64、float64(整数值)、"20" 这类数字字符串
// (unitNumberRe 修的正是这种)、含单位字符串("20s")
// · string:string;以及**结构体**(见下)
// · array:[]interface{}、[]string
// · object:map[string]interface{}
//
// ⚠️ string 放行结构体:模型常把复杂值塞进声明为 string 的参数
// (cmd 的 command 就常被写成含 JSON 的长文本)。拦它等于制造新失败;
// 真要用错时工具内部会自己报"格式不对",那已足够。
func checkArgType(v interface{}, want string) *sdk.ToolError {
if want == "" {
return nil
}
// 显式 null 一律放行:模型有意传 null 时,工具侧按零值处理,
// 拦它等于制造新失败(这正是本函数最该避免的)。
if v == nil {
return nil
}
switch want {
case "string":
// 宽松:只要不是显式的 bool/数字/数组/对象,基本都算字符串意图。
// 只在**明显是容器/标量错配**时报错。
switch v.(type) {
case []interface{}, []string, map[string]interface{}:
return newToolError(ErrReasonType, "", "", "")
}
return nil
case "integer":
switch x := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return nil
case float32, float64:
return nil // JSON 解码的常态
case string:
// 数字或带单位字符串都放行(getFloat 会解析)。
t := strings.TrimSpace(x)
if t == "" {
return nil
}
if _, err := strconv.ParseFloat(strings.TrimRight(t, "msdh"), 64); err == nil {
return nil
}
// 非数字字符串:可能是 "20s" 这类带单位的(unitNumberRe 的目标形态)
trimmed := strings.TrimRightFunc(t, func(r rune) bool {
return r == 's' || r == 'm' || r == 'h' || r == 'd'
})
if _, err := strconv.ParseFloat(trimmed, 64); err == nil {
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要整数,收到 %q", x),
"请改传数字(如 20 或 20.0),或把该参数改用 string 并带单位(如 \"20s\")。")
case bool:
return newToolError(ErrReasonType, "",
"参数需要整数,收到布尔值", "请改传数字。")
default:
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要整数,收到 %T", v), "请改传数字。")
}
case "number":
switch x := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
float32, float64:
return nil
case string:
if _, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil {
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要数字,收到 %q", x),
"请改传数字,或把该参数改用 string。")
}
return nil
case "boolean":
// getBool 的宽松形态全部放行(true/false/"1"/"0"/"yes"/... 与 0/1)。
return nil
case "array":
switch v.(type) {
case []interface{}, []string:
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要数组,收到 %T", v), "请改传数组,如 [\"a\", \"b\"]。")
case "object":
switch v.(type) {
case map[string]interface{}:
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要对象,收到 %T", v), "请改传对象,如 {\"k\": \"v\"}。")
}
return nil
}
// validateArgsAgainstSchema 按工具声明的 schema 校验参数。
//
// 两条来源都要查:插件工具走 StageHost,设备/通道工具走 IOManager
// (cmd_run / files_write 都属后者——只查前者会让它们完全绕过校验)。
// **查不到 schema 就放行**:没有声明不等于参数非法。
func (a *Agent) validateArgsAgainstSchema(tc agentAPI.ToolCall) *sdk.ToolError {
if a == nil {
return nil
}
if a.stageHost != nil {
if def := a.stageHost.ToolDef(tc.Name); def != nil {
return validateToolArgs(tc.Arguments, def.Parameters)
}
}
if a.io != nil {
if def, ok := a.io.ToolDefOf(tc.Name); ok {
return validateToolArgs(tc.Arguments, def.Parameters)
}
}
return nil
}
// toolParallelSafe 报告工具是否可被**并发执行**。
//
// 两条来源都要查(与 validateArgsAgainstSchema 同理):插件工具走 StageHost,
// 设备/通道工具走 IOManager。查不到 ⇒ 保守返回 false(不可并发)。
//
// 为什么保守:新语义下并发会改变工具的行为前提,让存量插件意外并发
// 比慢一点危险得多——判不出就该按串行走。
//
// ★ Serial 优先于 ParallelSafe:工具显式声明"必须串行"时,
// 即使同时写了 ParallelSafe:true 也不并发(判据 TestSerialOverridesParallelSafe)。
// 没有这条,"显式声明必须串行"就只是一个没被读取的死字段 ——
// 工具作者写了 Serial:true 以为能保护自己,实际毫无作用。
func (a *Agent) toolParallelSafe(name string) bool {
if a == nil {
return false
}
if a.stageHost != nil {
if def := a.stageHost.ToolDef(name); def != nil {
return def.ParallelSafe && !def.Serial
}
}
if a.io != nil {
if def, ok := a.io.ToolDefOf(name); ok {
return def.ParallelSafe && !def.Serial
}
}
// ③ 内置工具:以裸 schema map 下发,不在 stageHost/io 任何一侧 ⇒
// 前两条都查不到。声明随工具定义一起给(toolDef 的 toolParallel 选项,
// 与 SDK 的 NoMemory 同构),所以这里从定义里读,不是查硬编码名单。
return a.builtinToolParallelSafe(name)
}
// builtinToolParallelSafe 从**内置工具定义**里读并发声明。
//
// 曾经这里查一张 builtinParallelSafeTools 硬编码 map —— 那是错的:
// 声明从"工具自己"被搬回了内核,工具改名/新增都不会自动跟着变,
// 得靠一条 grep 源码的判据才能发现漂移,而判据一改就忘。
func (a *Agent) builtinToolParallelSafe(name string) bool {
if a == nil {
return false
}
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if n, _ := fn["name"].(string); n != name {
continue
}
safe, _ := fn["parallel_safe"].(bool)
return safe
}
return false
}
// batchRunnable 并发执行本批工具。
//
// 何时并发(三条全满足):
// 1. 批内 >1 个工具
// 2. **全部**工具都声明 ParallelSafe —— 一个不声明就整批降级,
// 不做"部分并发":部分并发收益不抵其不可预测性
// 3. 不含需要保序的同通道输出发送(同 output_send__<通道> 多次发送)
//
// 保序为什么不用 ParallelSafe 表达:那属于**批内**约束而非工具属性,
// 且同一工具在不同批里的通道可能不同(output_send__qq 两次、一次 qq 一次 cli)。
func (f *TaskFrame) batchRunnable(a *Agent) bool {
if f == nil || len(f.PendingTools) <= 1 {
return false
}
chans := map[string]bool{}
for _, tc := range f.PendingTools {
if !a.toolParallelSafe(tc.Name) {
return false
}
// 同通道多次发送必须保序 —— 用户可见消息顺序敏感
if isOutputDeliveryTool(tc.Name) {
ch := strings.TrimPrefix(tc.Name, "output_send__")
if chans[ch] {
return false
}
chans[ch] = true
}
}
return true
}