强 schema 清洗:所有工具定义在生成时统一清洗,补齐 properties、规整 type、强制 additionalProperties=false,避免 schema 漏洞导致模型随意输出。AITool.cs、ToolSchemaSanitizer.cs

预执行拦截(Claude Code 风格):工具执行前校验 JSON、必填字段、类型、未知字段;失败则返回错误 ToolResult 反馈给模型,允许自修正或降级回复。ToolCallValidator.cs、AIIntelligenceCore.cs
重试清洗:retry_tools 解析前剥离 json 围栏,避免误判。AIIntelligenceCore.cs
工具选择:Query 阶段强制 tool_choice: "required",Action 维持 auto。AIIntelligenceCore.cs
This commit is contained in:
2026-01-02 14:09:39 +08:00
parent 36c182636e
commit 2c4cff8b63
5 changed files with 322 additions and 11 deletions

View File

@@ -1909,21 +1909,33 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
}
try
try
{
var historyManager = Find.World?.GetComponent<AIHistoryManager>();
historyManager?.SaveHistory(_activeEventDefName, _history);
}
catch (Exception ex)
@@ -4431,7 +4443,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
if (string.IsNullOrWhiteSpace(cleaned))
{
@@ -4521,7 +4533,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
}
@@ -5081,6 +5093,20 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
continue;
}
sb.AppendLine($"{role}: {message}");
}
@@ -5863,3 +5889,11 @@ private async Task<PhaseExecutionResult> ExecuteJsonToolsForPhase(string json, R
{
StringBuilder sb = new StringBuilder();
sb.Append("{\"facts\":[");

View File

@@ -22,6 +22,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
public virtual Dictionary<string, object> GetFunctionDefinition()
{
var parameters = GetParametersSchema() ?? SchemaObject(new Dictionary<string, object>(), new string[] { });
parameters = ToolSchemaSanitizer.Sanitize(parameters);
return new Dictionary<string, object>
{
["type"] = "function",

View File

@@ -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<string, object> args, out string error)
{
args = new Dictionary<string, object>(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<string, object> args, Dictionary<string, object> schema, out string error)
{
error = null;
if (schema == null) return true;
if (schema.TryGetValue("properties", out object propsObj) && propsObj is Dictionary<string, object> props)
{
if (schema.TryGetValue("required", out object requiredObj) && requiredObj is List<object> 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<string, object> 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<string, object> 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<string, object> obj)
{
return TryValidateObject(obj, schema, out error);
}
error = "Expected object.";
return false;
case "array":
if (value is List<object> list)
{
if (schema.TryGetValue("items", out object itemsObj) && itemsObj is Dictionary<string, object> 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<string, object> schema)
{
if (!schema.TryGetValue("type", out object typeObj) || typeObj == null) return null;
if (typeObj is string s) return s;
if (typeObj is List<object> 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;
}
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
namespace WulaFallenEmpire.EventSystem.AI.Utils
{
public static class ToolSchemaSanitizer
{
public static Dictionary<string, object> Sanitize(Dictionary<string, object> schema)
{
if (schema == null) return new Dictionary<string, object>();
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<string, object>();
schema["properties"] = props;
}
var sanitizedProps = new Dictionary<string, object>();
foreach (var entry in props)
{
if (entry.Value is Dictionary<string, object> child)
{
sanitizedProps[entry.Key] = Sanitize(child);
}
else
{
sanitizedProps[entry.Key] = new Dictionary<string, object>
{
["type"] = "string"
};
}
}
schema["properties"] = sanitizedProps;
if (!schema.ContainsKey("additionalProperties"))
{
schema["additionalProperties"] = false;
}
if (schema.TryGetValue("required", out object requiredRaw) && requiredRaw is List<object> requiredList)
{
var filtered = new List<object>();
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<string, object> itemSchema)
{
schema["items"] = Sanitize(itemSchema);
}
}
return schema;
}
private static string NormalizeType(Dictionary<string, object> schema)
{
if (!schema.TryGetValue("type", out object typeObj) || typeObj == null) return null;
if (typeObj is string s) return s;
if (typeObj is List<object> 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<string, object> root, string key, out Dictionary<string, object> value)
{
value = null;
if (root == null || string.IsNullOrWhiteSpace(key)) return false;
if (root.TryGetValue(key, out object raw) && raw is Dictionary<string, object> dict)
{
value = dict;
return true;
}
return false;
}
}
}