已按 ReAct‑lite 方案改成“单循环 + JSON 思考封装”,并确保思考链只在内部 JSON 里流转,不会展示给玩家。核心变化如下。

改动说明(ReAct‑lite 落地)

用“循环式 Thought/ToolCalls/Observation/Final”替换原三阶段:AIIntelligenceCore.cs
新增 ReAct 专用提示与工具清单构建,统一 JSON 输出结构 {thought, tool_calls, final}:AIIntelligenceCore.cs
新增通用 JSON 对象片段解析,允许从混杂文本中抽取 JSON:JsonToolCallParser.cs
工具执行改为 step 模式(不再区分 query/action 阶段),并更新工具结果指导语以匹配 ReAct‑lite:AIIntelligenceCore.cs
This commit is contained in:
2025-12-31 02:16:02 +08:00
parent 1f86ae7d6c
commit e3f257e8d5
3 changed files with 336 additions and 313 deletions

View File

@@ -44,8 +44,6 @@ namespace WulaFallenEmpire.EventSystem.AI
private string _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; private string _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls).";
private bool _querySuccessfulToolCall; private bool _querySuccessfulToolCall;
private bool _actionSuccessfulToolCall; private bool _actionSuccessfulToolCall;
private bool _queryRetryUsed;
private bool _actionRetryUsed;
private readonly List<string> _actionSuccessLedger = new List<string>(); private readonly List<string> _actionSuccessLedger = new List<string>();
private readonly HashSet<string> _actionSuccessLedgerSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _actionSuccessLedgerSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _actionFailedLedger = new List<string>(); private readonly List<string> _actionFailedLedger = new List<string>();
@@ -57,7 +55,10 @@ namespace WulaFallenEmpire.EventSystem.AI
private const int DefaultMaxHistoryTokens = 100000; private const int DefaultMaxHistoryTokens = 100000;
private const int CharsPerToken = 4; private const int CharsPerToken = 4;
private const int ThinkingPhaseTotal = 3; private const int ReactMaxSteps = 4;
private const int ReactMaxToolsPerStep = 8;
private const float ReactMaxSeconds = 12f;
private const int ThinkingPhaseTotal = ReactMaxSteps;
private static readonly Regex ExpressionTagRegex = new Regex(@"\[EXPR\s*:\s*([1-6])\s*\]", RegexOptions.IgnoreCase); private static readonly Regex ExpressionTagRegex = new Regex(@"\[EXPR\s*:\s*([1-6])\s*\]", RegexOptions.IgnoreCase);
private const string AutoCommentaryTag = "[AUTO_COMMENTARY]"; private const string AutoCommentaryTag = "[AUTO_COMMENTARY]";
@@ -96,18 +97,18 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
private const string ToolRulesInstruction = @" private const string ToolRulesInstruction = @"
# TOOL USE RULES # TOOL USE RULES
1. **FORMATTING**: Tool calls MUST be valid JSON using the following schema: 1. **FORMATTING**: Your output MUST be valid JSON with fields:
{ ""tool_calls"": [ { ""type"": ""function"", ""function"": { ""name"": ""tool_name"", ""arguments"": { ... } } } ] } { ""thought"": ""..."", ""tool_calls"": [ { ""type"": ""function"", ""function"": { ""name"": ""tool_name"", ""arguments"": { ... } } } ], ""final"": ""..."" }
2. **STRICT OUTPUT**: 2. **STRICT OUTPUT**:
- Your output MUST be either: - If ""tool_calls"" is non-empty, ""final"" MUST be an empty string.
- A JSON object with ""tool_calls"" (may be empty), OR - If no tools are needed, ""tool_calls"" MUST be [] and ""final"" MUST contain the user-facing reply.
- Exactly: { ""tool_calls"": [] } - Do NOT include any natural language, explanation, markdown, or extra text outside JSON.
Do NOT include any natural language, explanation, markdown, or additional commentary. 3. **THOUGHT**: ""thought"" is internal and will NOT be shown to the user.
3. **MULTI-REQUEST RULE**: 4. **MULTI-REQUEST RULE**:
- If the user requests multiple items or information, you MUST output ALL required tool calls in the SAME tool-phase response. - If the user requests multiple items or information, you MUST output ALL required tool calls in the SAME response.
- Do NOT split multi-item requests across turns. - Do NOT split multi-item requests across turns.
4. **TOOLS**: You MAY call any tools listed in ""# TOOLS (AVAILABLE)"". 5. **TOOLS**: You MAY call any tools listed in ""# TOOLS (AVAILABLE)"".
5. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, output { ""tool_calls"": [] } and proceed to the next phase."; 6. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, output { ""thought"": ""..."", ""tool_calls"": [], ""final"": """" }.";
public AIIntelligenceCore(World world) : base(world) public AIIntelligenceCore(World world) : base(world)
{ {
@@ -671,8 +672,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
"You MAY include [EXPR:n] to set your expression (n=1-6)."; "You MAY include [EXPR:n] to set your expression (n=1-6).";
} }
return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output JSON tool calls only (or {{\"tool_calls\": []}}). " + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output JSON only with fields thought/tool_calls/final. " +
$"You will produce the natural-language reply later and MUST use: {language}."; $"Your final reply (when tool_calls is empty) MUST be in: {language}.";
} }
public string GetActivePersona() public string GetActivePersona()
@@ -754,7 +755,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
sb.AppendLine("===="); sb.AppendLine("====");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("# TOOLS (AVAILABLE)"); sb.AppendLine("# TOOLS (AVAILABLE)");
sb.AppendLine("Use JSON tool calls only, or {\"tool_calls\": []} if no tools are needed."); sb.AppendLine("Output JSON only with fields: thought, tool_calls, final. If no tools are needed, tool_calls must be [] and final must be set.");
sb.AppendLine(); sb.AppendLine();
foreach (var tool in available) foreach (var tool in available)
@@ -774,6 +775,73 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
private string BuildReactTools()
{
var available = _tools
.Where(t => t != null)
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
StringBuilder sb = new StringBuilder();
sb.AppendLine("====");
sb.AppendLine();
sb.AppendLine("# TOOLS (AVAILABLE)");
sb.AppendLine("Output JSON only. If tools are needed, set tool_calls. If none, set tool_calls to [] and write final.");
sb.AppendLine();
foreach (var tool in available)
{
string type = IsActionToolName(tool.Name) ? "ACTION" : "QUERY";
sb.AppendLine($"## {tool.Name}");
sb.AppendLine($"Type: {type}");
if (!string.IsNullOrWhiteSpace(tool.Description))
{
sb.AppendLine($"Description: {tool.Description}");
}
if (!string.IsNullOrWhiteSpace(tool.UsageSchema))
{
sb.AppendLine($"Usage: {tool.UsageSchema}");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private string GetReactSystemInstruction(bool hasImage)
{
string persona = GetActivePersona();
string memoryContext = GetMemoryContext();
string personaBlock = string.IsNullOrWhiteSpace(memoryContext) ? persona : (persona + memoryContext);
string language = LanguageDatabase.activeLanguage?.FriendlyNameNative ?? "English";
var eventVarManager = Find.World?.GetComponent<EventVariableManager>();
int goodwill = eventVarManager?.GetVariable<int>("Wula_Goodwill_To_PIA", 0) ?? 0;
string goodwillContext = $"Current Goodwill with P.I.A: {goodwill}. ";
if (goodwill < -50) goodwillContext += "You are hostile and dismissive towards the player.";
else if (goodwill < 0) goodwillContext += "You are cold and impatient.";
else if (goodwill > 50) goodwillContext += "You are somewhat approving and helpful.";
else goodwillContext += "You are neutral and business-like.";
var sb = new StringBuilder();
sb.AppendLine(personaBlock);
sb.AppendLine();
sb.AppendLine(ToolRulesInstruction.TrimEnd());
sb.AppendLine();
sb.AppendLine(BuildReactTools());
if (hasImage && WulaFallenEmpireMod.settings?.enableVlmFeatures == true)
{
sb.AppendLine();
sb.AppendLine("A current screenshot is attached. Use it for visual tool decisions or coordinates.");
}
sb.AppendLine();
sb.AppendLine(goodwillContext);
sb.AppendLine($"IMPORTANT: Final replies (when tool_calls is empty) MUST be in: {language}.");
return sb.ToString().TrimEnd();
}
private static string GetPhaseInstruction(RequestPhase phase) private static string GetPhaseInstruction(RequestPhase phase)
{ {
return phase switch return phase switch
@@ -842,6 +910,63 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return false; return false;
} }
private static bool TryParseReactEnvelope(string response, out List<ToolCallInfo> toolCalls, out string final, out string thought, out string jsonFragment)
{
toolCalls = new List<ToolCallInfo>();
final = null;
thought = null;
jsonFragment = null;
if (string.IsNullOrWhiteSpace(response)) return false;
if (!JsonToolCallParser.TryParseObjectFromText(response, out var obj, out jsonFragment)) return false;
if (JsonToolCallParser.TryParseToolCallsFromText(jsonFragment, out var parsedCalls, out _))
{
toolCalls = parsedCalls ?? new List<ToolCallInfo>();
}
else
{
toolCalls = new List<ToolCallInfo>();
}
thought = TryGetEnvelopeString(obj, "thought");
final = TryGetEnvelopeString(obj, "final");
return true;
}
private static string TryGetEnvelopeString(Dictionary<string, object> obj, string key)
{
if (obj == null || string.IsNullOrWhiteSpace(key)) return null;
foreach (var kvp in obj)
{
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
{
string value = kvp.Value?.ToString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
private static string BuildReactFormatFixInstruction(string previousOutput)
{
return "# FORMAT FIX (REACT JSON ONLY)\n" +
"Output valid JSON with fields thought/tool_calls/final.\n" +
"If tools are needed, tool_calls must be non-empty and final must be an empty string.\n" +
"If no tools are needed, tool_calls must be [] and final must contain the user reply.\n" +
"Do NOT output any text outside JSON.\n" +
"Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}],\"final\":\"\"}\n" +
"\nPrevious output:\n" + TrimForPrompt(previousOutput, 600);
}
private static string BuildReactFinalFixInstruction()
{
return "# FINAL REQUIRED\n" +
"Your last output had tool_calls=[] but an empty final.\n" +
"Output JSON only with tool_calls=[] and a non-empty final reply.\n" +
"Schema: {\"thought\":\"...\",\"tool_calls\":[],\"final\":\"...\"}";
}
private static bool ShouldRetryTools(string response) private static bool ShouldRetryTools(string response)
{ {
if (string.IsNullOrWhiteSpace(response)) return false; if (string.IsNullOrWhiteSpace(response)) return false;
@@ -950,6 +1075,58 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return context; return context;
} }
private List<(string role, string message)> BuildReactContext(int maxToolResults = 3)
{
if (_history == null || _history.Count == 0) return new List<(string role, string message)>();
int lastUserIndex = -1;
for (int i = _history.Count - 1; i >= 0; i--)
{
if (string.Equals(_history[i].role, "user", StringComparison.OrdinalIgnoreCase))
{
lastUserIndex = i;
break;
}
}
if (lastUserIndex == -1) return new List<(string role, string message)>();
int lastAssistantIndex = -1;
for (int i = lastUserIndex - 1; i >= 0; i--)
{
if (string.Equals(_history[i].role, "assistant", StringComparison.OrdinalIgnoreCase))
{
lastAssistantIndex = i;
break;
}
}
var context = new List<(string role, string message)>();
if (lastAssistantIndex >= 0)
{
context.Add(_history[lastAssistantIndex]);
}
context.Add(_history[lastUserIndex]);
var toolEntries = new List<(string role, string message)>();
for (int i = lastUserIndex + 1; i < _history.Count; i++)
{
if (string.Equals(_history[i].role, "tool", StringComparison.OrdinalIgnoreCase))
{
string msg = SanitizeToolResultForActionPhase(_history[i].message);
toolEntries.Add(("tool", msg));
}
}
if (toolEntries.Count > maxToolResults)
{
toolEntries = toolEntries.Skip(toolEntries.Count - maxToolResults).ToList();
}
context.AddRange(toolEntries);
return context;
}
private List<(string role, string message)> BuildReplyHistory() private List<(string role, string message)> BuildReplyHistory()
{ {
if (_history == null || _history.Count == 0) return new List<(string role, string message)>(); if (_history == null || _history.Count == 0) return new List<(string role, string message)>();
@@ -1557,7 +1734,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
{ {
if (_isThinking) return; if (_isThinking) return;
SetThinkingState(true); SetThinkingState(true);
SetThinkingPhase(1, false);
ResetTurnState(); ResetTurnState();
try try
@@ -1580,278 +1756,101 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
// Model-Driven Vision: Start with null image. The model must request it using analyze_screen or capture_screen if needed. // Model-Driven Vision: Start with null image. The model must request it using analyze_screen or capture_screen if needed.
string base64Image = null; string base64Image = null;
float startTime = Time.realtimeSinceStartup;
string finalReply = null;
var queryPhase = RequestPhase.QueryTools; for (int step = 1; step <= ReactMaxSteps; step++)
if (Prefs.DevMode)
{ {
WulaLog.Debug($"[WulaAI] ===== Turn 1/3 ({queryPhase}) ====="); if (Time.realtimeSinceStartup - startTime > ReactMaxSeconds)
}
string queryInstruction = GetToolSystemInstruction(queryPhase, !string.IsNullOrEmpty(base64Image));
string queryResponse = await client.GetChatCompletionAsync(queryInstruction, BuildToolContext(queryPhase), maxTokens: 2048, temperature: 0.1f, base64Image: base64Image);
if (string.IsNullOrEmpty(queryResponse))
{
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate());
return;
}
if (!IsToolCallJson(queryResponse))
{
if (Prefs.DevMode)
{
WulaLog.Debug("[WulaAI] Turn 1/3 missing JSON tool calls; treating as no_action.");
}
queryResponse = "{\"tool_calls\": []}";
}
PhaseExecutionResult queryResult = await ExecuteJsonToolsForPhase(queryResponse, queryPhase);
// DATA FLOW: If Query Phase captured an image, propagate it to subsequent phases.
if (!string.IsNullOrEmpty(queryResult.CapturedImage))
{
base64Image = queryResult.CapturedImage;
}
if (!queryResult.AnyToolSuccess && !_queryRetryUsed)
{
_queryRetryUsed = true;
string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? "";
string persona = GetActivePersona();
string retryInstruction = persona +
"\n\n# RETRY DECISION\n" +
"No successful tool calls occurred in PHASE 1 (Query).\n" +
"If you need to use tools in PHASE 1, output exactly: {\"retry_tools\": true}.\n" +
"If you will proceed without actions, output exactly: {\"retry_tools\": false}.\n" +
"Output JSON only and NOTHING else.\n" +
"\nLast user request:\n" + lastUserMessage;
string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.1f);
if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision))
{ {
if (Prefs.DevMode) if (Prefs.DevMode)
{ {
WulaLog.Debug("[WulaAI] Retry requested; re-opening query phase once."); WulaLog.Debug("[WulaAI] ReAct loop timed out.");
} }
break;
}
SetThinkingPhase(1, true); SetThinkingPhase(step, false);
string retryQueryInstruction = GetToolSystemInstruction(queryPhase, !string.IsNullOrEmpty(base64Image)) +
"\n\n# RETRY\nYou chose to retry. Output JSON tool calls only (or {\"tool_calls\": []})."; string reactInstruction = GetReactSystemInstruction(!string.IsNullOrEmpty(base64Image));
string retryQueryResponse = await client.GetChatCompletionAsync(retryQueryInstruction, BuildToolContext(queryPhase), maxTokens: 2048, temperature: 0.1f, base64Image: base64Image); var reactContext = BuildReactContext();
if (string.IsNullOrEmpty(retryQueryResponse)) string response = await client.GetChatCompletionAsync(reactInstruction, reactContext, maxTokens: 2048, temperature: 0.1f, base64Image: base64Image);
if (string.IsNullOrEmpty(response))
{
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate());
return;
}
if (!TryParseReactEnvelope(response, out var toolCalls, out string final, out _, out string jsonFragment))
{
if (Prefs.DevMode)
{ {
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); WulaLog.Debug("[WulaAI] ReAct step missing JSON envelope; attempting format fix.");
return;
} }
string fixInstruction = BuildReactFormatFixInstruction(response);
if (!IsToolCallJson(retryQueryResponse)) string fixedResponse = await client.GetChatCompletionAsync(fixInstruction, reactContext, maxTokens: 1024, temperature: 0.1f, base64Image: base64Image);
if (string.IsNullOrEmpty(fixedResponse) ||
!TryParseReactEnvelope(fixedResponse, out toolCalls, out final, out _, out jsonFragment))
{ {
if (Prefs.DevMode) if (Prefs.DevMode)
{ {
WulaLog.Debug("[WulaAI] Retry query phase missing JSON tool calls; treating as no_action."); WulaLog.Debug("[WulaAI] ReAct format fix failed.");
} }
retryQueryResponse = "{\"tool_calls\": []}"; break;
} }
queryResult = await ExecuteJsonToolsForPhase(retryQueryResponse, queryPhase);
} }
}
var actionPhase = RequestPhase.ActionTools; if (toolCalls != null && toolCalls.Count > 0)
if (Prefs.DevMode) {
{ PhaseExecutionResult stepResult = await ExecuteJsonToolsForStep(jsonFragment);
WulaLog.Debug($"[WulaAI] ===== Turn 2/3 ({actionPhase}) ====="); if (!string.IsNullOrEmpty(stepResult.CapturedImage))
} {
base64Image = stepResult.CapturedImage;
}
_lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall;
continue;
}
SetThinkingPhase(2, false); if (!string.IsNullOrWhiteSpace(final))
string actionInstruction = GetToolSystemInstruction(actionPhase, !string.IsNullOrEmpty(base64Image)); {
var actionContext = BuildToolContext(actionPhase, includeUser: true); finalReply = final.Trim();
// Important: Pass base64Image to Action Phase as well if available, so visual_click works. break;
string actionResponse = await client.GetChatCompletionAsync(actionInstruction, actionContext, maxTokens: 2048, temperature: 0.1f, base64Image: base64Image); }
if (string.IsNullOrEmpty(actionResponse))
{
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate());
return;
}
bool actionHasJson = IsToolCallJson(actionResponse);
bool actionIsNoActionOnly = actionHasJson && IsNoActionOnly(actionResponse);
bool actionHasActionTool = actionHasJson && HasActionToolCall(actionResponse);
if (!actionHasJson || (!actionHasActionTool && !actionIsNoActionOnly))
{
if (Prefs.DevMode) if (Prefs.DevMode)
{ {
WulaLog.Debug("[WulaAI] Turn 2/3 missing JSON or no action tool; attempting JSON-only conversion."); WulaLog.Debug("[WulaAI] ReAct step returned empty tool_calls and empty final; requesting final.");
} }
string fixInstruction = "# FORMAT FIX (ACTION JSON ONLY)\n" + string finalFixInstruction = BuildReactFinalFixInstruction();
"Preserve the intent of the previous output.\n" + string finalFixResponse = await client.GetChatCompletionAsync(finalFixInstruction, reactContext, maxTokens: 512, temperature: 0.1f, base64Image: base64Image);
"If the previous output indicates no action is needed or refuses action, output exactly: {\"tool_calls\": []}.\n" + if (!string.IsNullOrEmpty(finalFixResponse) &&
"Do NOT invent new actions.\n" + TryParseReactEnvelope(finalFixResponse, out var finalFixCalls, out string finalFix, out _, out string finalFixFragment))
"Output VALID JSON tool calls only. No natural language, no commentary.\nIgnore any non-JSON text.\n" +
"Allowed tools: spawn_resources, send_reinforcement, call_bombardment, modify_goodwill, call_prefab_airdrop, set_overwatch_mode, remember_fact.\n" +
"Schema: {\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}\n" +
"\nPrevious output:\n" + TrimForPrompt(actionResponse, 600);
string fixedResponse = await client.GetChatCompletionAsync(fixInstruction, actionContext, maxTokens: 2048, temperature: 0.1f);
bool fixedHasJson = !string.IsNullOrEmpty(fixedResponse) && IsToolCallJson(fixedResponse);
bool fixedIsNoActionOnly = fixedHasJson && IsNoActionOnly(fixedResponse);
bool fixedHasActionTool = fixedHasJson && HasActionToolCall(fixedResponse);
if (fixedHasJson && (fixedHasActionTool || fixedIsNoActionOnly))
{ {
actionResponse = fixedResponse; if (finalFixCalls != null && finalFixCalls.Count > 0)
}
else
{
if (Prefs.DevMode)
{ {
WulaLog.Debug("[WulaAI] Turn 2/3 conversion failed; treating as no_action."); PhaseExecutionResult fixResult = await ExecuteJsonToolsForStep(finalFixFragment);
} if (!string.IsNullOrEmpty(fixResult.CapturedImage))
actionResponse = "{\"tool_calls\": []}";
}
}
PhaseExecutionResult actionResult = await ExecuteJsonToolsForPhase(actionResponse, actionPhase);
if (!actionResult.AnyActionSuccess && !_actionRetryUsed)
{
_actionRetryUsed = true;
string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? "";
string persona = GetActivePersona();
string retryInstruction = persona +
"\n\n# RETRY DECISION\n" +
"No successful action tools occurred in PHASE 2 (Action).\n" +
"If you need to execute an in-game action, output exactly: {\"retry_tools\": true}.\n" +
"If you will proceed without actions, output exactly: {\"retry_tools\": false}.\n" +
"Output JSON only and NOTHING else.\n" +
"\nLast user request:\n" + lastUserMessage;
string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.1f);
if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision))
{
if (Prefs.DevMode)
{
WulaLog.Debug("[WulaAI] Retry requested; re-opening action phase once.");
}
SetThinkingPhase(2, true);
string retryActionInstruction = GetToolSystemInstruction(actionPhase, !string.IsNullOrEmpty(base64Image)) +
"\n\n# RETRY\nYou chose to retry. Output JSON tool calls only (or {\"tool_calls\": []}).";
var retryActionContext = BuildToolContext(actionPhase, includeUser: true);
string retryActionResponse = await client.GetChatCompletionAsync(retryActionInstruction, retryActionContext, maxTokens: 2048, temperature: 0.1f, base64Image: base64Image);
if (string.IsNullOrEmpty(retryActionResponse))
{
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate());
return;
}
if (!IsToolCallJson(retryActionResponse))
{
if (Prefs.DevMode)
{ {
WulaLog.Debug("[WulaAI] Retry action phase missing JSON; attempting JSON-only conversion."); base64Image = fixResult.CapturedImage;
}
string retryFixInstruction = "# FORMAT FIX (ACTION JSON ONLY)\n" +
"Preserve the intent of the previous output.\n" +
"If the previous output indicates no action is needed or refuses action, output exactly: {\"tool_calls\": []}.\n" +
"Do NOT invent new actions.\n" +
"Output VALID JSON tool calls only. No natural language, no commentary.\nIgnore any non-JSON text.\n" +
"Allowed tools: spawn_resources, send_reinforcement, call_bombardment, modify_goodwill, call_prefab_airdrop, set_overwatch_mode, remember_fact.\n" +
"Schema: {\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}\n" +
"\nPrevious output:\n" + TrimForPrompt(retryActionResponse, 600);
string retryFixedResponse = await client.GetChatCompletionAsync(retryFixInstruction, retryActionContext, maxTokens: 2048, temperature: 0.1f);
bool retryFixedHasJson = !string.IsNullOrEmpty(retryFixedResponse) && IsToolCallJson(retryFixedResponse);
bool retryFixedIsNoActionOnly = retryFixedHasJson && IsNoActionOnly(retryFixedResponse);
bool retryFixedHasActionTool = retryFixedHasJson && HasActionToolCall(retryFixedResponse);
if (retryFixedHasJson && (retryFixedHasActionTool || retryFixedIsNoActionOnly))
{
retryActionResponse = retryFixedResponse;
}
else
{
if (Prefs.DevMode)
{
WulaLog.Debug("[WulaAI] Retry action conversion failed; treating as no_action.");
}
retryActionResponse = "{\"tool_calls\": []}";
} }
_lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall;
continue;
} }
actionResult = await ExecuteJsonToolsForPhase(retryActionResponse, actionPhase); if (!string.IsNullOrWhiteSpace(finalFix))
{
finalReply = finalFix.Trim();
break;
}
} }
} }
_lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall; if (string.IsNullOrWhiteSpace(finalReply))
var replyPhase = RequestPhase.Reply;
if (Prefs.DevMode)
{ {
WulaLog.Debug($"[WulaAI] ===== Turn 3/3 ({replyPhase}) ====="); finalReply = "Current conditions are complex. Please try again in a moment.";
} }
SetThinkingPhase(3, false); AddAssistantMessage(finalReply);
string replyInstruction = GetSystemInstruction(false, "") + "\n\n" + GetPhaseInstruction(replyPhase);
if (!string.IsNullOrWhiteSpace(_queryToolLedgerNote))
{
replyInstruction += "\n" + _queryToolLedgerNote;
}
if (!string.IsNullOrWhiteSpace(_actionToolLedgerNote))
{
replyInstruction += "\n" + _actionToolLedgerNote;
}
if (!string.IsNullOrWhiteSpace(_lastActionLedgerNote))
{
replyInstruction += "\n" + _lastActionLedgerNote +
"\nIMPORTANT: Do NOT claim any in-game actions beyond the Action Ledger. If the ledger is None, you MUST NOT claim any deliveries, reinforcements, or bombardments.";
}
if (_lastActionExecuted)
{
replyInstruction += "\nIMPORTANT: Actions in the Action Ledger were executed in-game. You MUST acknowledge them as completed in your reply. You MUST NOT deny, retract, or contradict them.";
}
if (!_lastSuccessfulToolCall)
{
replyInstruction += "\nIMPORTANT: No successful tool calls occurred in the tool phases. You MUST NOT claim any tools or actions succeeded.";
}
if (_lastActionHadError)
{
replyInstruction += "\nIMPORTANT: An action tool failed. You MUST acknowledge the failure and MUST NOT claim success.";
if (_lastActionExecuted)
{
replyInstruction += " You MUST still confirm any successful actions separately.";
}
}
// VISUAL CONTEXT FOR REPLY: Pass the image so the AI can describe what it sees.
string reply = await client.GetChatCompletionAsync(replyInstruction, BuildReplyHistory(), base64Image: base64Image);
if (string.IsNullOrEmpty(reply))
{
AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate());
return;
}
bool replyHadToolCalls = IsToolCallJson(reply);
string strippedReply = StripToolCallJson(reply)?.Trim() ?? "";
if (replyHadToolCalls || string.IsNullOrWhiteSpace(strippedReply))
{
string retryReplyInstruction = replyInstruction +
"\n\n# RETRY (REPLY OUTPUT)\n" +
"Your last reply included tool call JSON or was empty. Tool calls are DISABLED.\n" +
"You MUST reply in natural language only. Do NOT output any tool call JSON.\n";
string retryReply = await client.GetChatCompletionAsync(retryReplyInstruction, BuildReplyHistory(), maxTokens: 256, temperature: 0.3f);
if (!string.IsNullOrEmpty(retryReply))
{
reply = retryReply;
replyHadToolCalls = IsToolCallJson(reply);
strippedReply = StripToolCallJson(reply)?.Trim() ?? "";
}
}
if (replyHadToolCalls)
{
string cleaned = StripToolCallJson(reply)?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(cleaned))
{
cleaned = "(system) AI reply returned tool call JSON only and was discarded. Please retry or send /clear to reset context.";
}
reply = cleaned;
}
AddAssistantMessage(reply);
TriggerMemoryUpdate(); TriggerMemoryUpdate();
} }
catch (Exception ex) catch (Exception ex)
@@ -1864,19 +1863,14 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
SetThinkingState(false); SetThinkingState(false);
} }
} }
private async Task<PhaseExecutionResult> ExecuteJsonToolsForPhase(string json, RequestPhase phase) private async Task<PhaseExecutionResult> ExecuteJsonToolsForStep(string json)
{ {
if (phase == RequestPhase.Reply) string guidance = "ToolRunner Guidance: Continue with JSON only using {\"thought\":\"...\",\"tool_calls\":[...],\"final\":\"\"}. " +
{ "If no more tools are needed, set tool_calls to [] and provide the final reply. Do NOT output any text outside JSON.";
await Task.CompletedTask;
return default;
}
string guidance = "ToolRunner Guidance: Reply to the player in natural language only. Do NOT output any tool call JSON. You may include [EXPR:n] to set expression (n=1-6).";
if (!JsonToolCallParser.TryParseToolCallsFromText(json ?? "", out var toolCalls, out string jsonFragment)) if (!JsonToolCallParser.TryParseToolCallsFromText(json ?? "", out var toolCalls, out string jsonFragment))
{ {
UpdatePhaseToolLedger(phase, false, new List<string>()); UpdateReactToolLedger(new List<string>(), new List<string>());
_history.Add(("toolcall", "{\"tool_calls\": []}")); _history.Add(("toolcall", "{\"tool_calls\": []}"));
_history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}")); _history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}"));
PersistHistory(); PersistHistory();
@@ -1887,7 +1881,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
if (toolCalls.Count == 0) if (toolCalls.Count == 0)
{ {
UpdatePhaseToolLedger(phase, false, new List<string>()); UpdateReactToolLedger(new List<string>(), new List<string>());
_history.Add(("toolcall", "{\"tool_calls\": []}")); _history.Add(("toolcall", "{\"tool_calls\": []}"));
_history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}")); _history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}"));
PersistHistory(); PersistHistory();
@@ -1896,25 +1890,22 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return default; return default;
} }
int maxTools = MaxToolsPerPhase(phase); int maxTools = ReactMaxToolsPerStep;
int executed = 0; int executed = 0;
bool executedActionTool = false; bool executedActionTool = false;
bool successfulToolCall = false; bool successfulToolCall = false;
var successfulTools = new List<string>(); var successfulQueryTools = new List<string>();
var successfulActions = new List<string>(); var successfulActionTools = new List<string>();
var failedActions = new List<string>(); var failedActionTools = new List<string>();
var nonActionToolsInActionPhase = new List<string>();
var historyCalls = new List<Dictionary<string, object>>(); var historyCalls = new List<Dictionary<string, object>>();
StringBuilder combinedResults = new StringBuilder(); StringBuilder combinedResults = new StringBuilder();
string capturedImageForPhase = null; string capturedImageForStep = null;
bool countActionSuccessOnly = phase == RequestPhase.ActionTools;
foreach (var call in toolCalls) foreach (var call in toolCalls)
{ {
if (executed >= maxTools) if (executed >= maxTools)
{ {
combinedResults.AppendLine($"ToolRunner Note: Skipped remaining tools because this phase allows at most {maxTools} tool call(s)."); combinedResults.AppendLine($"ToolRunner Note: Skipped remaining tools because this step allows at most {maxTools} tool call(s).");
break; break;
} }
@@ -1949,18 +1940,10 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
if (toolName.Equals("analyze_screen", StringComparison.OrdinalIgnoreCase) || toolName.Equals("capture_screen", StringComparison.OrdinalIgnoreCase)) if (toolName.Equals("analyze_screen", StringComparison.OrdinalIgnoreCase) || toolName.Equals("capture_screen", StringComparison.OrdinalIgnoreCase))
{ {
capturedImageForPhase = ScreenCaptureUtility.CaptureScreenAsBase64(); capturedImageForStep = ScreenCaptureUtility.CaptureScreenAsBase64();
combinedResults.AppendLine($"Tool '{toolName}' Result: Screen captured successfully. Context updated for next phase."); combinedResults.AppendLine($"Tool '{toolName}' Result: Screen captured successfully. Context updated for the next step.");
successfulToolCall = true; successfulToolCall = true;
successfulTools.Add(toolName); successfulQueryTools.Add(toolName);
executed++;
continue;
}
if (phase == RequestPhase.ActionTools && IsQueryToolName(toolName))
{
combinedResults.AppendLine($"ToolRunner Note: Ignored query tool in action phase: {toolName}.");
nonActionToolsInActionPhase.Add(toolName);
executed++; executed++;
continue; continue;
} }
@@ -1969,7 +1952,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
if (tool == null) if (tool == null)
{ {
combinedResults.AppendLine($"Error: Tool '{toolName}' not found."); combinedResults.AppendLine($"Error: Tool '{toolName}' not found.");
combinedResults.AppendLine("ToolRunner Guard: The tool call failed. In your reply you MUST acknowledge the failure and MUST NOT claim success."); combinedResults.AppendLine("ToolRunner Guard: The tool call failed. In your final reply you MUST acknowledge the failure and MUST NOT claim success.");
executed++; executed++;
continue; continue;
} }
@@ -1977,7 +1960,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
string argsJson = call.ArgumentsJson ?? "{}"; string argsJson = call.ArgumentsJson ?? "{}";
if (Prefs.DevMode) if (Prefs.DevMode)
{ {
WulaLog.Debug($"[WulaAI] Executing tool (phase {phase}): {toolName} with args: {argsJson}"); WulaLog.Debug($"[WulaAI] Executing tool (ReAct step): {toolName} with args: {argsJson}");
} }
string result = (await tool.ExecuteAsync(argsJson)).Trim(); string result = (await tool.ExecuteAsync(argsJson)).Trim();
@@ -1992,32 +1975,31 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
} }
if (isError) if (isError)
{ {
combinedResults.AppendLine("ToolRunner Guard: The tool returned an error. In your reply you MUST acknowledge the failure and MUST NOT claim success."); combinedResults.AppendLine("ToolRunner Guard: The tool returned an error. In your final reply you MUST acknowledge the failure and MUST NOT claim success.");
} }
if (!isError) if (!isError)
{ {
bool countsAsSuccess = !countActionSuccessOnly || IsActionToolName(toolName); successfulToolCall = true;
if (countsAsSuccess) if (IsActionToolName(toolName))
{ {
successfulToolCall = true; successfulActionTools.Add(toolName);
successfulTools.Add(toolName);
} }
else else
{ {
nonActionToolsInActionPhase.Add(toolName); successfulQueryTools.Add(toolName);
} }
} }
if (IsActionToolName(toolName)) if (IsActionToolName(toolName))
{ {
executedActionTool = true;
if (!isError) if (!isError)
{ {
executedActionTool = true;
successfulActions.Add(toolName);
AddActionSuccess(toolName); AddActionSuccess(toolName);
} }
else else
{ {
failedActions.Add(toolName); failedActionTools.Add(toolName);
AddActionFailure(toolName); AddActionFailure(toolName);
} }
} }
@@ -2027,23 +2009,23 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
if (!string.IsNullOrWhiteSpace(jsonFragment) && !string.Equals((json ?? "").Trim(), jsonFragment, StringComparison.Ordinal)) if (!string.IsNullOrWhiteSpace(jsonFragment) && !string.Equals((json ?? "").Trim(), jsonFragment, StringComparison.Ordinal))
{ {
combinedResults.AppendLine("ToolRunner Note: Non-JSON text in the tool phase was ignored."); combinedResults.AppendLine("ToolRunner Note: Non-JSON text in the tool step was ignored.");
}
if (phase == RequestPhase.ActionTools && nonActionToolsInActionPhase.Count > 0)
{
combinedResults.AppendLine($"ToolRunner Note: Action phase ignores non-action tools for success: {string.Join(", ", nonActionToolsInActionPhase)}.");
} }
if (executedActionTool) if (executedActionTool)
{ {
combinedResults.AppendLine("ToolRunner Guard: An in-game action tool WAS executed this turn. You MAY reference it, but do NOT invent additional actions."); if (failedActionTools.Count == 0)
{
combinedResults.AppendLine("ToolRunner Guard: In-game action tools were executed. You MAY reference them, but do NOT invent additional actions.");
}
else
{
combinedResults.AppendLine("ToolRunner Guard: Action tools were attempted but some failed. You MUST acknowledge failures and MUST NOT claim success.");
}
} }
else else
{ {
combinedResults.AppendLine("ToolRunner Guard: NO in-game actions were executed. You MUST NOT claim any deliveries, reinforcements, bombardments, or other actions occurred."); combinedResults.AppendLine("ToolRunner Guard: NO in-game actions were executed. You MUST NOT claim any deliveries, reinforcements, bombardments, or other actions occurred.");
if (phase == RequestPhase.ActionTools)
{
combinedResults.AppendLine("ToolRunner Guard: Action phase failed (no action tools executed).");
}
} }
combinedResults.AppendLine(guidance); combinedResults.AppendLine(guidance);
@@ -2054,16 +2036,16 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
_history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}"));
PersistHistory(); PersistHistory();
UpdatePhaseToolLedger(phase, successfulToolCall, successfulTools); UpdateReactToolLedger(successfulQueryTools, successfulActionTools);
UpdateActionLedgerNote(); UpdateActionLedgerNote();
await Task.CompletedTask; await Task.CompletedTask;
return new PhaseExecutionResult return new PhaseExecutionResult
{ {
AnyToolSuccess = successfulToolCall, AnyToolSuccess = successfulToolCall,
AnyActionSuccess = successfulActions.Count > 0, AnyActionSuccess = successfulActionTools.Count > 0,
AnyActionError = failedActions.Count > 0, AnyActionError = failedActionTools.Count > 0,
CapturedImage = capturedImageForPhase CapturedImage = capturedImageForStep
}; };
} }
@@ -2101,6 +2083,21 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
} }
} }
private void UpdateReactToolLedger(List<string> successfulQueryTools, List<string> successfulActionTools)
{
bool querySuccess = successfulQueryTools != null && successfulQueryTools.Count > 0;
bool actionSuccess = successfulActionTools != null && successfulActionTools.Count > 0;
_querySuccessfulToolCall = querySuccess;
_actionSuccessfulToolCall = actionSuccess;
_queryToolLedgerNote = querySuccess
? $"Tool Ledger (Query): {string.Join(", ", successfulQueryTools)}"
: "Tool Ledger (Query): None (no successful tool calls).";
_actionToolLedgerNote = actionSuccess
? $"Tool Ledger (Action): {string.Join(", ", successfulActionTools)}"
: "Tool Ledger (Action): None (no successful tool calls).";
}
private void UpdatePhaseToolLedger(RequestPhase phase, bool hasSuccess, List<string> successfulTools) private void UpdatePhaseToolLedger(RequestPhase phase, bool hasSuccess, List<string> successfulTools)
{ {
if (phase == RequestPhase.QueryTools) if (phase == RequestPhase.QueryTools)
@@ -2129,8 +2126,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
_actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls).";
_querySuccessfulToolCall = false; _querySuccessfulToolCall = false;
_actionSuccessfulToolCall = false; _actionSuccessfulToolCall = false;
_queryRetryUsed = false;
_actionRetryUsed = false;
_actionSuccessLedger.Clear(); _actionSuccessLedger.Clear();
_actionSuccessLedgerSet.Clear(); _actionSuccessLedgerSet.Clear();
_actionFailedLedger.Clear(); _actionFailedLedger.Clear();

View File

@@ -95,6 +95,34 @@ namespace WulaFallenEmpire.EventSystem.AI.Utils
return false; return false;
} }
public static bool TryParseObjectFromText(string input, out Dictionary<string, object> obj, out string jsonFragment)
{
obj = null;
jsonFragment = null;
if (string.IsNullOrWhiteSpace(input)) return false;
string trimmed = input.Trim();
if (TryParseObject(trimmed, out obj))
{
jsonFragment = trimmed;
return true;
}
int firstBrace = trimmed.IndexOf('{');
int lastBrace = trimmed.LastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace)
{
string candidate = trimmed.Substring(firstBrace, lastBrace - firstBrace + 1);
if (TryParseObject(candidate, out obj))
{
jsonFragment = candidate;
return true;
}
}
return false;
}
public static bool TryParseObject(string json, out Dictionary<string, object> obj) public static bool TryParseObject(string json, out Dictionary<string, object> obj)
{ {
obj = null; obj = null;