diff --git a/internal/agent/core/argvalidate.go b/internal/agent/core/argvalidate.go new file mode 100644 index 0000000..7925c84 --- /dev/null +++ b/internal/agent/core/argvalidate.go @@ -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 +} diff --git a/internal/agent/core/argvalidate_test.go b/internal/agent/core/argvalidate_test.go new file mode 100644 index 0000000..fdf8d4e --- /dev/null +++ b/internal/agent/core/argvalidate_test.go @@ -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{} } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 725ee99..d83b967 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -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)} diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index b25be70..8e6212e 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -624,6 +624,22 @@ func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) { return ch.Def, true } +// ToolDefOf 按工具名取其声明(含 Parameters schema)。 +// 用途:内核在执行前按 schema 预校验——没有它就只���校验到插件工具, +// 而设备/通道工具(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()