diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index da891887..a3eae9c9 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ diff --git a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs index bc738ace..4993ccf3 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -1909,21 +1909,33 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori return _tools.Any(t => string.Equals(t?.Name, toolName, StringComparison.OrdinalIgnoreCase)); - } - - - - - - - + } + + private static string StripJsonFence(string input) + { + if (string.IsNullOrWhiteSpace(input)) return input; + string trimmed = input.Trim(); + if (!trimmed.StartsWith("```", StringComparison.Ordinal)) return trimmed; + int firstNewline = trimmed.IndexOf('\n'); + if (firstNewline < 0) return trimmed; + string inner = trimmed.Substring(firstNewline + 1); + int lastFence = inner.LastIndexOf("```", StringComparison.Ordinal); + if (lastFence >= 0) + { + inner = inner.Substring(0, lastFence); + } + return inner.Trim(); + } + private static bool ShouldRetryTools(string response) { if (string.IsNullOrWhiteSpace(response)) return false; - if (!JsonToolCallParser.TryParseObject(response, out var obj)) return false; + string cleaned = StripJsonFence(response); + + if (!JsonToolCallParser.TryParseObject(cleaned, out var obj)) return false; if (obj.TryGetValue("retry_tools", out object raw) && raw != null) @@ -4431,7 +4443,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori temperature: 0.2f, - toolChoice: "auto"); + toolChoice: "required"); @@ -4521,7 +4533,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori temperature: 0.2f, - toolChoice: "auto"); + toolChoice: "required"); if (retryQueryResponse == null) @@ -5081,6 +5093,20 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori + if (!ToolCallValidator.TryValidate(tool, argsJson, out _, out string validationError)) + { + string note = $"{validationError} Please output tool_calls with valid arguments only."; + combinedResults.AppendLine(note); + combinedResults.AppendLine("ToolRunner Guard: The tool call was blocked before execution. You MUST correct the tool call."); + messages?.Add(ChatMessage.ToolResult(call.Id ?? "", note)); + if (IsActionToolName(call.Name)) + { + failedActions.Add(call.Name); + AddActionFailure(call.Name); + } + executed++; + continue; + } if (Prefs.DevMode) { @@ -5863,3 +5889,11 @@ private async Task ExecuteJsonToolsForPhase(string json, R + + + + + + + + diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/AITool.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/AITool.cs index c7feeefb..99633fd7 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/Tools/AITool.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/AITool.cs @@ -22,6 +22,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools public virtual Dictionary GetFunctionDefinition() { var parameters = GetParametersSchema() ?? SchemaObject(new Dictionary(), new string[] { }); + parameters = ToolSchemaSanitizer.Sanitize(parameters); return new Dictionary { ["type"] = "function", diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolCallValidator.cs b/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolCallValidator.cs new file mode 100644 index 00000000..9a7374ed --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolCallValidator.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using WulaFallenEmpire.EventSystem.AI.Tools; + +namespace WulaFallenEmpire.EventSystem.AI.Utils +{ + public static class ToolCallValidator + { + public static bool TryValidate(AITool tool, string argsJson, out Dictionary args, out string error) + { + args = new Dictionary(StringComparer.OrdinalIgnoreCase); + error = null; + if (tool == null) + { + error = "Error: Tool not found."; + return false; + } + + if (string.IsNullOrWhiteSpace(argsJson)) argsJson = "{}"; + if (!JsonToolCallParser.TryParseObject(argsJson, out var parsed)) + { + error = $"Error: Invalid JSON arguments for tool '{tool.Name}'."; + return false; + } + + args = parsed ?? args; + var schema = ToolSchemaSanitizer.Sanitize(tool.GetParametersSchema()); + if (!TryValidateObject(args, schema, out error)) + { + error = $"Error: Tool '{tool.Name}' arguments failed validation. {error}"; + return false; + } + + return true; + } + + private static bool TryValidateObject(Dictionary args, Dictionary schema, out string error) + { + error = null; + if (schema == null) return true; + + if (schema.TryGetValue("properties", out object propsObj) && propsObj is Dictionary props) + { + if (schema.TryGetValue("required", out object requiredObj) && requiredObj is List requiredList) + { + foreach (var req in requiredList) + { + string name = req as string; + if (string.IsNullOrWhiteSpace(name)) continue; + if (!args.ContainsKey(name)) + { + error = $"Missing required field '{name}'."; + return false; + } + } + } + + if (schema.TryGetValue("additionalProperties", out object additionalObj) + && additionalObj is bool allowAdditional && !allowAdditional) + { + foreach (var key in args.Keys) + { + if (!props.ContainsKey(key)) + { + error = $"Unexpected field '{key}'."; + return false; + } + } + } + + foreach (var entry in args) + { + if (!props.TryGetValue(entry.Key, out object propSchemaObj)) continue; + if (propSchemaObj is not Dictionary propSchema) continue; + if (!TryValidateValue(entry.Value, propSchema, out error)) + { + error = $"Field '{entry.Key}' is invalid. {error}"; + return false; + } + } + } + + return true; + } + + private static bool TryValidateValue(object value, Dictionary schema, out string error) + { + error = null; + if (schema == null) return true; + + string type = NormalizeType(schema); + if (string.IsNullOrWhiteSpace(type)) return true; + + if (value == null) + { + error = "Value must not be null."; + return false; + } + + switch (type) + { + case "string": + if (value is string) return true; + error = "Expected string."; + return false; + case "boolean": + if (value is bool) return true; + if (value is string s && bool.TryParse(s, out _)) return true; + if (IsNumber(value)) return true; + error = "Expected boolean."; + return false; + case "number": + if (IsNumber(value)) return true; + if (value is string n && double.TryParse(n, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) return true; + error = "Expected number."; + return false; + case "integer": + if (value is int || value is long) return true; + if (value is string i && long.TryParse(i, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) return true; + error = "Expected integer."; + return false; + case "object": + if (value is Dictionary obj) + { + return TryValidateObject(obj, schema, out error); + } + error = "Expected object."; + return false; + case "array": + if (value is List list) + { + if (schema.TryGetValue("items", out object itemsObj) && itemsObj is Dictionary itemSchema) + { + foreach (var item in list) + { + if (!TryValidateValue(item, itemSchema, out error)) return false; + } + } + return true; + } + error = "Expected array."; + return false; + default: + return true; + } + } + + private static string NormalizeType(Dictionary schema) + { + if (!schema.TryGetValue("type", out object typeObj) || typeObj == null) return null; + if (typeObj is string s) return s; + if (typeObj is List list) + { + foreach (var item in list) + { + if (item is string candidate && !string.Equals(candidate, "null", StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + } + return null; + } + + private static bool IsNumber(object value) + { + return value is int || value is long || value is float || value is double || value is decimal; + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolSchemaSanitizer.cs b/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolSchemaSanitizer.cs new file mode 100644 index 00000000..f0fadf99 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Utils/ToolSchemaSanitizer.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace WulaFallenEmpire.EventSystem.AI.Utils +{ + public static class ToolSchemaSanitizer + { + public static Dictionary Sanitize(Dictionary schema) + { + if (schema == null) return new Dictionary(); + + string schemaType = NormalizeType(schema); + if (string.IsNullOrWhiteSpace(schemaType)) + { + schemaType = "object"; + schema["type"] = schemaType; + } + + if (string.Equals(schemaType, "object", StringComparison.OrdinalIgnoreCase)) + { + if (!TryGetDict(schema, "properties", out var props)) + { + props = new Dictionary(); + schema["properties"] = props; + } + + var sanitizedProps = new Dictionary(); + foreach (var entry in props) + { + if (entry.Value is Dictionary child) + { + sanitizedProps[entry.Key] = Sanitize(child); + } + else + { + sanitizedProps[entry.Key] = new Dictionary + { + ["type"] = "string" + }; + } + } + schema["properties"] = sanitizedProps; + + if (!schema.ContainsKey("additionalProperties")) + { + schema["additionalProperties"] = false; + } + + if (schema.TryGetValue("required", out object requiredRaw) && requiredRaw is List requiredList) + { + var filtered = new List(); + foreach (var item in requiredList) + { + string name = item as string; + if (string.IsNullOrWhiteSpace(name)) continue; + if (sanitizedProps.ContainsKey(name)) + { + filtered.Add(name); + } + } + schema["required"] = filtered; + } + } + else if (string.Equals(schemaType, "array", StringComparison.OrdinalIgnoreCase)) + { + if (schema.TryGetValue("items", out object itemsObj) && itemsObj is Dictionary itemSchema) + { + schema["items"] = Sanitize(itemSchema); + } + } + + return schema; + } + + private static string NormalizeType(Dictionary schema) + { + if (!schema.TryGetValue("type", out object typeObj) || typeObj == null) return null; + if (typeObj is string s) return s; + if (typeObj is List list) + { + foreach (var item in list) + { + if (item is string candidate && !string.Equals(candidate, "null", StringComparison.OrdinalIgnoreCase)) + { + schema["type"] = candidate; + return candidate; + } + } + } + return null; + } + + private static bool TryGetDict(Dictionary root, string key, out Dictionary value) + { + value = null; + if (root == null || string.IsNullOrWhiteSpace(key)) return false; + if (root.TryGetValue(key, out object raw) && raw is Dictionary dict) + { + value = dict; + return true; + } + return false; + } + } +}