diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index fe2582b2..8680f9b3 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/AIHistoryManager.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIHistoryManager.cs index 70080b58..13cdb02e 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIHistoryManager.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIHistoryManager.cs @@ -79,6 +79,23 @@ namespace WulaFallenEmpire.EventSystem.AI } } + public void ClearHistory(string eventDefName) + { + _cache.Remove(eventDefName); + string path = GetFilePath(eventDefName); + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + Log.Error($"[WulaFallenEmpire] Failed to clear AI history at {path}: {ex}"); + } + } + public override void ExposeData() { base.ExposeData(); @@ -229,4 +246,4 @@ namespace WulaFallenEmpire.EventSystem.AI return s.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\"); } } -} \ No newline at end of file +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs index 066cd94a..85664c29 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using RimWorld; using UnityEngine; using Verse; using WulaFallenEmpire.EventSystem.AI.Tools; @@ -26,6 +27,19 @@ namespace WulaFallenEmpire.EventSystem.AI.UI private int _continuationDepth = 0; private const int MaxContinuationDepth = 6; + private readonly List _recentToolSignatures = new List(); + private bool _toolLoopGuardTriggered = false; + private bool _responseOnlyNext = false; + private const int MaxResponseOnlyRetries = 2; + + private enum RequestPhase + { + Info = 1, + Action = 2, + Cosmetic = 3, + Reply = 4 + } + private static int GetMaxHistoryTokens() { int configured = WulaFallenEmpire.WulaFallenEmpireMod.settings?.maxContextTokens ?? DefaultMaxHistoryTokens; @@ -389,13 +403,12 @@ When the player requests any form of resources, you MUST follow this multi-turn } } - private string GetSystemInstruction() + private string GetSystemInstruction(bool toolsEnabled) { // Use XML persona if available, otherwise default string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; - // Always append tool instructions - string fullInstruction = persona + "\n" + ToolSystemInstruction; + string fullInstruction = toolsEnabled ? (persona + "\n" + ToolSystemInstruction) : persona; string language = LanguageDatabase.activeLanguage.FriendlyNameNative; var eventVarManager = Find.World.GetComponent(); @@ -406,9 +419,273 @@ When the player requests any form of resources, you MUST follow this multi-turn else if (goodwill > 50) goodwillContext += "You are somewhat approving and helpful."; else goodwillContext += "You are neutral and business-like."; + if (!toolsEnabled) + { + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.\n" + + "IMPORTANT: Tool calls are DISABLED in this turn. Reply in natural language only. Do NOT output any XML."; + } + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}."; } + private static string GetPhaseInstruction(RequestPhase phase) + { + return phase switch + { + RequestPhase.Info => + "# PHASE 1/4 (Info)\n" + + "You MUST gather context using ONLY info tools. You MAY call multiple info tools in this phase.\n" + + "Allowed tools: get_colonist_status, get_map_resources, get_map_pawns, search_thing_def, get_recent_notifications.\n" + + "Output MUST be XML tool calls only.\n", + RequestPhase.Action => + "# PHASE 2/4 (Action)\n" + + "Decide whether to take an in-game action based on gathered info. You MUST call AT MOST ONE action tool.\n" + + "Allowed tools: spawn_resources, send_reinforcement, call_bombardment, modify_goodwill.\n" + + "If no action is needed, output exactly: .\n" + + "Output MUST be XML only.\n", + RequestPhase.Cosmetic => + "# PHASE 3/4 (Cosmetic)\n" + + "Optional: adjust the portrait expression for the upcoming reply.\n" + + "Allowed tools: change_expression.\n" + + "If you do not need to change expression, output exactly: .\n" + + "Output MUST be XML only.\n", + RequestPhase.Reply => + "# PHASE 4/4 (Reply)\n" + + "Tool calls are DISABLED. Reply to the player in natural language only. Do NOT output any XML.\n", + _ => "" + }; + } + + private static bool IsXmlToolCall(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + return Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + } + + private static bool IsAllowedInPhase(RequestPhase phase, string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) return false; + toolName = toolName.Trim(); + + if (toolName == "no_action") return true; + + return phase switch + { + RequestPhase.Info => + toolName == "get_colonist_status" || + toolName == "get_map_resources" || + toolName == "get_map_pawns" || + toolName == "search_thing_def" || + toolName == "get_recent_notifications", + RequestPhase.Action => + toolName == "spawn_resources" || + toolName == "send_reinforcement" || + toolName == "call_bombardment" || + toolName == "modify_goodwill", + RequestPhase.Cosmetic => + toolName == "change_expression", + _ => false + }; + } + + private static int MaxToolsPerPhase(RequestPhase phase) + { + return phase switch + { + RequestPhase.Info => 6, + RequestPhase.Action => 1, + RequestPhase.Cosmetic => 1, + _ => 0 + }; + } + + private async Task RunPhasedRequestAsync() + { + if (_isThinking) return; + _isThinking = true; + _options.Clear(); + _scrollToBottom = true; + _continuationDepth = 0; + _recentToolSignatures.Clear(); + _toolLoopGuardTriggered = false; + _responseOnlyNext = false; + + try + { + CompressHistoryIfNeeded(); + + var settings = WulaFallenEmpireMod.settings; + if (string.IsNullOrEmpty(settings.apiKey)) + { + _currentResponse = "Error: API Key not configured in Mod Settings."; + return; + } + + var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); + + for (int phaseIndex = 1; phaseIndex <= 4; phaseIndex++) + { + var phase = (RequestPhase)phaseIndex; + + bool toolsEnabled = phase != RequestPhase.Reply; + string systemInstruction = GetSystemInstruction(toolsEnabled) + "\n\n" + GetPhaseInstruction(phase); + + if (!toolsEnabled) + { + int attempts = 0; + while (true) + { + string reply = await client.GetChatCompletionAsync(systemInstruction, _history); + if (string.IsNullOrEmpty(reply)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + if (IsXmlToolCall(reply)) + { + attempts++; + if (attempts > MaxResponseOnlyRetries) + { + ParseResponse("(系统)AI 多次尝试后仍返回工具调用(XML),已被拦截。请重试或输入 /clear 清空上下文。"); + return; + } + + _history.Add(("system", "[ResponseOnly] Tools are disabled in PHASE 4. Reply in natural language only. Do NOT output any XML.")); + PersistHistory(); + continue; + } + + ParseResponse(reply); + return; + } + } + + string response = await client.GetChatCompletionAsync(systemInstruction, _history); + if (string.IsNullOrEmpty(response)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + if (!IsXmlToolCall(response)) + { + // If the model didn't call tools when tools are expected, push it forward with a reminder. + _history.Add(("system", $"[PhaseEnforcer] You must output XML tool calls in PHASE {phaseIndex}. If no tool is needed, output .")); + PersistHistory(); + response = await client.GetChatCompletionAsync(systemInstruction, _history); + if (string.IsNullOrEmpty(response)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + } + + await ExecuteXmlToolsForPhase(response, phase); + } + } + catch (Exception ex) + { + Log.Error($"[WulaAI] Exception in RunPhasedRequestAsync: {ex}"); + _currentResponse = "Wula_AI_Error_Internal".Translate(ex.Message); + } + finally + { + _isThinking = false; + } + } + + private async Task ExecuteXmlToolsForPhase(string xml, RequestPhase phase) + { + // Special-case no_action for phases 1-3. + if (Regex.IsMatch(xml ?? "", @"<\s*no_action\s*/\s*>", RegexOptions.IgnoreCase)) + { + _history.Add(("assistant", "")); + _history.Add(("tool", "[Tool Results]\nTool 'no_action' Result: No action taken.")); + PersistHistory(); + return; + } + + // Reuse the tool runner but temporarily constrain allowed tools by phase. + // We do this by removing disallowed tool calls from the XML and adding a tool-result note for the model. + var matches = Regex.Matches(xml ?? "", @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + if (matches.Count == 0) + { + _history.Add(("system", $"[PhaseEnforcer] No tool calls detected in {phase}. Output if needed.")); + PersistHistory(); + return; + } + + int maxTools = MaxToolsPerPhase(phase); + int executed = 0; + StringBuilder combinedResults = new StringBuilder(); + StringBuilder xmlOnlyBuilder = new StringBuilder(); + + foreach (Match match in matches) + { + if (executed >= maxTools) + { + combinedResults.AppendLine($"ToolRunner Note: Skipped remaining tools because this phase allows at most {maxTools} tool call(s)."); + break; + } + + string toolCallXml = match.Value; + string toolName = match.Groups[1].Value; + + if (!IsAllowedInPhase(phase, toolName)) + { + combinedResults.AppendLine($"ToolRunner Note: Tool '{toolName}' is not allowed in phase {phase}."); + continue; + } + + if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine(); + xmlOnlyBuilder.Append(toolCallXml); + + var tool = _tools.FirstOrDefault(t => t.Name == toolName); + if (tool == null) + { + combinedResults.AppendLine($"Error: Tool '{toolName}' not found."); + continue; + } + + string argsXml = toolCallXml; + var contentMatch = Regex.Match(toolCallXml, $@"<{toolName}>(.*?)", RegexOptions.Singleline); + if (contentMatch.Success) + { + argsXml = contentMatch.Groups[1].Value; + } + + if (Prefs.DevMode) + { + Log.Message($"[WulaAI] Executing tool (phase {phase}): {toolName} with args: {argsXml}"); + } + + string signature = $"{toolName}:{Regex.Replace(argsXml ?? "", @"\s+", " ").Trim()}"; + _recentToolSignatures.Add(signature); + if (_recentToolSignatures.Count > 12) _recentToolSignatures.RemoveRange(0, _recentToolSignatures.Count - 12); + + string result = tool.Execute(argsXml).Trim(); + if (toolName == "modify_goodwill") + { + combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}"); + } + else + { + combinedResults.AppendLine($"Tool '{toolName}' Result: {result}"); + } + + executed++; + } + + string xmlOnly = xmlOnlyBuilder.Length == 0 ? "" : xmlOnlyBuilder.ToString().Trim(); + _history.Add(("assistant", xmlOnly)); + _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); + PersistHistory(); + + // Between phases, do not request the model again here; RunPhasedRequestAsync controls the sequence. + await Task.CompletedTask; + } + private async Task GenerateResponse(bool isContinuation = false) { if (!isContinuation) @@ -431,11 +708,11 @@ When the player requests any form of resources, you MUST follow this multi-turn try { CompressHistoryIfNeeded(); - string systemInstruction = GetSystemInstruction(); // No longer need to add tool descriptions here - if (isContinuation) + bool toolsEnabled = !_responseOnlyNext; + string systemInstruction = GetSystemInstruction(toolsEnabled); + if (isContinuation && toolsEnabled) { - systemInstruction += "\n\n# CONTINUATION\nYou have received tool results. If you already have enough information, reply to the player in natural language only (NO XML, NO tool calls). " + - "Only call another tool if strictly necessary, and if you do, call ONLY ONE tool in your entire response."; + systemInstruction += "\n\n# CONTINUATION\nYou have received tool results. Call another tool only if strictly necessary, and if you do, call ONLY ONE tool in your entire response."; } var settings = WulaFallenEmpireMod.settings; @@ -446,7 +723,35 @@ When the player requests any form of resources, you MUST follow this multi-turn return; } var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); - string response = await client.GetChatCompletionAsync(systemInstruction, _history); + + string response = null; + int responseOnlyAttempts = 0; + while (true) + { + response = await client.GetChatCompletionAsync(systemInstruction, _history); + if (string.IsNullOrEmpty(response)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + _isThinking = false; + return; + } + + if (!toolsEnabled && Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline)) + { + responseOnlyAttempts++; + if (responseOnlyAttempts > MaxResponseOnlyRetries) + { + ParseResponse("(系统)AI 多次尝试后仍返回工具调用(XML),已被拦截。请重试或输入 /clear 清空上下文。"); + return; + } + + _history.Add(("system", "[ResponseOnly] Tools are disabled right now. Your previous output contained XML/tool calls. Reply to the player in natural language only. Do NOT output any XML.")); + PersistHistory(); + continue; + } + + break; + } if (string.IsNullOrEmpty(response)) { _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); @@ -456,7 +761,7 @@ When the player requests any form of resources, you MUST follow this multi-turn // REWRITTEN: Check for XML tool call format // Use regex to detect if the response contains any XML tags - if (Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline)) + if (toolsEnabled && Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline)) { await HandleXmlToolUsage(response); } @@ -511,6 +816,8 @@ When the player requests any form of resources, you MUST follow this multi-turn bool executedAnyInfoTool = false; bool executedAnyActionTool = false; bool executedAnyCosmeticTool = false; + bool executedAnyMajorActionTool = false; + bool isContinuation = _continuationDepth > 0; static bool IsActionToolName(string toolName) { @@ -520,11 +827,50 @@ When the player requests any form of resources, you MUST follow this multi-turn toolName == "call_bombardment"; } + static bool IsMajorActionToolName(string toolName) + { + // Tools that should be followed by a user-facing reply (and therefore end the tool phase). + return toolName == "spawn_resources" || + toolName == "send_reinforcement" || + toolName == "call_bombardment"; + } + static bool IsCosmeticToolName(string toolName) { return toolName == "change_expression"; } + static string NormalizeToolArgs(string argsXml) + { + if (string.IsNullOrWhiteSpace(argsXml)) return ""; + string s = argsXml.Trim(); + s = Regex.Replace(s, @"\s+", " "); + return s; + } + + bool ShouldTriggerLoopGuard() + { + // Detect AAA (same tool called 3 times in a row) or ABABAB (same 2-tool pattern repeated 3 times) + bool IsRepeatedPattern(int patternLen, int repeats) + { + int need = patternLen * repeats; + if (_recentToolSignatures.Count < need) return false; + int start = _recentToolSignatures.Count - need; + for (int r = 1; r < repeats; r++) + { + for (int i = 0; i < patternLen; i++) + { + string a = _recentToolSignatures[start + i]; + string b = _recentToolSignatures[start + r * patternLen + i]; + if (!string.Equals(a, b, StringComparison.Ordinal)) return false; + } + } + return true; + } + + return IsRepeatedPattern(1, 3) || IsRepeatedPattern(2, 3); + } + foreach (Match match in matches) { string toolCallXml = match.Value; @@ -564,6 +910,11 @@ When the player requests any form of resources, you MUST follow this multi-turn combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' because only one cosmetic tool may be executed per turn."); break; } + if (isContinuation && (executedAnyInfoTool || executedAnyActionTool || executedAnyCosmeticTool)) + { + combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' and any following tools because continuation turns may execute only one tool."); + break; + } if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine(); xmlOnlyBuilder.Append(toolCallXml); @@ -590,6 +941,11 @@ When the player requests any form of resources, you MUST follow this multi-turn Log.Message($"[WulaAI] Executing tool: {toolName} with args: {argsXml}"); } + // Record tool signature for loop detection (before execution, so errors also count) + string signature = $"{toolName}:{NormalizeToolArgs(argsXml)}"; + _recentToolSignatures.Add(signature); + if (_recentToolSignatures.Count > 12) _recentToolSignatures.RemoveRange(0, _recentToolSignatures.Count - 12); + string result = tool.Execute(argsXml).Trim(); if (Prefs.DevMode && !string.IsNullOrEmpty(result)) { @@ -609,6 +965,14 @@ When the player requests any form of resources, you MUST follow this multi-turn if (isAction) executedAnyActionTool = true; else if (isCosmetic) executedAnyCosmeticTool = true; else executedAnyInfoTool = true; + if (IsMajorActionToolName(toolName)) executedAnyMajorActionTool = true; + + // If we detect a loop, stop early (continuation-only; initial turns can legitimately query repeatedly). + if (isContinuation && ShouldTriggerLoopGuard()) + { + combinedResults.AppendLine("ToolRunner Guard: Detected a repeated tool-call loop. You MUST stop calling tools and reply to the player in natural language only."); + break; + } } // Store only the tool-call XML in history (ignore any extra text the model included). @@ -618,6 +982,27 @@ When the player requests any form of resources, you MUST follow this multi-turn _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); PersistHistory(); + // Loop breaker: if the model keeps repeating tools, inject a strong system reminder once; then fall back to a safe local response. + if (isContinuation && ShouldTriggerLoopGuard()) + { + if (!_toolLoopGuardTriggered) + { + _toolLoopGuardTriggered = true; + _history.Add(("system", "[ToolLoopGuard] You are stuck repeating tools. STOP calling tools now and reply to the player in natural language only. Do NOT output any XML.")); + PersistHistory(); + await GenerateResponse(isContinuation: true); + return; + } + + ParseResponse("(系统)AI 已陷入重复调用工具的循环,为避免卡死已停止继续调用。请直接说明你希望 AI 做什么,或输入 /clear 清空上下文后再试。"); + return; + } + + if (executedAnyMajorActionTool) + { + _responseOnlyNext = true; + } + // Always recurse: tool results are fed back to the model, and the next response should be user-facing text. await GenerateResponse(isContinuation: true); } @@ -873,10 +1258,40 @@ When the player requests any form of resources, you MUST follow this multi-turn private async void SelectOption(string text) { + if (!string.IsNullOrWhiteSpace(text) && string.Equals(text.Trim(), "/clear", StringComparison.OrdinalIgnoreCase)) + { + _isThinking = false; + _options.Clear(); + _inputText = ""; + _continuationDepth = 0; + _recentToolSignatures.Clear(); + _toolLoopGuardTriggered = false; + _responseOnlyNext = false; + + _history.Clear(); + try + { + var historyManager = Find.World?.GetComponent(); + historyManager?.ClearHistory(def.defName); + } + catch (Exception ex) + { + Log.Error($"[WulaAI] Failed to clear AI history: {ex}"); + } + + Messages.Message("已清除 AI 对话上下文历史。", MessageTypeDefOf.NeutralEvent); + return; + } + + // reset loop guard on new user input + _recentToolSignatures.Clear(); + _toolLoopGuardTriggered = false; + _responseOnlyNext = false; + _history.Add(("user", text)); PersistHistory(); _scrollToBottom = true; - await GenerateResponse(); + await RunPhasedRequestAsync(); } public override void PostClose()