This commit is contained in:
2025-12-31 02:48:32 +08:00
parent b64aa60e91
commit f29910ad91
4 changed files with 98 additions and 126 deletions

View File

@@ -98,17 +98,17 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
private const string ToolRulesInstruction = @"
# TOOL USE RULES
1. **FORMATTING**: Your output MUST be valid JSON with fields:
{ ""thought"": ""..."", ""tool_calls"": [ { ""type"": ""function"", ""function"": { ""name"": ""tool_name"", ""arguments"": { ... } } } ], ""final"": ""..."" }
{ ""thought"": ""..."", ""tool_calls"": [ { ""type"": ""function"", ""function"": { ""name"": ""tool_name"", ""arguments"": { ... } } } ] }
2. **STRICT OUTPUT**:
- If ""tool_calls"" is non-empty, ""final"" MUST be an empty string.
- If no tools are needed, ""tool_calls"" MUST be [] and ""final"" MUST contain the user-facing reply.
- If tools are needed, output non-empty ""tool_calls"".
- If no tools are needed, output exactly: { ""tool_calls"": [] } (you may still include ""thought"").
- Do NOT include any natural language, explanation, markdown, or extra text outside JSON.
3. **THOUGHT**: ""thought"" is internal and will NOT be shown to the user.
4. **MULTI-REQUEST RULE**:
- 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.
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 { ""thought"": ""..."", ""tool_calls"": [], ""final"": """" }.
6. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, output { ""tool_calls"": [] }.
7. **NO TAGS**: Do NOT use <think> tags, code fences, or any extra text outside JSON.";
public AIIntelligenceCore(World world) : base(world)
@@ -675,8 +675,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).";
}
return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output JSON only with fields thought/tool_calls/final. " +
$"Your final reply (when tool_calls is empty) MUST be in: {language}.";
return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output JSON tool calls only. " +
$"Final replies are generated later and MUST use: {language}.";
}
public string GetActivePersona()
@@ -758,7 +758,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
sb.AppendLine("====");
sb.AppendLine();
sb.AppendLine("# TOOLS (AVAILABLE)");
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("Output JSON only with tool_calls. If no tools are needed, output exactly: {\"tool_calls\": []}.");
sb.AppendLine();
foreach (var tool in available)
@@ -789,7 +789,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
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("Output JSON only. If tools are needed, set tool_calls. If none, output exactly: {\"tool_calls\": []}.");
sb.AppendLine();
foreach (var tool in available)
@@ -913,63 +913,17 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
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" +
"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" +
"Do NOT output any text outside JSON.\n" +
"Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}],\"final\":\"\"}\n" +
"Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}\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 string NormalizeReactResponse(string response)
{
if (string.IsNullOrWhiteSpace(response)) return response;
@@ -980,20 +934,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return cleaned.Trim();
}
private static bool TryGetNonJsonFinal(string response, out string final)
{
final = null;
if (string.IsNullOrWhiteSpace(response)) return false;
string cleaned = NormalizeReactResponse(response);
cleaned = StripToolCallJson(cleaned) ?? cleaned;
cleaned = cleaned.Trim();
if (string.IsNullOrWhiteSpace(cleaned)) return false;
final = cleaned;
return true;
}
private bool IsToolAvailable(string toolName)
{
if (string.IsNullOrWhiteSpace(toolName)) return false;
@@ -1007,9 +947,9 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
return "# TOOL CORRECTION (REACT JSON ONLY)\n" +
"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 tool_calls=[] and provide final.\n" +
"If no tools are needed, output exactly: {\"tool_calls\": []}.\n" +
"Do NOT output any text outside JSON.\n" +
"Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}],\"final\":\"\"}";
"Schema: {\"thought\":\"...\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":{...}}}]}";
}
private static bool ShouldRetryTools(string response)
@@ -1811,8 +1751,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
maxSeconds = Mathf.Clamp(settings.reactMaxSeconds, 2f, 60f);
}
_thinkingPhaseTotal = maxSteps;
string finalReply = null;
for (int step = 1; step <= maxSteps; step++)
{
if (Time.realtimeSinceStartup - startTime > maxSeconds)
@@ -1836,7 +1774,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
}
string normalizedResponse = NormalizeReactResponse(response);
if (!TryParseReactEnvelope(normalizedResponse, out var toolCalls, out string final, out _, out string jsonFragment))
if (!JsonToolCallParser.TryParseToolCallsFromText(normalizedResponse, out var toolCalls, out string jsonFragment))
{
if (Prefs.DevMode)
{
@@ -1846,17 +1784,12 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
string fixedResponse = await client.GetChatCompletionAsync(fixInstruction, reactContext, maxTokens: 1024, temperature: 0.1f, base64Image: base64Image);
string normalizedFixed = NormalizeReactResponse(fixedResponse);
if (string.IsNullOrEmpty(normalizedFixed) ||
!TryParseReactEnvelope(normalizedFixed, out toolCalls, out final, out _, out jsonFragment))
!JsonToolCallParser.TryParseToolCallsFromText(normalizedFixed, out toolCalls, out jsonFragment))
{
if (Prefs.DevMode)
{
WulaLog.Debug("[WulaAI] ReAct format fix failed.");
}
if (TryGetNonJsonFinal(response, out string fallbackFinal))
{
finalReply = fallbackFinal;
break;
}
break;
}
}
@@ -1876,8 +1809,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
string correctionInstruction = BuildReactToolCorrectionInstruction(invalidTools);
string correctedResponse = await client.GetChatCompletionAsync(correctionInstruction, reactContext, maxTokens: 1024, temperature: 0.1f, base64Image: base64Image);
string normalizedCorrected = NormalizeReactResponse(correctedResponse);
if (!string.IsNullOrEmpty(normalizedCorrected) &&
TryParseReactEnvelope(normalizedCorrected, out toolCalls, out final, out _, out jsonFragment))
if (!string.IsNullOrEmpty(normalizedCorrected) &&
JsonToolCallParser.TryParseToolCallsFromText(normalizedCorrected, out toolCalls, out jsonFragment))
{
invalidTools = toolCalls
.Where(c => !IsToolAvailable(c.Name))
@@ -1896,7 +1829,10 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
{
WulaLog.Debug("[WulaAI] Invalid tools remain after correction; skipping tool execution.");
}
continue;
_history.Add(("toolcall", "{\"tool_calls\": []}"));
_history.Add(("tool", $"[Tool Results]\nToolRunner Error: Invalid tool(s): {string.Join(", ", invalidTools)}.\nToolRunner Guidance: Re-issue valid tool calls only."));
PersistHistory();
break;
}
PhaseExecutionResult stepResult = await ExecuteJsonToolsForStep(jsonFragment);
if (!string.IsNullOrEmpty(stepResult.CapturedImage))
@@ -1907,46 +1843,77 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
continue;
}
if (!string.IsNullOrWhiteSpace(final))
{
finalReply = final.Trim();
break;
}
if (Prefs.DevMode)
{
WulaLog.Debug("[WulaAI] ReAct step returned empty tool_calls and empty final; requesting final.");
}
string finalFixInstruction = BuildReactFinalFixInstruction();
string finalFixResponse = await client.GetChatCompletionAsync(finalFixInstruction, reactContext, maxTokens: 512, temperature: 0.1f, base64Image: base64Image);
if (!string.IsNullOrEmpty(finalFixResponse) &&
TryParseReactEnvelope(finalFixResponse, out var finalFixCalls, out string finalFix, out _, out string finalFixFragment))
{
if (finalFixCalls != null && finalFixCalls.Count > 0)
{
PhaseExecutionResult fixResult = await ExecuteJsonToolsForStep(finalFixFragment);
if (!string.IsNullOrEmpty(fixResult.CapturedImage))
{
base64Image = fixResult.CapturedImage;
}
_lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall;
continue;
}
if (!string.IsNullOrWhiteSpace(finalFix))
{
finalReply = finalFix.Trim();
break;
}
}
// No tool calls requested: exit tool loop and generate natural-language reply separately.
break;
}
_lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall;
if (string.IsNullOrWhiteSpace(finalReply))
string replyInstruction = GetSystemInstruction(false, "");
if (!string.IsNullOrWhiteSpace(_queryToolLedgerNote))
{
finalReply = "Current conditions are complex. Please try again in a moment.";
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 phase. 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.";
}
}
AddAssistantMessage(finalReply);
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();
}
catch (Exception ex)
@@ -1961,8 +1928,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
}
private async Task<PhaseExecutionResult> ExecuteJsonToolsForStep(string json)
{
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.";
string guidance = "ToolRunner Guidance: Continue with JSON only using {\"thought\":\"...\",\"tool_calls\":[...]}. " +
"If no more tools are needed, output exactly: {\"tool_calls\": []}. Do NOT output any text outside JSON.";
if (!JsonToolCallParser.TryParseToolCallsFromText(json ?? "", out var toolCalls, out string jsonFragment))
{

View File

@@ -100,11 +100,14 @@ namespace WulaFallenEmpire
listingStandard.Label("<color=cyan>ReAct Loop Settings</color>");
listingStandard.Label("Max Steps (1-10):");
Rect stepsRect = listingStandard.GetRect(Text.LineHeight);
Widgets.TextFieldNumeric(stepsRect, ref settings.reactMaxSteps, ref _reactMaxStepsBuffer, 1, 10);
Widgets.TextFieldNumeric(stepsRect, ref settings.reactMaxSteps, ref _reactMaxStepsBuffer, 1, 20);
listingStandard.Label("Max Seconds (2-60):");
Rect secondsRect = listingStandard.GetRect(Text.LineHeight);
Widgets.TextFieldNumeric(secondsRect, ref settings.reactMaxSeconds, ref _reactMaxSecondsBuffer, 2f, 60f);
Widgets.TextFieldNumeric(secondsRect, ref settings.reactMaxSeconds, ref _reactMaxSecondsBuffer, 10f, 600f);
listingStandard.GapLine();
listingStandard.CheckboxLabeled("显示ReAct思考折叠框", ref settings.showReactTraceInUI, "在对话窗口中显示思考/工具调用折叠面板。");
listingStandard.GapLine();
listingStandard.CheckboxLabeled("Wula_AISettings_AutoCommentary".Translate(), ref settings.enableAIAutoCommentary, "Wula_AISettings_AutoCommentaryDesc".Translate());

View File

@@ -25,6 +25,7 @@ namespace WulaFallenEmpire
public string extraPersonalityPrompt = "";
public int reactMaxSteps = 4;
public float reactMaxSeconds = 60f;
public bool showReactTraceInUI = false;
public override void ExposeData()
{
@@ -47,7 +48,8 @@ namespace WulaFallenEmpire
Scribe_Values.Look(ref commentOnNegativeOnly, "commentOnNegativeOnly", false);
Scribe_Values.Look(ref extraPersonalityPrompt, "extraPersonalityPrompt", "");
Scribe_Values.Look(ref reactMaxSteps, "reactMaxSteps", 4);
Scribe_Values.Look(ref reactMaxSeconds, "reactMaxSeconds", 12f);
Scribe_Values.Look(ref reactMaxSeconds, "reactMaxSeconds", 60f);
Scribe_Values.Look(ref showReactTraceInUI, "showReactTraceInUI", false);
base.ExposeData();
}