diff --git a/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs b/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs index cb4f8e107..137d446c1 100644 --- a/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs @@ -25,7 +25,9 @@ public class MetaFieldResolutionTests [Theory] [InlineData("_rationale", "_rationale")] [InlineData("Rationale", "_rationale")] + [InlineData("r_rationale", "_rationale")] [InlineData("rationale", "_rationale")] + [InlineData("rationalle", "_rationale")] [InlineData("_timeout_seconds", "_timeout_seconds")] [InlineData("TimeoutSeconds", "_timeout_seconds")] [InlineData("timeout_seconds", "_timeout_seconds")] diff --git a/src/Netclaw.Tools.Abstractions/FuzzyMatcher.cs b/src/Netclaw.Tools.Abstractions/FuzzyMatcher.cs new file mode 100644 index 000000000..414014152 --- /dev/null +++ b/src/Netclaw.Tools.Abstractions/FuzzyMatcher.cs @@ -0,0 +1,64 @@ + +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Linq; + +namespace Netclaw.Tools +{ + public static class FuzzyMatcher + { + public static string? FindBestMatch(string input, System.Collections.Generic.IEnumerable candidates, double threshold = 0.75) + { + if (string.IsNullOrWhiteSpace(input)) return null; + var normalisedInput = Normalise(input); + if (normalisedInput.Length == 0) return null; + + string? bestMatch = null; + double bestScore = 0; + + foreach (var candidate in candidates) + { + var normalisedCandidate = Normalise(candidate); + if (normalisedCandidate.Length == 0) continue; + double score = Similarity(normalisedInput, normalisedCandidate); + if (normalisedCandidate.Contains(normalisedInput, StringComparison.Ordinal)) score = System.Math.Max(score, 0.85); + if (score > bestScore) + { + bestScore = score; + bestMatch = candidate; + } + } + + return bestScore >= threshold ? bestMatch : null; + } + + private static string Normalise(string value) => + new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static double Similarity(string a, string b) + { + int distance = LevenshteinDistance(a, b); + return 1.0 - (double)distance / System.Math.Max(a.Length, b.Length); + } + + private static int LevenshteinDistance(string a, string b) + { + var previous = System.Linq.Enumerable.Range(0, b.Length + 1).ToArray(); + var current = new int[b.Length + 1]; + for (int i = 1; i <= a.Length; i++) + { + current[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + current[j] = System.Math.Min(System.Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost); + } + (previous, current) = (current, previous); + } + return previous[b.Length]; + } + } +} diff --git a/src/Netclaw.Tools.Abstractions/ToolArgumentHelper.cs b/src/Netclaw.Tools.Abstractions/ToolArgumentHelper.cs index 4595fa999..f488855c1 100644 --- a/src/Netclaw.Tools.Abstractions/ToolArgumentHelper.cs +++ b/src/Netclaw.Tools.Abstractions/ToolArgumentHelper.cs @@ -112,11 +112,7 @@ private static Dictionary BuildNormalizedMetaFields() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var canonical in ToolCallMeta.MetaFieldNames) - map[NormalizeKey(canonical)] = canonical; - - map["timeout"] = "_timeout_seconds"; - map["rational"] = "_rationale"; - map["rationalle"] = "_rationale"; + map[NormalizeKey(canonical)] = canonical; return map; } @@ -132,7 +128,15 @@ private static Dictionary BuildNormalizedMetaFields() public static string? ResolveMetaField(string? key) => string.IsNullOrWhiteSpace(key) ? null - : NormalizedMetaFields.TryGetValue(NormalizeKey(key), out var canonical) ? canonical : null; + : NormalizedMetaFields.TryGetValue(NormalizeKey(key), out var canonical) + ? canonical + : FuzzyMatchUnmatched(key); + + private static string? FuzzyMatchUnmatched(string key) + { + const double threshold = 0.75; + return FuzzyMatcher.FindBestMatch(key, ToolCallMeta.MetaFieldNames, threshold); + } public static string? GetString(IDictionary? arguments, string key) {