Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Unit Tests

on:
push:
branches: [main, master]
paths:
- 'Source/**'
- 'Tests/**'
- 'ArchTests/**'
- '.github/workflows/test.yml'
pull_request:
branches: [main, master]
paths:
- 'Source/**'
- 'Tests/**'
- 'ArchTests/**'
- '.github/workflows/test.yml'

jobs:
test:
uses: RimWorld-RimMind-Mod/Workflows/.github/workflows/test.yml@main
secrets: inherit
48 changes: 44 additions & 4 deletions Source/Advisor/AdvisorCooldownCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,56 @@ public static int CalculateDynamicCooldown(
return Math.Max(safeMin, dynamicCooldown);
}

public const float MinActivityCooldownMultiplier = 0.35f;
public const float MaxActivityCooldownMultiplier = 3.5f;

/// <summary>
/// Scales a cooldown by Core's ActivityFrequencyScale (higher scale = shorter cooldown),
/// clamped to [0.35x, 3.5x]. Non-positive or tiny scales leave the cooldown unchanged.
/// </summary>
public static int ApplyActivityFrequencyScale(int cooldownTicks, float activityFrequencyScale)
=> RimMind.Application.Common.Helpers.ActivityCooldownScaler.Scale(cooldownTicks, activityFrequencyScale);

/// <summary>
/// The single effective Advisor cooldown: danger-adapted base cooldown, scaled by the
/// activity frequency, never below the crisis safety floor. Every readiness check (scan,
/// <c>CanRequestAdvice</c>, gizmo/debug display) must derive from this value.
/// </summary>
public static int CalculateEffectiveCooldown(
int baseCooldownTicks,
bool isMapHighDanger,
bool isPawnInCrisis,
bool isPawnInExtremeBreakRisk,
float activityFrequencyScale,
int minCooldownTicks = MinCrisisCooldownTicks)
{
int dynamicCooldown = CalculateDynamicCooldown(
baseCooldownTicks,
isMapHighDanger,
isPawnInCrisis,
isPawnInExtremeBreakRisk,
minCooldownTicks);
int scaled = ApplyActivityFrequencyScale(dynamicCooldown, activityFrequencyScale);
return Math.Max(Math.Max(600, minCooldownTicks), scaled);
}

/// <summary>Ticks left until the next request is allowed (0 when ready).</summary>
public static int CooldownTicksLeft(int effectiveCooldownTicks, int nowTicks, int lastRequestTick)
=> Math.Max(0, effectiveCooldownTicks - (nowTicks - lastRequestTick));

#if !RIMMIND_ADVISOR_TESTS
/// <summary>
/// Evaluates pawn and map state to determine the dynamic cooldown.
/// Evaluates pawn and map state to determine the effective cooldown
/// (including Core's ActivityFrequencyScale).
/// </summary>
public static int CalculateCooldownForPawn(Pawn pawn, RimMindAdvisorSettings settings)
{
if (settings == null)
return 30000;

float activityScale = RimMind.Presentation.Api.RimMindAPI.Settings.ActivityFrequencyScale;
if (pawn == null)
return settings.requestCooldownTicks;
return CalculateEffectiveCooldown(settings.requestCooldownTicks, false, false, false, activityScale);

bool isMapHighDanger = false;
try
Expand Down Expand Up @@ -98,11 +137,12 @@ public static int CalculateCooldownForPawn(Pawn pawn, RimMindAdvisorSettings set
// Defensive fallback
}

return CalculateDynamicCooldown(
return CalculateEffectiveCooldown(
settings.requestCooldownTicks,
isMapHighDanger,
isPawnInCrisis,
isBreakRisk);
isBreakRisk,
activityScale);
}
#endif
}
Expand Down
121 changes: 68 additions & 53 deletions Source/Advisor/AdvisorCycleCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
public int LastRequestTick { get => _lastRequestTick; set => _lastRequestTick = value; }
public AdvisorTaskDriver? TaskDriver => _taskDriver;
public int CooldownTicksLeft =>
Math.Max(0, AdvisorCooldownCalculator.CalculateCooldownForPawn(_pawn, Settings) - (Find.TickManager.TicksGame - _lastRequestTick));
AdvisorCooldownCalculator.CooldownTicksLeft(
AdvisorCooldownCalculator.CalculateCooldownForPawn(_pawn, Settings),
Find.TickManager.TicksGame,
_lastRequestTick);

private RimMindAdvisorSettings Settings => _settings();

Expand Down Expand Up @@ -95,8 +98,8 @@
}

_lastRequestTick = -9999;
RimMindAPI.ClearModCooldown("Advisor");
Log.Message("[RimMind-Advisor] ForceRequest: Core-layer cooldown cleared (Advisor), sending request...");
RimMindAPI.ClearModCooldown(AdvisorRequestIds.RequestModId);

Check warning on line 101 in Source/Advisor/AdvisorCycleCoordinator.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.ClearModCooldown(string)' is obsolete: 'Use RimMindAPI.Request.ClearModCooldown instead.'
Log.Message($"[RimMind-Advisor] ForceRequest: Core-layer cooldown cleared ({AdvisorRequestIds.RequestModId}), sending request...");
RequestAdvice(Settings);
}

Expand Down Expand Up @@ -174,17 +177,25 @@
continue;
}

if (AdvisorToolRiskResolver.IsAutonomyForbidden(_pawn, toolCall.Name))
var preflightArgs = new ToolCallArgs
{
ToolCallId = toolCall.Id ?? string.Empty,
ToolName = toolCall.Name ?? string.Empty,
ArgumentsJson = string.IsNullOrWhiteSpace(toolCall.Arguments) ? "{}" : toolCall.Arguments,
PawnId = _pawn.thingIDNumber
};

if (AdvisorToolRiskResolver.IsAutonomyForbidden(_pawn, toolCall.Name, preflightArgs))
{
var blockedReason = ExtractToolCallReason(toolCall) ?? toolCall.Name;
RecordToolHistory(toolCall.Name, blockedReason, "blocked");
RecordToolHistory(toolCall.Name!, blockedReason, "blocked");
RimMindErrors.Warn(
$"[RimMind-Advisor] Tool '{toolCall.Name}' blocked by autonomy policy for {_pawn.Name.ToStringShort}.");
continue;
}

var riskLevel = AdvisorToolRiskResolver.Resolve(toolCall.Name);
if (ShouldDeferForApproval(toolCall, riskLevel))
var riskLevel = AdvisorToolRiskResolver.Resolve(toolCall.Name, preflightArgs);
if (ShouldDeferForApproval(toolCall, riskLevel, preflightArgs))
{
deferredCalls.Add((toolCall, riskLevel));
}
Expand All @@ -198,11 +209,11 @@

if (deferredCalls.Count > 1)
{
SubmitCabinetProposalForApproval(deferredCalls, taskDriver, requestCycle);
SubmitCabinetProposalForApproval(deferredCalls, taskDriver, requestCycle, response.RequestId);
}
else if (deferredCalls.Count == 1)
{
SubmitToolCallForApproval(deferredCalls[0].toolCall, deferredCalls[0].riskLevel, taskDriver, requestCycle);
SubmitToolCallForApproval(deferredCalls[0].toolCall, deferredCalls[0].riskLevel, taskDriver, requestCycle, response.RequestId);
}

if (approvedCalls.Count > 0)
Expand Down Expand Up @@ -367,9 +378,18 @@
}
}

private bool ShouldDeferForApproval(ClientStructuredToolCall call, RiskLevel riskLevel) =>
private bool ShouldDeferForApproval(ClientStructuredToolCall call, RiskLevel riskLevel, ToolCallArgs? args = null) =>
ShouldDeferForApproval(riskLevel, call.Arguments) ||
AdvisorToolRiskResolver.RequiresAutonomyApproval(_pawn, call.Name ?? string.Empty);
AdvisorToolRiskResolver.RequiresAutonomyApproval(
_pawn,
call.Name ?? string.Empty,
args ?? new ToolCallArgs
{
ToolCallId = call.Id ?? string.Empty,
ToolName = call.Name ?? string.Empty,
ArgumentsJson = string.IsNullOrWhiteSpace(call.Arguments) ? "{}" : call.Arguments,
PawnId = _pawn.thingIDNumber
});

private bool ShouldDeferForApproval(RiskLevel riskLevel, string? arguments) =>
AdvisorApprovalPolicy.RequiresApproval(
Expand All @@ -382,7 +402,8 @@
ClientStructuredToolCall toolCall,
RiskLevel riskLevel,
AdvisorTaskDriver taskDriver,
AdvisorRequestCycleState<ClientStructuredToolCall, ToolResult> requestCycle)
AdvisorRequestCycleState<ClientStructuredToolCall, ToolResult> requestCycle,
string? requestId = null)
{
var reason = ExtractToolCallReason(toolCall) ?? toolCall.Name;
var arguments = ParseToolCallArguments(toolCall.Arguments);
Expand Down Expand Up @@ -426,7 +447,7 @@

var approvedCall = StripPreApproval(toolCall);
var calls = new List<ClientStructuredToolCall> { approvedCall };
var results = ExecuteToolCallsSafely(calls, toolCall.Id, playerApproved: true);
var results = ExecuteToolCallsSafely(calls, requestId ?? toolCall.Id, playerApproved: true);
taskDriver.BroadcastDecisionExecuted(toolCall.Name, reason);
foreach (var result in results)
{
Expand Down Expand Up @@ -492,7 +513,8 @@
private bool SubmitCabinetProposalForApproval(
IReadOnlyList<(ClientStructuredToolCall toolCall, RiskLevel riskLevel)> deferredCalls,
AdvisorTaskDriver taskDriver,
AdvisorRequestCycleState<ClientStructuredToolCall, ToolResult> requestCycle)
AdvisorRequestCycleState<ClientStructuredToolCall, ToolResult> requestCycle,
string? requestId = null)
{
if (deferredCalls == null || deferredCalls.Count == 0) return false;

Expand Down Expand Up @@ -579,7 +601,7 @@
var selectedCalls = indicesToRun.Select(i => callsToExecute[i]).ToList();
var selectedReasons = indicesToRun.Select(i => !string.IsNullOrWhiteSpace(reasonsToRecord[i]) ? reasonsToRecord[i] : "Cabinet Plan").ToList();
var preApprovedCalls = selectedCalls.Select(StripPreApproval).ToList();
var results = ExecuteToolCallsSafely(preApprovedCalls, null, playerApproved: true);
var results = ExecuteToolCallsSafely(preApprovedCalls, requestId, playerApproved: true);
for (int i = 0; i < selectedCalls.Count; i++)
{
var call = selectedCalls[i];
Expand Down Expand Up @@ -702,6 +724,28 @@
return reason;

var readableTool = FormatHumanReadableToolName(toolCall.Name);

try
{
var summaryKeys = RimMindAPI.Tools.FindById(toolCall.Name ?? string.Empty)?.Definition?.Manifest?.SummaryArgKeys;
if (summaryKeys != null)
{
for (int i = 0; i < summaryKeys.Count; i++)
{
var key = summaryKeys[i];
if (!string.IsNullOrWhiteSpace(key) &&
arguments.TryGetValue(key, out var summaryVal) &&
!summaryVal.NullOrEmpty())
{
return $"{readableTool}: {FormatHumanReadableToolName(summaryVal)}";
}
}
}
}
catch
{
}

string[] detailKeys =
{
"building_def", "recipe_def", "zone_type", "work_type",
Expand All @@ -726,44 +770,15 @@
string? traceId,
bool playerApproved = false)
{
try
{
// Approval travels via ToolCallArgs.IsPlayerApproved (trusted channel), never via LLM JSON.
var executionTask = _toolExecutor.ExecuteAsync(
calls,
$"NPC-{_pawn.thingIDNumber}",
traceId,
CancellationToken.None,
playerApproved);

if (!executionTask.IsCompleted)
{
RimMind.Application.Common.Threading.SynchronousTaskGate.ObserveFault(executionTask);
RimMindErrors.Warn(
"[RimMind-Advisor][ToolCall][MainThreadWait] " +
$"Tool execution did not complete synchronously on main thread for {_pawn.Name.ToStringShort} " +
$"(trace={traceId ?? "unknown"}, tools={calls.Count}); failing fast to avoid main-thread freeze.");
return calls
.Select(call => ToolResult.Fail(
RimMind.Application.Common.Threading.SynchronousTaskGate.AsyncNotSupportedMessage,
call.Id,
call.Name))
.ToList();
}

return executionTask.GetAwaiter().GetResult();
}
catch (Exception exception)
{
RimMindErrors.Warn(
$"[RimMind-Advisor] Tool execution failed for {_pawn.Name.ToStringShort}: {exception.Message}");
return calls
.Select(call => ToolResult.Fail(
$"Tool execution failed: {exception.Message}",
call.Id,
call.Name))
.ToList();
}
// Per-call isolation: one failing call must not rewrite the outcome of calls whose side
// effects already ran (that would send false feedback to the LLM).
return AdvisorToolCallBatchRunner.ExecuteOnMainThread(
_toolExecutor,
calls,
$"NPC-{_pawn.thingIDNumber}",
traceId,
playerApproved,
message => RimMindErrors.Warn($"{message} (pawn={_pawn.Name.ToStringShort})"));
}

private static ClientStructuredToolCall StripPreApproval(ClientStructuredToolCall toolCall)
Expand Down
11 changes: 4 additions & 7 deletions Source/Advisor/AdvisorGameComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
base.GameComponentTick();
var settings = RimMindAdvisorMod.Settings;
if (!settings.enableAdvisor) return;
if (!RimMindAPI.IsConfigured()) return;

Check warning on line 51 in Source/Advisor/AdvisorGameComponent.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.IsConfigured()' is obsolete: 'Use RimMindAPI.Settings.IsConfigured instead.'

int now = Find.TickManager.TicksGame;
int interval = settings.pawnScanIntervalTicks;
Expand Down Expand Up @@ -88,14 +88,11 @@
}
if (!shouldTrigger) continue;

float scale = RimMindAPI.Settings.ActivityFrequencyScale;
int ticksGame = Find.TickManager.TicksGame;
int dynamicCooldown = AdvisorCooldownCalculator.CalculateCooldownForPawn(pawn, settings);
float cdMult = scale > 0.01f ? (1.0f / scale) : 1.0f;
dynamicCooldown = UnityEngine.Mathf.RoundToInt(dynamicCooldown * UnityEngine.Mathf.Clamp(cdMult, 0.35f, 3.5f));
int advisorCooldownLeft = dynamicCooldown - (ticksGame - comp.LastRequestTick);
if (advisorCooldownLeft > 0 || !comp.CanRequestAdvice()) continue;
// Single cooldown source: CanRequestAdvice uses AdvisorCooldownCalculator's
// effective (activity-scaled) cooldown, so no second check here.
if (!comp.CanRequestAdvice()) continue;

float scale = RimMindAPI.Settings.ActivityFrequencyScale;
float effectiveChance = UnityEngine.Mathf.Clamp(settings.macroCheckChance * scale, 0.05f, 0.95f);
if (Rand.Value > effectiveChance) continue;

Expand Down
11 changes: 8 additions & 3 deletions Source/Advisor/AdvisorProviderRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ internal static class AdvisorProviderRegistrar

internal static void RegisterAll()
{
// Register the typed event before any key names it, so the trigger resolves
// to a real event (unknown names never fall back to the base bus event).
AdvisorHistoryChangedEvent.Register();

RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef(
"advisor_history", ContextLayer.L4_History, 0.8f,
async (ctx, ct) =>
Expand Down Expand Up @@ -74,7 +78,8 @@ internal static void RegisterAll()
sb.AppendLine($"- {r.action}: {r.reason} → {resultLabel}");
}
return sb.ToString().TrimEnd();
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 3000, invalidationTriggers: new[] { "AdvisorEvent" }));
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 3000,
invalidationTriggers: new[] { AdvisorHistoryChangedEvent.EventName }));

RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef(
"actions_list", ContextLayer.L3_State, 0.85f,
Expand All @@ -85,7 +90,7 @@ internal static void RegisterAll()
var pawn = RimMindPawnLookup.FindPawnByNumber(ctx.PawnId);
var text = BuildToolListText(pawn);
return string.IsNullOrEmpty(text) ? null : text;
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 750, invalidationTriggers: new[] { "AdvisorEvent" }));
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 750)); // follows the tool registry; refreshed by staleness

RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef(
"advisor_task", ContextLayer.L0_Static, 0.95f,
Expand All @@ -98,7 +103,7 @@ internal static void RegisterAll()
.Select(key => (string)key.Translate())
.Where(t => !string.IsNullOrEmpty(t)));
return instruction;
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 0, invalidationTriggers: new[] { "AdvisorEvent" }));
}, RimMindOwnerConsts.AdvisorModId, stalenessTicks: 0));

RegisterPublicProviders();
}
Expand Down
2 changes: 1 addition & 1 deletion Source/Advisor/AdvisorRequestAugmentationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static LlmRequestEnvelope Create(

return LlmRequestEnvelopeBuilder
.ForScenario(RimMindAPI.Context.ScenarioDecision)
.WithModId("RimMind.Advisor")
.WithModId(AdvisorRequestIds.RequestModId)
.WithNpcId(npcId)
.WithSchema(schema)
.WithSystemAugmentations(augmentations)
Expand Down
15 changes: 15 additions & 0 deletions Source/Advisor/AdvisorRequestIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace RimMind.Advisor.Advisor
{
/// <summary>
/// Advisor-owned identity used by Core's request queue.
/// The same id must be passed to <c>LlmRequestEnvelopeBuilder.WithModId</c>, to
/// <c>RimMindAPI.ClearModCooldown</c> / <c>GetModCooldownTicksLeft</c>, and exposed by
/// <c>AdvisorModCooldown</c>: Core stores per-mod cooldowns under the envelope ModId,
/// so any other spelling silently targets a cooldown key that never exists.
/// </summary>
public static class AdvisorRequestIds
{
/// <summary>Envelope ModId and Core queue cooldown key for every Advisor request.</summary>
public const string RequestModId = "RimMind.Advisor";
}
}
2 changes: 1 addition & 1 deletion Source/Advisor/AdvisorTaskDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ public void RequestToolFeedback(

var envelope = LlmRequestEnvelopeBuilder
.ForScenario(RimMindAPI.Context.ScenarioDecision)
.WithModId("RimMind.Advisor")
.WithModId(AdvisorRequestIds.RequestModId)
.WithNpcId(npcId)
.WithSchema(_feedbackSession.Schema)
.WithMessages(messages)
Expand Down
Loading
Loading