diff --git a/internal/agent/tools/param_validate.go b/internal/agent/tools/param_validate.go index 0c97f4864b7..4c1a96ba979 100644 --- a/internal/agent/tools/param_validate.go +++ b/internal/agent/tools/param_validate.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "unicode/utf8" ) // ValidationError describes a specific parameter validation failure. @@ -132,14 +133,16 @@ func validateProperty(name string, val any, prop map[string]any) []ValidationErr // String length bounds if targetType == "string" { if s, ok := val.(string); ok { - if minLen, ok := getFloat(prop, "minLength"); ok && float64(len(s)) < minLen { + // JSON Schema measures string length in Unicode code points, not bytes. + length := float64(utf8.RuneCountInString(s)) + if minLen, ok := getFloat(prop, "minLength"); ok && length < minLen { errs = append(errs, ValidationError{ Param: name, Message: fmt.Sprintf("parameter '%s' must have at least %d characters", name, int(minLen)), }) } - if maxLen, ok := getFloat(prop, "maxLength"); ok && float64(len(s)) > maxLen { + if maxLen, ok := getFloat(prop, "maxLength"); ok && length > maxLen { errs = append(errs, ValidationError{ Param: name, Message: fmt.Sprintf("parameter '%s' must have at most %d characters", diff --git a/internal/agent/tools/param_validate_unicode_test.go b/internal/agent/tools/param_validate_unicode_test.go new file mode 100644 index 00000000000..cd10164c3c3 --- /dev/null +++ b/internal/agent/tools/param_validate_unicode_test.go @@ -0,0 +1,41 @@ +package tools + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestValidateParamsUnicodeLength(t *testing.T) { + cases := []struct { + name, value, constraint, wantError string + }{ + {"Chinese maximum", "你好", `"maxLength":2`, ""}, + {"emoji maximum", "😀", `"maxLength":1`, ""}, + {"accent maximum", "é", `"maxLength":1`, ""}, + {"Chinese minimum", "你", `"minLength":2`, "at least 2 characters"}, + {"emoji minimum", "😀", `"minLength":2`, "at least 2 characters"}, + {"mixed exact length", "A你😀", `"minLength":3,"maxLength":3`, ""}, + {"combining code points", "e\u0301", `"minLength":2,"maxLength":2`, ""}, + {"too many code points", "你好啊", `"maxLength":2`, "at most 2 characters"}, + {"empty minimum", "", `"minLength":1`, "at least 1 characters"}, + {"ASCII exact length", "abc", `"minLength":3,"maxLength":3`, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args, err := json.Marshal(map[string]string{"text": tc.value}) + if err != nil { + t.Fatal(err) + } + schema := json.RawMessage(`{"type":"object","properties":{"text":{"type":"string",` + tc.constraint + `}}}`) + errs := ValidateParams(args, schema) + if tc.wantError == "" { + if len(errs) != 0 { + t.Fatalf("valid code-point length rejected: %v", errs) + } + } else if len(errs) != 1 || errs[0].Param != "text" || !strings.Contains(errs[0].Message, tc.wantError) { + t.Fatalf("expected %q for text, got %v", tc.wantError, errs) + } + }) + } +}