mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
feat(toolcall): 按 schema 预校验参数,在分派前拦下(阶段 1c)
问题:`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 包)。
This commit is contained in:
243
internal/agent/core/argvalidate.go
Normal file
243
internal/agent/core/argvalidate.go
Normal file
@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
283
internal/agent/core/argvalidate_test.go
Normal file
283
internal/agent/core/argvalidate_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 阶段 1c:按 ToolDef.Parameters 预校验,在**分派之前**拦下坏参数。
|
||||
//
|
||||
// 现状:required 被 69 处声明却**无任何消费方**(grep 确认内核不读它),
|
||||
// 校验散落在每个工具内部手写成中文字符串("path is required"),
|
||||
// 要等工具真被调用才暴露。
|
||||
//
|
||||
// ⚠️ 本判据的第一要务是**不误伤**:模型写错参数时内核要拦,但模型**写对**
|
||||
// 的各种等价形态("true" 当 bool、20 当 int)必须照常放行——
|
||||
// 工具内部 getBool/getFloat 就是宽松解析的(见 utils.go 注释:
|
||||
// "实际调用里三种都出现过")。若校验比工具本身还严,会制造新失败。
|
||||
|
||||
// schemaWithRequired 造一个带 required 的参数 schema。
|
||||
func schemaWithRequired(required []string, props map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": required,
|
||||
}
|
||||
}
|
||||
|
||||
// ① 缺 required 字段必须在**进分派前**被拦下,且指名字段。
|
||||
func TestValidateArgsReportsMissingRequired(t *testing.T) {
|
||||
schema := schemaWithRequired([]string{"path", "content"},
|
||||
map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string"},
|
||||
"content": map[string]interface{}{"type": "string"},
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]interface{}
|
||||
wantField string
|
||||
}{
|
||||
{"两个都缺", map[string]interface{}{}, "path"},
|
||||
{"缺第二个", map[string]interface{}{"path": "/a"}, "content"},
|
||||
{"空字符串算缺失", map[string]interface{}{"path": "/a", "content": ""}, "content"},
|
||||
// ⚠️ 显式 null **不算**缺失(模型可能有意传 null,工具按零值处理)。
|
||||
// 真正的缺失是"键不存在",由 required 列表表达。
|
||||
{"只传 content,path 键不存在", map[string]interface{}{"content": "x"}, "path"},
|
||||
// args 整个为 nil + schema 有 required ⇒ 等价于全部必填缺失。
|
||||
// (我最初把这条误放进「应放行」组——自相矛盾:组名是 no-constraints,
|
||||
// 而 schema 明明带了 required。写完立刻发现并改正。)
|
||||
{"args 为 nil", nil, "path"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ve := validateToolArgs(c.args, schema)
|
||||
if ve == nil {
|
||||
t.Fatalf("缺 required 未被拦下: %#v", c.args)
|
||||
}
|
||||
if ve.Field != c.wantField {
|
||||
t.Errorf("Field = %q,期望 %q", ve.Field, c.wantField)
|
||||
}
|
||||
if ve.Reason != ErrReasonRequired {
|
||||
t.Errorf("Reason = %q,期望 %q", ve.Reason, ErrReasonRequired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ② ★ 误伤防线:模型**实际会写**的等价形态必须放行。
|
||||
// 这条是本阶段最大的回归风险——校验比工具更严就制造了新失败。
|
||||
func TestValidateArgsAcceptsLenientEquivalentForms(t *testing.T) {
|
||||
schema := schemaWithRequired([]string{"name", "count", "flag", "items", "opts"},
|
||||
map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"count": map[string]interface{}{"type": "integer"},
|
||||
"flag": map[string]interface{}{"type": "boolean"},
|
||||
"items": map[string]interface{}{"type": "array"},
|
||||
"opts": map[string]interface{}{"type": "object"},
|
||||
})
|
||||
// 这些形态在 getString/getBool/getFloat 宽松解析下**本来就可用**,
|
||||
// 若校验拒绝,就是内核自己制造失败。
|
||||
ok := []struct {
|
||||
name string
|
||||
args map[string]interface{}
|
||||
}{
|
||||
{"标准形态", map[string]interface{}{
|
||||
"name": "x", "count": 3, "flag": true,
|
||||
"items": []interface{}{"a"}, "opts": map[string]interface{}{"k": "v"},
|
||||
}},
|
||||
{"bool 传字符串 \"true\"", map[string]interface{}{
|
||||
"name": "x", "count": 3, "flag": "true",
|
||||
"items": []interface{}{"a"}, "opts": map[string]interface{}{},
|
||||
}},
|
||||
{"bool 传 \"1\"/\"0\"", map[string]interface{}{
|
||||
"name": "x", "count": 3, "flag": "0",
|
||||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||||
}},
|
||||
{"显式 null 视为已提供(不误伤)", map[string]interface{}{
|
||||
"name": "x", "count": 3, "flag": true,
|
||||
"items": []interface{}{}, "opts": nil,
|
||||
}},
|
||||
{"integer 传 float64(JSON 解码常态)", map[string]interface{}{
|
||||
"name": "x", "count": float64(3), "flag": false,
|
||||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||||
}},
|
||||
{"integer 传字符串 \"20\"(unitNumberRe 修过的形态)", map[string]interface{}{
|
||||
"name": "x", "count": "20", "flag": true,
|
||||
"items": []interface{}{}, "opts": map[string]interface{}{},
|
||||
}},
|
||||
{"字段名大小写/顺序不同", map[string]interface{}{
|
||||
"opts": map[string]interface{}{}, "items": []interface{}{},
|
||||
"flag": true, "count": 1, "name": "n",
|
||||
}},
|
||||
}
|
||||
for _, c := range ok {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if ve := validateToolArgs(c.args, schema); ve != nil {
|
||||
t.Errorf("**误伤**:本应放行却被拒: %v(args=%#v)", ve, c.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 类型完全对不上时给出 type 错误(而不是放行到工具内部再报 xxx is required)。
|
||||
func TestValidateArgsReportsTypeMismatch(t *testing.T) {
|
||||
schema := schemaWithRequired([]string{"name"},
|
||||
map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
})
|
||||
// 传结构体当字符串:任何解析都不可能得到该值
|
||||
ve := validateToolArgs(map[string]interface{}{
|
||||
"name": map[string]interface{}{"nested": true},
|
||||
}, schema)
|
||||
if ve == nil {
|
||||
t.Fatal("类型完全不符未被拦下")
|
||||
}
|
||||
if ve.Reason != ErrReasonType {
|
||||
t.Errorf("Reason = %q,期望 %q", ve.Reason, ErrReasonType)
|
||||
}
|
||||
if ve.Field != "name" {
|
||||
t.Errorf("Field = %q,期望 name", ve.Field)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 无 required / 无 schema 时一律放行(不因缺声明而阻塞任何工具)。
|
||||
func TestValidateArgsPassesWhenNoConstraints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]interface{}
|
||||
schema map[string]interface{}
|
||||
}{
|
||||
{"schema 为 nil", map[string]interface{}{"x": 1}, nil},
|
||||
{"schema 空表", map[string]interface{}{"x": 1}, map[string]interface{}{}},
|
||||
{"无 properties", map[string]interface{}{"x": 1}, map[string]interface{}{"type": "object"}},
|
||||
{"required 为空数组", map[string]interface{}{"x": 1}, schemaWithRequired([]string{}, map[string]interface{}{})},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if ve := validateToolArgs(c.args, c.schema); ve != nil {
|
||||
t.Errorf("无约束场景不应拦截,却得到: %v", ve)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 错误文案必须**可执行**:含字段名、原因、以及改法。
|
||||
// 这是「让模型看得懂真因」的核心——否则模型只会原样重试
|
||||
// (实测 cmd_run 失败率 34%~48% 的成因)。
|
||||
func TestValidateArgsErrorIsActionable(t *testing.T) {
|
||||
schema := schemaWithRequired([]string{"path"},
|
||||
map[string]interface{}{"path": map[string]interface{}{"type": "string", "description": "文件路径"}})
|
||||
ve := validateToolArgs(map[string]interface{}{}, schema)
|
||||
if ve == nil {
|
||||
t.Fatal("缺 required 未被拦下")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Fatal("Hint 为空——模型将不知道该改什么,只会原样重试")
|
||||
}
|
||||
text := renderToolError("files_write", ve)
|
||||
for _, want := range []string{"files_write", "path"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("错误文案缺少 %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:缺必填参数必须在**工具被调用之前**被拦下。
|
||||
//
|
||||
// 这是阶段 1c 的真正目标:此前 `required` 无消费方,坏参数要等工具真被
|
||||
// 调用才报 "path is required" 这类与真因无关的错,模型据此只会原样重试。
|
||||
// 本判据断言「设备真的没被调用」+「文案指名字段」两件事。
|
||||
func TestSchemaValidationInterceptsBeforeDispatch(t *testing.T) {
|
||||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||||
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_req", Arguments: map[string]interface{}{}}}},
|
||||
{Content: "final"},
|
||||
}}
|
||||
a, _ := newBatchAgent(t, sp)
|
||||
|
||||
var executed bool
|
||||
a.io.RegisterDevice(&schemaDevice{
|
||||
name: "schemadev", toolName: "tool_req",
|
||||
schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"path": map[string]interface{}{"type": "string", "description": "文件路径"}},
|
||||
"required": []interface{}{"path"},
|
||||
},
|
||||
onExec: func() { executed = true },
|
||||
})
|
||||
|
||||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||||
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
|
||||
}
|
||||
if executed {
|
||||
t.Error("缺必填参数仍进入了工具 —— 校验没有前置")
|
||||
}
|
||||
// 文案必须指名字段并给出改法,否则模型只会原样重试。
|
||||
var text string
|
||||
for _, m := range f.Msgs {
|
||||
if m.Role == "tool" {
|
||||
text = m.Content
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"tool_req", "path", "必填"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("错误文案缺少 %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:参数**齐备**时必须照常执行(校验不得阻塞正常路径)。
|
||||
func TestSchemaValidationPassesCompleteArgs(t *testing.T) {
|
||||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||||
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_req2",
|
||||
Arguments: map[string]interface{}{"path": "/a/b.txt"}}}},
|
||||
{Content: "final"},
|
||||
}}
|
||||
a, _ := newBatchAgent(t, sp)
|
||||
|
||||
var executed bool
|
||||
a.io.RegisterDevice(&schemaDevice{
|
||||
name: "schemadev2", toolName: "tool_req2",
|
||||
schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"path": map[string]interface{}{"type": "string"}},
|
||||
"required": []interface{}{"path"},
|
||||
},
|
||||
onExec: func() { executed = true },
|
||||
})
|
||||
|
||||
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
|
||||
t.Fatalf("runTaskSteps 未收敛: %v", out)
|
||||
}
|
||||
if !executed {
|
||||
t.Error("参数齐备却没执行 —— 校验误伤了正常路径")
|
||||
}
|
||||
}
|
||||
|
||||
// schemaDevice 带 schema 声明的测试设备,并记录是否真被执行。
|
||||
type schemaDevice struct {
|
||||
name string
|
||||
toolName string
|
||||
schema map[string]interface{}
|
||||
onExec func()
|
||||
}
|
||||
|
||||
func (d *schemaDevice) Name() string { return d.name }
|
||||
func (d *schemaDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *schemaDevice) Description() string { return "schema test device" }
|
||||
func (d *schemaDevice) Tools() []agentIO.ToolDef {
|
||||
return []agentIO.ToolDef{{Name: d.toolName, Parameters: d.schema}}
|
||||
}
|
||||
func (d *schemaDevice) Execute(string, map[string]interface{}) (interface{}, error) {
|
||||
if d.onExec != nil {
|
||||
d.onExec()
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
func (d *schemaDevice) Start() error { return nil }
|
||||
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{} }
|
||||
@ -77,6 +77,15 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
return toolOutcome{Text: msg}
|
||||
}
|
||||
|
||||
// 按 schema 预校验(阶段 1c)。放在分派**之前**:坏参数不该进到工具内部
|
||||
// 再报一句与真因无关的 "path is required"——模型据此只会原样重试。
|
||||
// ⚠️ 校验器刻意宽松(见 argvalidate.go):只拦真正无法解析的形态,
|
||||
// 对 "true"/20/"20s" 这类宽松等价形态一律放行,避免制造新失败。
|
||||
if ve := a.validateArgsAgainstSchema(tc); ve != nil {
|
||||
log.Printf("[agent] tool %s rejected by schema validation: field=%s reason=%s", tc.Name, ve.Field, ve.Reason)
|
||||
return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve}
|
||||
}
|
||||
|
||||
switch {
|
||||
case tc.Name == "persona_set":
|
||||
return toolOutcome{Text: a.executePersonaTool(tc)}
|
||||
|
||||
@ -624,6 +624,22 @@ func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
|
||||
return ch.Def, true
|
||||
}
|
||||
|
||||
// ToolDefOf 按工具名取其声明(含 Parameters schema)。
|
||||
// 用途:内核在执行前按 schema 预校验——没有它就只<E5B0B1><E58FAA><EFBFBD>校验到插件工具,
|
||||
// 而设备/通道工具(cmd_run、files_write 等)会完全绕过校验。
|
||||
func (m *IOManager) ToolDefOf(name string) (ToolDef, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, dev := range m.devices {
|
||||
for _, t := range dev.Tools() {
|
||||
if t.Name == name {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ToolDef{}, false
|
||||
}
|
||||
|
||||
func (m *IOManager) GetAllTools() []ToolDef {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
Reference in New Issue
Block a user