diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index b0eeee39..5eadf44f 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 2756c8f7..5ff3339c 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -57,7 +57,7 @@ namespace WulaFallenEmpire.EventSystem.AI private const int CharsPerToken = 4; private const int DefaultReactMaxSteps = 4; private const int ReactMaxToolsPerStep = 8; - private const float DefaultReactMaxSeconds = 12f; + private const float DefaultReactMaxSeconds = 30f; private int _thinkingPhaseTotal = DefaultReactMaxSteps; private static readonly Regex ExpressionTagRegex = new Regex(@"\[EXPR\s*:\s*([1-6])\s*\]", RegexOptions.IgnoreCase); @@ -111,7 +111,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori - Do NOT split multi-item requests across turns. 5. **TOOLS**: You MAY call any tools listed in ""# TOOLS (AVAILABLE)"". 6. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, output { ""tool_calls"": [] }. -7. **STEP BUDGET (OPTIONAL)**: You MAY include { ""meta"": { ""step_budget"": } } to request more tool steps for this turn. +7. **WORKFLOW PREFERENCE**: Prefer the flow Query tools → Action tools → Reply. If action results reveal missing info, you MAY return to Query and then Action again. 8. **NO TAGS**: Do NOT use tags, code fences, or any extra text outside JSON."; public AIIntelligenceCore(World world) : base(world) @@ -454,7 +454,14 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori private void SetThinkingPhase(int phaseIndex, bool isRetry) { - _thinkingPhaseIndex = Math.Max(1, Math.Min(_thinkingPhaseTotal, phaseIndex)); + if (_thinkingPhaseTotal <= 0 || _thinkingPhaseTotal == int.MaxValue) + { + _thinkingPhaseIndex = Math.Max(1, phaseIndex); + } + else + { + _thinkingPhaseIndex = Math.Max(1, Math.Min(_thinkingPhaseTotal, phaseIndex)); + } _thinkingPhaseRetry = isRetry; _thinkingStartTime = Time.realtimeSinceStartup; } @@ -664,7 +671,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori $"Final replies are generated later and MUST use: {language}."; } - private string GetNativeSystemInstruction() + private string GetNativeSystemInstruction(RequestPhase phase) { string persona = GetActivePersona(); string personaBlock = persona; @@ -684,6 +691,21 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori sb.AppendLine(goodwillContext); sb.AppendLine($"IMPORTANT: Reply in the following language: {language}."); sb.AppendLine("IMPORTANT: Use tools to fetch in-game data or perform actions. Do NOT invent tool results."); + sb.AppendLine("IMPORTANT: Tool workflow is fixed: Phase 1 = Query Tools, Phase 2 = Action Tools, Phase 3 = Reply."); + switch (phase) + { + case RequestPhase.QueryTools: + sb.AppendLine("CURRENT PHASE: Query Tools. Use ONLY query tools (get_*/search_*/analyze_*/recall_memories)."); + sb.AppendLine("Do NOT reply in natural language. If no query tools are needed, return no tool calls and leave content empty."); + break; + case RequestPhase.ActionTools: + sb.AppendLine("CURRENT PHASE: Action Tools. Use ONLY action tools (spawn_resources, send_reinforcement, call_bombardment, modify_goodwill, call_prefab_airdrop, set_overwatch_mode, remember_fact)."); + sb.AppendLine("Do NOT reply in natural language. If no actions are needed, return no tool calls and leave content empty."); + break; + default: + sb.AppendLine("CURRENT PHASE: Reply. Do NOT call any tools. Reply in natural language only."); + break; + } sb.AppendLine("IMPORTANT: Long-term memory is not preloaded. Use recall_memories to fetch memories when needed."); sb.AppendLine("IMPORTANT: When the user asks for an item by name, call search_thing_def to confirm the exact defName before spawning."); sb.AppendLine("You MAY include [EXPR:n] (n=1-6) to set your expression."); @@ -822,10 +844,15 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori return sb.ToString().TrimEnd(); } - private List> BuildNativeToolDefinitions() + private List> BuildNativeToolDefinitions(RequestPhase phase) { var available = _tools .Where(t => t != null) + .Where(t => phase == RequestPhase.QueryTools + ? IsQueryToolName(t.Name) + : phase == RequestPhase.ActionTools + ? IsActionToolName(t.Name) + : false) .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -943,27 +970,14 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori return false; } - private static int? ExtractStepBudget(string json) - { - if (string.IsNullOrWhiteSpace(json)) return null; - var match = Regex.Match(json, "\"step_budget\"\\s*:\\s*\"?(\\d+)\"?", RegexOptions.IgnoreCase); - if (!match.Success) return null; - if (int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) - { - return value; - } - return null; - } - private static string BuildReactFormatFixInstruction(string previousOutput) { return "# FORMAT FIX (REACT JSON ONLY)\n" + "Output valid JSON with fields thought/tool_calls.\n" + "If tools are needed, tool_calls must be non-empty.\n" + "If no tools are needed, output exactly: {\"tool_calls\": []} (you may include thought).\n" + - "You MAY include an optional meta block: {\"meta\":{\"step_budget\":}}.\n" + "Do NOT output any text outside JSON.\n" + - "Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}],\"meta\":{\"step_budget\":7}}\n" + + "Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}\n" + "\nPrevious output:\n" + TrimForPrompt(previousOutput, 600); } @@ -1007,9 +1021,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori "You used tool names that are NOT available: " + invalidList + "\n" + "Re-emit JSON with only available tools from # TOOLS (AVAILABLE).\n" + "If no tools are needed, output exactly: {\"tool_calls\": []}.\n" + - "You MAY include an optional meta block: {\"meta\":{\"step_budget\":}}.\n" + "Do NOT output any text outside JSON.\n" + - "Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}],\"meta\":{\"step_budget\":7}}"; + "Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}"; } private static bool ShouldRetryTools(string response) @@ -1924,28 +1937,18 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori var client = new SimpleAIClient(apiKey, baseUrl, model, settings.useGeminiProtocol); _currentClient = client; - if (!settings.useGeminiProtocol) - { - await RunNativeToolLoopAsync(client, settings); - return; - } - - // Model-Driven Vision: Start with null image. The model must request it using analyze_screen or capture_screen if needed. + // ReAct Tool Loop: Start with null image. The model must request it using analyze_screen or capture_screen if needed. string base64Image = null; float startTime = Time.realtimeSinceStartup; - int maxSteps = DefaultReactMaxSteps; + int maxSteps = int.MaxValue; float maxSeconds = DefaultReactMaxSeconds; - int stepBudgetMax = int.MaxValue; if (settings != null) { - stepBudgetMax = Math.Max(1, settings.reactMaxStepsMax); - maxSteps = Math.Max(1, settings.reactMaxSteps); - maxSeconds = Math.Max(2f, settings.reactMaxSeconds); + maxSeconds = Math.Max(2f, settings.reactMaxSeconds <= 0f ? DefaultReactMaxSeconds : settings.reactMaxSeconds); } - _thinkingPhaseTotal = maxSteps; + _thinkingPhaseTotal = 0; string toolPhaseReplyCandidate = null; - bool budgetApplied = false; for (int step = 1; step <= maxSteps; step++) { if (Time.realtimeSinceStartup - startTime > maxSeconds) @@ -2003,26 +2006,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori parsedSource = normalizedFixed; } - if (!budgetApplied && !string.IsNullOrWhiteSpace(jsonFragment)) - { - string budgetSource = jsonFragment; - int? requested = ExtractStepBudget(budgetSource); - if (!requested.HasValue) - { - requested = ExtractStepBudget(parsedSource); - } - if (requested.HasValue) - { - budgetApplied = true; - int clamped = Math.Max(1, Math.Min(stepBudgetMax, requested.Value)); - if (clamped > maxSteps) - { - maxSteps = clamped; - _thinkingPhaseTotal = maxSteps; - } - } - } - if (!string.IsNullOrWhiteSpace(parsedSource)) { string traceText = parsedSource; @@ -2059,25 +2042,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori if (!string.IsNullOrEmpty(normalizedCorrected) && JsonToolCallParser.TryParseToolCallsFromText(normalizedCorrected, out toolCalls, out jsonFragment)) { - if (!budgetApplied && !string.IsNullOrWhiteSpace(jsonFragment)) - { - string budgetSource = jsonFragment; - int? requested = ExtractStepBudget(budgetSource); - if (!requested.HasValue) - { - requested = ExtractStepBudget(normalizedCorrected); - } - if (requested.HasValue) - { - budgetApplied = true; - int clamped = Math.Max(1, Math.Min(stepBudgetMax, requested.Value)); - if (clamped > maxSteps) - { - maxSteps = clamped; - _thinkingPhaseTotal = maxSteps; - } - } - } parsedSource = normalizedCorrected; invalidTools = toolCalls @@ -2216,9 +2180,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori private async Task RunNativeToolLoopAsync(SimpleAIClient client, WulaFallenEmpireSettings settings) { - string systemInstruction = GetNativeSystemInstruction(); var messages = BuildNativeHistory(); - var tools = BuildNativeToolDefinitions(); + RequestPhase phase = RequestPhase.QueryTools; string finalReply = null; var successfulQueryTools = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -2226,17 +2189,15 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori var failedActionTools = new HashSet(StringComparer.OrdinalIgnoreCase); float startTime = Time.realtimeSinceStartup; - int maxSteps = Math.Max(1, settings.reactMaxSteps); - float maxSeconds = Math.Max(2f, settings.reactMaxSeconds); - _thinkingPhaseTotal = maxSteps; + int maxSteps = int.MaxValue; + float maxSeconds = Math.Max(2f, settings.reactMaxSeconds <= 0f ? DefaultReactMaxSeconds : settings.reactMaxSeconds); + _thinkingPhaseTotal = 3; int strictRetryCount = 0; + int phaseRetryCount = 0; const int MaxStrictRetries = 2; - const string StrictRetryGuidance = - "ToolRunner Error: Your last response was rejected because tool_calls was empty. " + - "You MUST call tools via the tool_calls field. Do NOT output XML or natural language. " + - "If no tools are needed, return no tool calls and leave content empty."; + const int MaxPhaseRetries = 2; - for (int step = 1; step <= maxSteps; step++) + for (int step = 1; step <= maxSteps && phase != RequestPhase.Reply; step++) { if (Time.realtimeSinceStartup - startTime > maxSeconds) { @@ -2247,8 +2208,10 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori break; } - SetThinkingPhase(step, false); + SetThinkingPhase(phase == RequestPhase.QueryTools ? 1 : 2, false); + string systemInstruction = GetNativeSystemInstruction(phase); + var tools = BuildNativeToolDefinitions(phase); ChatCompletionResult result = await client.GetChatCompletionWithToolsAsync( systemInstruction, messages, @@ -2264,15 +2227,26 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori if (result.ToolCalls == null || result.ToolCalls.Count == 0) { - if (strictRetryCount < MaxStrictRetries) + if (!string.IsNullOrWhiteSpace(result.Content) && strictRetryCount < MaxStrictRetries) { strictRetryCount++; - messages.Add(ChatMessage.User(StrictRetryGuidance)); + string strictRetryGuidance = phase == RequestPhase.QueryTools + ? "ToolRunner Error: This is Query phase. You MUST call query tools using tool_calls. Do NOT output XML or natural language. If no query tools are needed, return no tool calls and leave content empty." + : "ToolRunner Error: This is Action phase. You MUST call action tools using tool_calls. Do NOT output XML or natural language. If no actions are needed, return no tool calls and leave content empty."; + messages.Add(ChatMessage.User(strictRetryGuidance)); if (Prefs.DevMode) { WulaLog.Debug($"[WulaAI] Native tool loop retry: missing tool_calls (attempt {strictRetryCount}/{MaxStrictRetries})."); } - step--; + continue; + } + + strictRetryCount = 0; + phaseRetryCount = 0; + + if (phase == RequestPhase.QueryTools) + { + phase = RequestPhase.ActionTools; continue; } @@ -2280,9 +2254,35 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori } strictRetryCount = 0; + phaseRetryCount = 0; if (result.ToolCalls != null && result.ToolCalls.Count > 0) { + var invalidTools = result.ToolCalls + .Where(c => c != null && !string.IsNullOrWhiteSpace(c.Name)) + .Select(c => c.Name) + .Where(n => phase == RequestPhase.QueryTools ? !IsQueryToolName(n) : !IsActionToolName(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (invalidTools.Count > 0) + { + if (phaseRetryCount < MaxPhaseRetries) + { + phaseRetryCount++; + string invalidList = string.Join(", ", invalidTools); + string guidance = phase == RequestPhase.QueryTools + ? $"ToolRunner Error: Query phase only allows query tools. Invalid: {invalidList}. Re-issue tool_calls with query tools only." + : $"ToolRunner Error: Action phase only allows action tools. Invalid: {invalidList}. Re-issue tool_calls with action tools only."; + messages.Add(ChatMessage.User(guidance)); + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] Native tool loop retry: invalid tools ({invalidList})."); + } + continue; + } + break; + } + int maxTools = ReactMaxToolsPerStep; var callsToExecute = result.ToolCalls.Count > maxTools ? result.ToolCalls.Take(maxTools).ToList() @@ -2476,7 +2476,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori { string guidance = "ToolRunner Guidance: Continue with JSON only using {\"thought\":\"...\",\"tool_calls\":[...]}. " + "If no more tools are needed, output exactly: {\"tool_calls\": []}. " + - "You MAY include {\"meta\":{\"step_budget\":}} to request more steps. " + "Do NOT output any text outside JSON."; if (!JsonToolCallParser.TryParseToolCallsFromText(json ?? "", out var toolCalls, out string jsonFragment)) diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs index 449c0a62..d4afcd4c 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs @@ -805,7 +805,7 @@ namespace WulaFallenEmpire.EventSystem.AI.UI ? Mathf.Max(0f, Time.realtimeSinceStartup - (_core?.ThinkingStartTime ?? 0f)) : _core?.LastThinkingDuration ?? 0f; string elapsedText = elapsed > 0f ? elapsed.ToString("0.0", CultureInfo.InvariantCulture) : "0.0"; - return $"{state} (用时 {elapsedText}s · Loop {_core?.ThinkingPhaseIndex ?? 0}/{_core?.ThinkingPhaseTotal ?? 0})"; + return $"{state} (用时 {elapsedText}s · Loop {_core?.ThinkingPhaseIndex ?? 0})"; } private void DrawReactTracePanel(Rect rect, CachedMessage traceEntry) @@ -852,7 +852,7 @@ namespace WulaFallenEmpire.EventSystem.AI.UI if (_core == null) return "Thinking..."; float elapsedSeconds = Mathf.Max(0f, Time.realtimeSinceStartup - _core.ThinkingStartTime); string elapsedText = elapsedSeconds.ToString("0.0", CultureInfo.InvariantCulture); - return $"P.I.A is thinking... ({elapsedText}s Loop {_core.ThinkingPhaseIndex}/{_core.ThinkingPhaseTotal})"; + return $"P.I.A is thinking... ({elapsedText}s Loop {_core.ThinkingPhaseIndex})"; } private void DrawThinkingIndicator(Rect rect) diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs index 72d67042..6c6688f3 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs @@ -849,8 +849,7 @@ namespace WulaFallenEmpire.EventSystem.AI.UI : _core?.LastThinkingDuration ?? 0f; string elapsedText = elapsed > 0f ? elapsed.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) : "0.0"; int phaseIndex = _core?.ThinkingPhaseIndex ?? 0; - int phaseTotal = _core?.ThinkingPhaseTotal ?? 0; - return $"{state} (用时 {elapsedText}s · Loop {phaseIndex}/{phaseTotal})"; + return $"{state} (用时 {elapsedText}s · Loop {phaseIndex})"; } private void DrawReactTracePanel(Rect rect, CachedMessage traceEntry) @@ -1037,7 +1036,7 @@ namespace WulaFallenEmpire.EventSystem.AI.UI if (_core == null) return "Thinking..."; float elapsedSeconds = Mathf.Max(0f, Time.realtimeSinceStartup - _core.ThinkingStartTime); string elapsedText = elapsedSeconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture); - return $"P.I.A is thinking... ({elapsedText}s Loop {_core.ThinkingPhaseIndex}/{_core.ThinkingPhaseTotal})"; + return $"P.I.A is thinking... ({elapsedText}s Loop {_core.ThinkingPhaseIndex})"; } private void DrawThinkingIndicator(Rect rect) diff --git a/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs b/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs index d4dad45f..29d72745 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs +++ b/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs @@ -14,8 +14,6 @@ namespace WulaFallenEmpire public static bool _showApiKey = false; public static bool _showVlmApiKey = false; private string _maxContextTokensBuffer; - private string _reactMaxStepsBuffer; - private string _reactMaxStepsMaxBuffer; private string _reactMaxSecondsBuffer; public WulaFallenEmpireMod(ModContentPack content) : base(content) @@ -99,14 +97,7 @@ namespace WulaFallenEmpire listingStandard.GapLine(); listingStandard.Label("ReAct Loop Settings"); - listingStandard.Label("Default Steps (min 1):"); - Rect stepsRect = listingStandard.GetRect(Text.LineHeight); - Widgets.TextFieldNumeric(stepsRect, ref settings.reactMaxSteps, ref _reactMaxStepsBuffer, 1, int.MaxValue); - - listingStandard.Label("Max Steps Limit (step_budget upper bound, min 1):"); - Rect stepsMaxRect = listingStandard.GetRect(Text.LineHeight); - Widgets.TextFieldNumeric(stepsMaxRect, ref settings.reactMaxStepsMax, ref _reactMaxStepsMaxBuffer, 1, int.MaxValue); - + listingStandard.Label("Steps: Unlimited (step limit removed)."); listingStandard.Label("Max Seconds (min 2):"); Rect secondsRect = listingStandard.GetRect(Text.LineHeight); Widgets.TextFieldNumeric(secondsRect, ref settings.reactMaxSeconds, ref _reactMaxSecondsBuffer, 10f, 600f); diff --git a/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs index 49476f4e..f59d31b8 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs +++ b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs @@ -23,9 +23,9 @@ namespace WulaFallenEmpire public float aiCommentaryChance = 0.7f; public bool commentOnNegativeOnly = false; public string extraPersonalityPrompt = ""; - public int reactMaxSteps = 4; - public int reactMaxStepsMax = 7; - public float reactMaxSeconds = 60f; + public int reactMaxSteps = 0; // Deprecated: step limit removed (unlimited). + public int reactMaxStepsMax = 0; // Deprecated: step limit removed (unlimited). + public float reactMaxSeconds = 30f; public bool showReactTraceInUI = false; public override void ExposeData() @@ -48,9 +48,9 @@ namespace WulaFallenEmpire Scribe_Values.Look(ref aiCommentaryChance, "aiCommentaryChance", 0.7f); Scribe_Values.Look(ref commentOnNegativeOnly, "commentOnNegativeOnly", false); Scribe_Values.Look(ref extraPersonalityPrompt, "extraPersonalityPrompt", ""); - Scribe_Values.Look(ref reactMaxSteps, "reactMaxSteps", 4); - Scribe_Values.Look(ref reactMaxStepsMax, "reactMaxStepsMax", 7); - Scribe_Values.Look(ref reactMaxSeconds, "reactMaxSeconds", 60f); + Scribe_Values.Look(ref reactMaxSteps, "reactMaxSteps", 0); + Scribe_Values.Look(ref reactMaxStepsMax, "reactMaxStepsMax", 0); + Scribe_Values.Look(ref reactMaxSeconds, "reactMaxSeconds", 30f); Scribe_Values.Look(ref showReactTraceInUI, "showReactTraceInUI", false); base.ExposeData();