mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
问题:`required` 在仓内被声明 69 处,却**无任何消费方**(内核从不读)。
校验散落在每个工具内部手写成中文字符串("path is required"),
要等工具真被调用才暴露——而模型看到这类与真因无关的报错只会原样重试
(实测 cmd_run 失败率 34%~48% 的成因)。
改动:
· core/argvalidate.go: validateToolArgs(纯函数)+ validateArgsAgainstSchema。
★ 校验器刻意**宽松**:只拦真正无法解析的形态,对模型实际会写的等价形态
一律放行。依据是工具内部 getter 的既有约定(utils.go 注释:
"实际调用里 bool/string/float 三种都出现过";unitNumberRe 修的正是
`"20s"` 少引号那类)。**校验比工具更严就是在制造新失败**。
· required 判据是**键存在性** + 非空字符串;显式 null 视为已提供
(模型可能有意传 null,工具按零值处理,判成缺失即误伤)
· boolean 全放行(getBool 的 true/"1"/"0"/"yes"/0/1 全都合法)
· integer 接受 int/float64/"20"/"20s";string 接受含 JSON 的长文本
· 无 schema / 无 required / 查不到 schema ⇒ 一律放行
· core/toolcall.go: 在 __arg_error 短路**之后**、分派**之前**接入。
· io/channel.go: 新增 IOManager.ToolDefOf——没有它就只校验到插件工具,
而 cmd_run / files_write 这类**设备/通道工具会完全绕过校验**。
判据(argvalidate_test.go,7 组):
· 缺 required 被拦下并指名字段
· ★ 误伤防线:bool 传 "true"/"0"、integer 传 float64/"20"、
显式 null、字段顺序不同 —— 全部必须放行
· 类型确实不符报 type 错误
· 无约束场景一律放行(含 args 为 nil + schema 带 required ⇒ 应拦,
这条我最初**误放进放行组**,写完立刻发现改正)
· 错误文案含字段名/必填/改法(否则模型只会原样重试)
· 端到端:缺参时**设备真的没被调用** + 文案指名字段
· 端到端反向:参数齐备照常执行(校验不得阻塞正常路径)
变异验证(两轮):
· 关闭分派前校验 ⇒ 端到端判据 FAIL「仍进入了工具」
· 把 boolean 校验改严格 ⇒ 宽松防线 FAIL 两个子用例(误伤 "true"/"0")
过程中三次自伤:臆造 sdkToolError 别名;number 分支写了没有绑定的 x(v);
把"显式 null"先当成缺失、过度修正后又漏掉"键不存在"的判定——
最终改为「键存在性 + 非空串」双条件,null 与缺失各归其位。
回归:internal/agent/... internal/sdk/... internal/plugin/...
internal/plugins/... 全绿(18 包)。
244 lines
8.1 KiB
Go
244 lines
8.1 KiB
Go
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
|
||
}
|