From eafd6ce4f9e3da29558bbc21f495fab3e73647c4 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 7 Nov 2026 04:31:43 +0100 Subject: [PATCH] feat(advisor): manifest-driven risk triage, tool call batch isolation, and CI test workflow fix(advisor): unify cooldown source, queue identity, and per-call tool isolation - Derive every readiness check from a single activity-scaled effective cooldown (AdvisorCooldownCalculator.CalculateEffectiveCooldown), removing the duplicated scaling in AdvisorGameComponent and applying the same scaling to the Core queue cooldown. - Introduce AdvisorRequestIds.RequestModId so the envelope ModId, Core cooldown key, and AdvisorModCooldown.Id can no longer diverge. - Resolve tool risk and autonomy domains dynamically via Core's ClassifyPolicy/manifest instead of a hardcoded Actions tool map, so argument-dependent policies (e.g. survival bed placement) are honored. - Isolate each tool call in a batch: a throwing, faulted, or asynchronous handler fails only that call, preserving real results of calls whose side effects already ran. - Register and publish AdvisorHistoryChangedEvent so the advisor_history context key invalidates on history changes. - Add CI workflow and contract tests covering queue identity, tool isolation, and cooldown scaling. --- .github/workflows/test.yml | 22 ++ Source/Advisor/AdvisorCooldownCalculator.cs | 48 ++- Source/Advisor/AdvisorCycleCoordinator.cs | 121 +++---- Source/Advisor/AdvisorGameComponent.cs | 11 +- Source/Advisor/AdvisorProviderRegistrar.cs | 11 +- .../AdvisorRequestAugmentationFactory.cs | 2 +- Source/Advisor/AdvisorRequestIds.cs | 15 + Source/Advisor/AdvisorTaskDriver.cs | 2 +- Source/Advisor/AdvisorToolCallExecutor.cs | 133 +++++++- Source/Advisor/AdvisorToolRiskResolver.cs | 161 ++++++++-- Source/Advisor/ApprovalManager.cs | 12 +- Source/Comps/CompAIAdvisor.cs | 5 +- Source/Data/AdvisorHistoryChangedEvent.cs | 38 +++ Source/Data/AdvisorHistoryStore.cs | 23 ++ Source/Debug/AdvisorBehaviorAutotestSuite.cs | 4 +- Source/Debug/AdvisorDebugActions.cs | 6 +- Source/Extensions/AdvisorModCooldown.cs | 12 +- .../AdvisorActionBoundaryContracts.cs | 29 ++ .../AdvisorQueueAndToolIsolationContracts.cs | 297 ++++++++++++++++++ .../AdvisorToolDomainMapContracts.cs | 230 +++++++++++++- Tests/RimMindAdvisor.Tests.csproj | 2 + Tests/VerseStubs.cs | 87 ++++- 22 files changed, 1139 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Source/Advisor/AdvisorRequestIds.cs create mode 100644 Source/Data/AdvisorHistoryChangedEvent.cs create mode 100644 Tests/Contracts/AdvisorQueueAndToolIsolationContracts.cs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb71105 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/Source/Advisor/AdvisorCooldownCalculator.cs b/Source/Advisor/AdvisorCooldownCalculator.cs index 8b5f10b..2a05fbb 100644 --- a/Source/Advisor/AdvisorCooldownCalculator.cs +++ b/Source/Advisor/AdvisorCooldownCalculator.cs @@ -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; + + /// + /// 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. + /// + public static int ApplyActivityFrequencyScale(int cooldownTicks, float activityFrequencyScale) + => RimMind.Application.Common.Helpers.ActivityCooldownScaler.Scale(cooldownTicks, activityFrequencyScale); + + /// + /// The single effective Advisor cooldown: danger-adapted base cooldown, scaled by the + /// activity frequency, never below the crisis safety floor. Every readiness check (scan, + /// CanRequestAdvice, gizmo/debug display) must derive from this value. + /// + 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); + } + + /// Ticks left until the next request is allowed (0 when ready). + public static int CooldownTicksLeft(int effectiveCooldownTicks, int nowTicks, int lastRequestTick) + => Math.Max(0, effectiveCooldownTicks - (nowTicks - lastRequestTick)); + #if !RIMMIND_ADVISOR_TESTS /// - /// Evaluates pawn and map state to determine the dynamic cooldown. + /// Evaluates pawn and map state to determine the effective cooldown + /// (including Core's ActivityFrequencyScale). /// 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 @@ -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 } diff --git a/Source/Advisor/AdvisorCycleCoordinator.cs b/Source/Advisor/AdvisorCycleCoordinator.cs index 6ed6801..0d4e834 100644 --- a/Source/Advisor/AdvisorCycleCoordinator.cs +++ b/Source/Advisor/AdvisorCycleCoordinator.cs @@ -48,7 +48,10 @@ public AdvisorCycleCoordinator( 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(); @@ -95,8 +98,8 @@ public void ForceRequestAdvice() } _lastRequestTick = -9999; - RimMindAPI.ClearModCooldown("Advisor"); - Log.Message("[RimMind-Advisor] ForceRequest: Core-layer cooldown cleared (Advisor), sending request..."); + RimMindAPI.ClearModCooldown(AdvisorRequestIds.RequestModId); + Log.Message($"[RimMind-Advisor] ForceRequest: Core-layer cooldown cleared ({AdvisorRequestIds.RequestModId}), sending request..."); RequestAdvice(Settings); } @@ -174,17 +177,25 @@ private void OnAdviceReceived( 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)); } @@ -198,11 +209,11 @@ private void OnAdviceReceived( 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) @@ -367,9 +378,18 @@ private void CompleteRequestCycle() } } - 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( @@ -382,7 +402,8 @@ private bool SubmitToolCallForApproval( ClientStructuredToolCall toolCall, RiskLevel riskLevel, AdvisorTaskDriver taskDriver, - AdvisorRequestCycleState requestCycle) + AdvisorRequestCycleState requestCycle, + string? requestId = null) { var reason = ExtractToolCallReason(toolCall) ?? toolCall.Name; var arguments = ParseToolCallArguments(toolCall.Arguments); @@ -426,7 +447,7 @@ private bool SubmitToolCallForApproval( var approvedCall = StripPreApproval(toolCall); var calls = new List { 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) { @@ -492,7 +513,8 @@ private bool SubmitToolCallForApproval( private bool SubmitCabinetProposalForApproval( IReadOnlyList<(ClientStructuredToolCall toolCall, RiskLevel riskLevel)> deferredCalls, AdvisorTaskDriver taskDriver, - AdvisorRequestCycleState requestCycle) + AdvisorRequestCycleState requestCycle, + string? requestId = null) { if (deferredCalls == null || deferredCalls.Count == 0) return false; @@ -579,7 +601,7 @@ private bool SubmitCabinetProposalForApproval( 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]; @@ -702,6 +724,28 @@ private void QueueFeedbackBatch( 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", @@ -726,44 +770,15 @@ private List ExecuteToolCallsSafely( 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) diff --git a/Source/Advisor/AdvisorGameComponent.cs b/Source/Advisor/AdvisorGameComponent.cs index ef4eace..033aa52 100644 --- a/Source/Advisor/AdvisorGameComponent.cs +++ b/Source/Advisor/AdvisorGameComponent.cs @@ -88,14 +88,11 @@ private void EvaluateAllPawns(RimMindAdvisorSettings settings) } 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; diff --git a/Source/Advisor/AdvisorProviderRegistrar.cs b/Source/Advisor/AdvisorProviderRegistrar.cs index e6b9d58..0bcd083 100644 --- a/Source/Advisor/AdvisorProviderRegistrar.cs +++ b/Source/Advisor/AdvisorProviderRegistrar.cs @@ -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) => @@ -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, @@ -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, @@ -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(); } diff --git a/Source/Advisor/AdvisorRequestAugmentationFactory.cs b/Source/Advisor/AdvisorRequestAugmentationFactory.cs index c71cc2e..3d4c291 100644 --- a/Source/Advisor/AdvisorRequestAugmentationFactory.cs +++ b/Source/Advisor/AdvisorRequestAugmentationFactory.cs @@ -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) diff --git a/Source/Advisor/AdvisorRequestIds.cs b/Source/Advisor/AdvisorRequestIds.cs new file mode 100644 index 0000000..90686f1 --- /dev/null +++ b/Source/Advisor/AdvisorRequestIds.cs @@ -0,0 +1,15 @@ +namespace RimMind.Advisor.Advisor +{ + /// + /// Advisor-owned identity used by Core's request queue. + /// The same id must be passed to LlmRequestEnvelopeBuilder.WithModId, to + /// RimMindAPI.ClearModCooldown / GetModCooldownTicksLeft, and exposed by + /// AdvisorModCooldown: Core stores per-mod cooldowns under the envelope ModId, + /// so any other spelling silently targets a cooldown key that never exists. + /// + public static class AdvisorRequestIds + { + /// Envelope ModId and Core queue cooldown key for every Advisor request. + public const string RequestModId = "RimMind.Advisor"; + } +} diff --git a/Source/Advisor/AdvisorTaskDriver.cs b/Source/Advisor/AdvisorTaskDriver.cs index 203825d..36b63de 100644 --- a/Source/Advisor/AdvisorTaskDriver.cs +++ b/Source/Advisor/AdvisorTaskDriver.cs @@ -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) diff --git a/Source/Advisor/AdvisorToolCallExecutor.cs b/Source/Advisor/AdvisorToolCallExecutor.cs index c6cc3c4..99c780d 100644 --- a/Source/Advisor/AdvisorToolCallExecutor.cs +++ b/Source/Advisor/AdvisorToolCallExecutor.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using RimMind.Application.Common.Interfaces.Tools; using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Common.Threading; using RimMind.Domain.Llm; using RimMind.Presentation.Api; @@ -56,13 +57,141 @@ public async Task> ExecuteAsync( IsPlayerApproved = playerApproved }; + results.Add(await ExecuteIsolatedAsync(handler, args, call, ct)); + } + + return results; + } + + /// + /// Runs one handler so that a throwing or faulted handler yields its own failed result + /// instead of aborting the batch; earlier calls may already have mutated the game, so + /// their real outcomes must still reach the feedback batch. Mirrors Core's + /// ToolCallDispatchMiddleware isolation (cancellation still propagates). + /// + private static async Task ExecuteIsolatedAsync( + IToolHandler handler, + ToolCallArgs args, + StructuredToolCall call, + CancellationToken ct) + { + try + { var result = await handler.ExecuteAsync(args, ct); - results.Add(result.Match( + return result.Match( ok => ok with { ToolCallId = call.Id, ToolName = call.Name }, - err => ToolResult.Fail(err.Message, call.Id, call.Name))); + err => ToolResult.Fail(err.Message, call.Id, call.Name)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; } + catch (Exception exception) + { + return ToolResult.Fail( + $"Tool threw {exception.GetType().Name}: {exception.Message}", + call.Id, + call.Name); + } + } + } + /// + /// Main-thread runner for an Advisor tool batch. Each call is dispatched and completed on its + /// own, so a throwing executor, a malformed result or an asynchronous handler only fails that + /// call; calls whose side effects already ran keep their real result in the feedback batch, and + /// later calls never resume on a thread-pool continuation. + /// + internal static class AdvisorToolCallBatchRunner + { + internal static List ExecuteOnMainThread( + IAdvisorToolCallExecutor executor, + IReadOnlyList calls, + string? npcId, + string? traceId, + bool playerApproved, + Action? warn = null) + { + if (executor == null) throw new ArgumentNullException(nameof(executor)); + var results = new List(calls.Count); + foreach (var call in calls) + results.Add(ExecuteOne(executor, call, npcId, traceId, playerApproved, warn)); return results; } + + private static ToolResult ExecuteOne( + IAdvisorToolCallExecutor executor, + StructuredToolCall call, + string? npcId, + string? traceId, + bool playerApproved, + Action? warn) + { + try + { + // Approval travels via ToolCallArgs.IsPlayerApproved (trusted channel), never via LLM JSON. + var executionTask = executor.ExecuteAsync( + new[] { call }, + npcId, + traceId, + CancellationToken.None, + playerApproved); + + if (!executionTask.IsCompleted) + { + SynchronousTaskGate.ObserveFault(executionTask); + warn?.Invoke( + "[RimMind-Advisor][ToolCall][MainThreadWait] " + + $"Tool '{call.Name}' did not complete synchronously on main thread " + + $"(trace={traceId ?? "unknown"}, npc={npcId ?? "none"}); failing fast to avoid main-thread freeze."); + var asyncFail = ToolResult.Fail(SynchronousTaskGate.AsyncNotSupportedMessage, call.Id, call.Name); + RimMindAPI.Tools.RecordToolCallTrace( + traceId, + call.Name ?? string.Empty, + call.Arguments, + false, + asyncFail.Content); + return asyncFail; + } + + var single = executionTask.GetAwaiter().GetResult(); + if (single == null || single.Count != 1 || single[0] == null) + { + var countFail = ToolResult.Fail( + $"Tool executor returned {single?.Count ?? 0} results for one call.", + call.Id, + call.Name); + RimMindAPI.Tools.RecordToolCallTrace( + traceId, + call.Name ?? string.Empty, + call.Arguments, + false, + countFail.Content); + return countFail; + } + + var toolResult = single[0]; + RimMindAPI.Tools.RecordToolCallTrace( + traceId, + toolResult.ToolName ?? call.Name ?? string.Empty, + call.Arguments, + !toolResult.IsError, + toolResult.Content); + return toolResult; + } + catch (Exception exception) + { + warn?.Invoke( + $"[RimMind-Advisor] Tool '{call.Name}' execution failed (npc={npcId ?? "none"}): {exception.Message}"); + var exFail = ToolResult.Fail($"Tool execution failed: {exception.Message}", call.Id, call.Name); + RimMindAPI.Tools.RecordToolCallTrace( + traceId, + call.Name ?? string.Empty, + call.Arguments, + false, + exFail.Content); + return exFail; + } + } } } diff --git a/Source/Advisor/AdvisorToolRiskResolver.cs b/Source/Advisor/AdvisorToolRiskResolver.cs index cc89e30..20b4b27 100644 --- a/Source/Advisor/AdvisorToolRiskResolver.cs +++ b/Source/Advisor/AdvisorToolRiskResolver.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; using RimMind.Domain.Enums; using RimMind.Presentation.Api; @@ -9,29 +11,14 @@ namespace RimMind.Advisor.Advisor { internal static class AdvisorToolRiskResolver { - private static readonly Dictionary ToolAutonomyDomainMap - = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "actions_place_blueprint", AutonomyDomain.BlueprintPlacement }, - { "actions_plan_room_project", AutonomyDomain.BlueprintPlacement }, - { "actions_designate_resources", AutonomyDomain.ResourceMarking }, - { "actions_manage_zone", AutonomyDomain.ResourceMarking }, - { "actions_add_worktable_bill", AutonomyDomain.WorktableBills }, - { "actions_manage_equipment", AutonomyDomain.EmergencySurvival }, - { "actions_emergency_flee", AutonomyDomain.EmergencySurvival }, - { "actions_stabilize_rest", AutonomyDomain.EmergencySurvival }, - { "actions_eat_and_recreation", AutonomyDomain.EmergencySurvival }, - { "actions_triage_patient", AutonomyDomain.EmergencySurvival }, - }; - - public static RiskLevel Resolve(string toolName) + public static RiskLevel Resolve(string? toolName, ToolCallArgs? args = null) { if (string.IsNullOrWhiteSpace(toolName)) { return RiskLevel.Low; } - if (TryParseToolName(toolName, out var mechanismId, out var operation)) + if (TryParseToolName(toolName!, out var mechanismId, out var operation)) { try { @@ -46,14 +33,37 @@ public static RiskLevel Resolve(string toolName) } } - if (TryGetAutonomyDomain(toolName, out var domain)) + try + { + var policy = RimMindAPI.Tools.ClassifyPolicy(toolName, args); + if (policy.HasValue) + { + if (policy.Value.RequiresApproval) + { + return RiskLevel.Critical; + } + if (policy.Value.Domain.HasValue) + { + return MapAutonomyDomainRisk(policy.Value.Domain.Value); + } + if (policy.Value.Risk != RiskLevel.Medium) + { + return policy.Value.Risk; + } + } + } + catch + { + } + + if (TryGetAutonomyDomain(toolName, args, out var domain)) { return MapAutonomyDomainRisk(domain); } try { - var manifest = RimMindAPI.Tools.FindById(toolName)?.Definition?.Manifest; + var manifest = RimMindAPI.Tools.FindById(toolName!)?.Definition?.Manifest; if (manifest != null && (manifest.RequiresApproval || manifest.RiskLevel != RiskLevel.Medium)) { if (manifest.RequiresApproval) @@ -70,31 +80,107 @@ public static RiskLevel Resolve(string toolName) return RiskLevel.Low; } - public static bool TryGetAutonomyDomain(string? toolName, out AutonomyDomain domain) + public static RiskLevel ResolveRiskLevel(string? toolName, ToolCallArgs? args = null) + => Resolve(toolName, args); + + public static AutonomyDomain? ResolveAutonomyDomain(string? toolName, ToolCallArgs? args = null) { if (string.IsNullOrWhiteSpace(toolName)) { - domain = default; - return false; + return null; } - // Tool IDs may arrive dotted ("actions.emergency_flee", registry form) or underscored - // ("actions_emergency_flee", the OpenAI function-name form). Normalize like ToolRegistry does. - return ToolAutonomyDomainMap.TryGetValue(NormalizeToolId(toolName!), out domain); + try + { + var policy = RimMindAPI.Tools.ClassifyPolicy(toolName!, args); + if (policy.HasValue) + { + if (policy.Value.Domain.HasValue) + { + return policy.Value.Domain.Value; + } + if (!string.IsNullOrWhiteSpace(policy.Value.DomainId) && + Enum.TryParse(policy.Value.DomainId, ignoreCase: true, out var parsedDomain)) + { + return parsedDomain; + } + } + } + catch + { + } + + try + { + var manifest = RimMindAPI.Tools.FindById(toolName!)?.Definition?.Manifest; + if (manifest != null) + { + if (manifest.DefaultDomain.HasValue) + { + return manifest.DefaultDomain.Value; + } + if (!string.IsNullOrWhiteSpace(manifest.AutonomyDomainId) && + Enum.TryParse(manifest.AutonomyDomainId, ignoreCase: true, out var manifestDomain)) + { + return manifestDomain; + } + } + } + catch + { + } + + if (TryParseToolName(toolName!, out var mechanismId, out var operation) && + operation != MechanismOperationType.Query && + operation != MechanismOperationType.List && + operation != MechanismOperationType.Watch) + { + if (mechanismId.StartsWith("pawn.", StringComparison.OrdinalIgnoreCase) || + string.Equals(mechanismId, "pawn", StringComparison.OrdinalIgnoreCase)) + { + return AutonomyDomain.EmergencySurvival; + } + if (mechanismId.StartsWith("map.", StringComparison.OrdinalIgnoreCase) || + string.Equals(mechanismId, "map", StringComparison.OrdinalIgnoreCase)) + { + return AutonomyDomain.ResourceMarking; + } + if (mechanismId.StartsWith("world.", StringComparison.OrdinalIgnoreCase) || + string.Equals(mechanismId, "world", StringComparison.OrdinalIgnoreCase)) + { + return AutonomyDomain.HighRiskOperations; + } + } + + return null; } - internal static string NormalizeToolId(string toolName) => toolName.Trim().Replace('.', '_'); + public static bool TryGetAutonomyDomain(string? toolName, out AutonomyDomain domain) + => TryGetAutonomyDomain(toolName, null, out domain); + + public static bool TryGetAutonomyDomain(string? toolName, ToolCallArgs? args, out AutonomyDomain domain) + { + var resolved = ResolveAutonomyDomain(toolName, args); + if (resolved.HasValue) + { + domain = resolved.Value; + return true; + } - internal static IEnumerable MappedToolIds => ToolAutonomyDomainMap.Keys; + domain = default; + return false; + } - public static bool RequiresAutonomyApproval(Pawn? pawn, string? toolName) + internal static string NormalizeToolId(string toolName) => toolName.Trim().Replace('.', '_'); + + public static bool RequiresAutonomyApproval(Pawn? pawn, string? toolName, ToolCallArgs? args = null) { if (string.IsNullOrWhiteSpace(toolName)) { return false; } - if (TryGetAutonomyDomain(toolName, out var domain)) + if (TryGetAutonomyDomain(toolName, args, out var domain)) { try { @@ -110,6 +196,12 @@ public static bool RequiresAutonomyApproval(Pawn? pawn, string? toolName) try { + var policy = RimMindAPI.Tools.ClassifyPolicy(toolName!, args); + if (policy.HasValue && policy.Value.RequiresApproval) + { + return true; + } + var manifest = RimMindAPI.Tools.FindById(toolName!)?.Definition?.Manifest; if (manifest != null && manifest.RequiresApproval) { @@ -123,14 +215,17 @@ public static bool RequiresAutonomyApproval(Pawn? pawn, string? toolName) return false; } - public static bool IsAutonomyForbidden(Pawn? pawn, string? toolName) + public static bool RequiresPlayerApproval(Pawn? pawn, string? toolName, ToolCallArgs? args = null) + => RequiresAutonomyApproval(pawn, toolName, args); + + public static bool IsAutonomyForbidden(Pawn? pawn, string? toolName, ToolCallArgs? args = null) { if (string.IsNullOrWhiteSpace(toolName)) { return false; } - if (TryGetAutonomyDomain(toolName, out var domain)) + if (TryGetAutonomyDomain(toolName, args, out var domain)) { try { @@ -145,7 +240,7 @@ public static bool IsAutonomyForbidden(Pawn? pawn, string? toolName) return false; } - public static bool ShouldSkipAction(string? toolName, Pawn? pawn = null) + public static bool ShouldSkipAction(string? toolName, Pawn? pawn = null, ToolCallArgs? args = null) { if (string.IsNullOrWhiteSpace(toolName)) { @@ -157,7 +252,7 @@ public static bool ShouldSkipAction(string? toolName, Pawn? pawn = null) return true; } - if (IsAutonomyForbidden(pawn, toolName)) + if (IsAutonomyForbidden(pawn, toolName, args)) { return true; } diff --git a/Source/Advisor/ApprovalManager.cs b/Source/Advisor/ApprovalManager.cs index 3d638df..0334b16 100644 --- a/Source/Advisor/ApprovalManager.cs +++ b/Source/Advisor/ApprovalManager.cs @@ -307,7 +307,17 @@ internal static string FormatHumanReadableToolName(string? rawName) return "Action"; var trimmed = rawName!.Trim(); - string[] prefixes = { "actions_", "pawn_", "rimmind_" }; + try + { + var displayName = RimMindAPI.Tools.FindById(trimmed)?.Definition?.Manifest?.DisplayName; + if (!string.IsNullOrWhiteSpace(displayName)) + return displayName!; + } + catch + { + } + + string[] prefixes = { "pawn_", "rimmind_" }; foreach (var prefix in prefixes) { if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && diff --git a/Source/Comps/CompAIAdvisor.cs b/Source/Comps/CompAIAdvisor.cs index 9beefd9..5003f8c 100644 --- a/Source/Comps/CompAIAdvisor.cs +++ b/Source/Comps/CompAIAdvisor.cs @@ -36,7 +36,10 @@ private AdvisorCycleCoordinator Cycle public AdvisorTaskDriver? TaskDriver => _cycle?.TaskDriver; public int AdvisorCooldownTicksLeft => _cycle != null ? _cycle.CooldownTicksLeft - : System.Math.Max(0, AdvisorCooldownCalculator.CalculateCooldownForPawn(Pawn, Settings) - ((Find.TickManager?.TicksGame ?? 0) - _lastRequestTick)); + : AdvisorCooldownCalculator.CooldownTicksLeft( + AdvisorCooldownCalculator.CalculateCooldownForPawn(Pawn, Settings), + Find.TickManager?.TicksGame ?? 0, + _lastRequestTick); public bool CanRequestAdvice() => IsEnabled && !HasPendingRequest && IsEligible() && AdvisorCooldownTicksLeft <= 0; diff --git a/Source/Data/AdvisorHistoryChangedEvent.cs b/Source/Data/AdvisorHistoryChangedEvent.cs new file mode 100644 index 0000000..6d27e62 --- /dev/null +++ b/Source/Data/AdvisorHistoryChangedEvent.cs @@ -0,0 +1,38 @@ +using System; +using RimMind.Domain.Events; +using RimMind.Domain.ValueObjects; +using RimMind.Presentation.Api; + +namespace RimMind.Advisor.Data +{ + /// + /// Published on the Core bus whenever a pawn's advisor request history changes. + /// The advisor_history context key names as its invalidation + /// trigger, so the next prompt sees the newest approval/rejection instead of a cached copy. + /// + public sealed class AdvisorHistoryChangedEvent : AgentBusEvent + { + /// Bus event name used by context-key invalidation triggers. + public const string EventName = "AdvisorEvent"; + + public AdvisorHistoryChangedEvent(int pawnId) + { + PawnId = pawnId; + NpcId = pawnId > 0 ? "NPC-" + pawnId : string.Empty; + } + + /// Registers with the Core bus. Call before registering context keys. + internal static void Register() + { + try { RimMindAPI.Bus.RegisterEventType(EventName); } + catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Advisor] Event type registration failed: {ex.Message}"); } + } + + /// Publishes from any thread; never throws. + internal static void Publish(int pawnId) + { + try { RimMindAPI.Bus.PublishOnMainThread(new AdvisorHistoryChangedEvent(pawnId)); } + catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Advisor] History change notification failed: {ex.Message}"); } + } + } +} diff --git a/Source/Data/AdvisorHistoryStore.cs b/Source/Data/AdvisorHistoryStore.cs index 10a1a15..6d26dbe 100644 --- a/Source/Data/AdvisorHistoryStore.cs +++ b/Source/Data/AdvisorHistoryStore.cs @@ -63,6 +63,7 @@ public void AddRecord(Pawn? pawn, AdvisorRequestRecord record) _globalLog.Add(record); if (_globalLog.Count > 200) _globalLog.RemoveRange(0, _globalLog.Count - 200); + AdvisorHistoryChangedEvent.Publish(pawn.thingIDNumber); } public void PruneInactivePawns() @@ -111,4 +112,26 @@ public override void ExposeData() _globalLog = globalLog ?? new List(); } } + +#if RIMMIND_ADVISOR_TESTS + internal sealed class AdvisorHistoryChangedEvent : RimMind.Domain.Events.AgentBusEvent + { + public const string EventName = "AdvisorEvent"; + + public AdvisorHistoryChangedEvent(int pawnId) + { + PawnId = pawnId; + NpcId = pawnId > 0 ? "NPC-" + pawnId : string.Empty; + } + + public static void Register() + { + RimMind.Application.Features.AgentBus.AgentBusImpl.RegisterKnownEventType(EventName, typeof(AdvisorHistoryChangedEvent)); + } + + public static void Publish(int pawnId) + { + } + } +#endif } diff --git a/Source/Debug/AdvisorBehaviorAutotestSuite.cs b/Source/Debug/AdvisorBehaviorAutotestSuite.cs index fb56cf2..15281a3 100644 --- a/Source/Debug/AdvisorBehaviorAutotestSuite.cs +++ b/Source/Debug/AdvisorBehaviorAutotestSuite.cs @@ -141,8 +141,8 @@ public override void RunSuite(IInGameBehaviorSuiteContext context) context.Assert(AdvisorConcurrencyTracker.ActiveCount == startCount, "AdvisorConcurrencyTracker decremented correctly"); // 6. Advisor Mod Cooldown - RimMindAPI.ClearModCooldown("Advisor"); - int cdLeft = RimMindAPI.GetModCooldownTicksLeft("Advisor"); + RimMindAPI.ClearModCooldown(AdvisorRequestIds.RequestModId); + int cdLeft = RimMindAPI.GetModCooldownTicksLeft(AdvisorRequestIds.RequestModId); context.Assert(cdLeft == 0, "Advisor Core-level cooldown cleared successfully"); } } diff --git a/Source/Debug/AdvisorDebugActions.cs b/Source/Debug/AdvisorDebugActions.cs index 8babab9..86c3347 100644 --- a/Source/Debug/AdvisorDebugActions.cs +++ b/Source/Debug/AdvisorDebugActions.cs @@ -51,7 +51,7 @@ private static void ShowAdvisorStateSelected() ? $"Cooling down ({advisorLeft} ticks ~ {advisorLeft / 2500f:F2} game hours)" : "Ready"; - int coreLeft = RimMindAPI.GetModCooldownTicksLeft("Advisor"); + int coreLeft = RimMindAPI.GetModCooldownTicksLeft(AdvisorRequestIds.RequestModId); string coreCooldown = coreLeft > 0 ? $"Cooling down ({coreLeft} ticks ~ {coreLeft / 2500f:F2} game hours)" : "Ready"; @@ -180,7 +180,7 @@ private static void ListAllAdvisorStates() if (comp == null) continue; int advisorLeft = comp.AdvisorCooldownTicksLeft; - int coreLeft = RimMindAPI.GetModCooldownTicksLeft("Advisor"); + int coreLeft = RimMindAPI.GetModCooldownTicksLeft(AdvisorRequestIds.RequestModId); string aState = advisorLeft > 0 ? $"AdvisorCD{advisorLeft}t" : "AdvisorReady"; string cState = coreLeft > 0 ? $"CoreCD{coreLeft}t" : "CoreReady"; @@ -195,7 +195,7 @@ private static void ListAllAdvisorStates() actionType = DebugActionType.Action)] private static void ClearAllCooldowns() { - RimMindAPI.ClearModCooldown("Advisor"); + RimMindAPI.ClearModCooldown(AdvisorRequestIds.RequestModId); Log.Message("[RimMind-Advisor] Advisor Core-layer cooldown cleared. To clear Advisor-layer cooldowns, use Force Request Advice on each colonist."); } diff --git a/Source/Extensions/AdvisorModCooldown.cs b/Source/Extensions/AdvisorModCooldown.cs index 559c098..2ab788e 100644 --- a/Source/Extensions/AdvisorModCooldown.cs +++ b/Source/Extensions/AdvisorModCooldown.cs @@ -1,3 +1,4 @@ +using RimMind.Advisor.Advisor; using RimMind.Advisor.Settings; using RimMind.Application.Common.Interfaces.Extension; @@ -7,8 +8,13 @@ internal sealed class AdvisorModCooldown : IModCooldown { private readonly RimMindAdvisorSettings _settings; public AdvisorModCooldown(RimMindAdvisorSettings settings) { _settings = settings; } - public string Id => "Advisor"; - public string OwnerModId => "RimMind.Advisor"; - public int CooldownTicks => _settings.requestCooldownTicks; + // Must equal the envelope ModId: Core resolves and stores queue cooldowns by that key. + public string Id => AdvisorRequestIds.RequestModId; + public string OwnerModId => AdvisorRequestIds.RequestModId; + // Same activity scaling as the per-pawn cooldown (AdvisorCooldownCalculator), so the + // Core queue gate does not silently re-impose the unscaled interval. + public int CooldownTicks => AdvisorCooldownCalculator.ApplyActivityFrequencyScale( + _settings.requestCooldownTicks, + RimMind.Presentation.Api.RimMindAPI.Settings.ActivityFrequencyScale); } } diff --git a/Tests/Contracts/AdvisorActionBoundaryContracts.cs b/Tests/Contracts/AdvisorActionBoundaryContracts.cs index bbd10b9..2d681f7 100644 --- a/Tests/Contracts/AdvisorActionBoundaryContracts.cs +++ b/Tests/Contracts/AdvisorActionBoundaryContracts.cs @@ -116,6 +116,34 @@ public void Approval_boundary_combines_risk_request_and_terminal_semantics() RimMindAPI.Autonomy.Policy = RimMind.Domain.Agent.Policies.AgentAutonomyPolicy.CreateDefault(); try { + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = "actions_place_blueprint", + Manifest = new ToolManifest + { + DefaultDomain = RimMind.Domain.Agent.Policies.AutonomyDomain.BlueprintPlacement, + RiskLevel = RiskLevel.Medium + } + }); + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = "actions_add_worktable_bill", + Manifest = new ToolManifest + { + DefaultDomain = RimMind.Domain.Agent.Policies.AutonomyDomain.WorktableBills, + RiskLevel = RiskLevel.Medium + } + }); + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = "actions_designate_resources", + Manifest = new ToolManifest + { + DefaultDomain = RimMind.Domain.Agent.Policies.AutonomyDomain.ResourceMarking, + RiskLevel = RiskLevel.Low + } + }); + Assert.Equal(RiskLevel.Medium, AdvisorToolRiskResolver.Resolve("actions_place_blueprint")); Assert.Equal(RiskLevel.Medium, AdvisorToolRiskResolver.Resolve("actions_add_worktable_bill")); Assert.Equal(RiskLevel.Low, AdvisorToolRiskResolver.Resolve("actions_designate_resources")); @@ -131,6 +159,7 @@ public void Approval_boundary_combines_risk_request_and_terminal_semantics() } finally { + RimMindAPI.Tools.Definitions.Clear(); RimMindAPI.Autonomy.Policy = RimMind.Domain.Agent.Policies.AgentAutonomyPolicy.CreateDefault(); } }), diff --git a/Tests/Contracts/AdvisorQueueAndToolIsolationContracts.cs b/Tests/Contracts/AdvisorQueueAndToolIsolationContracts.cs new file mode 100644 index 0000000..4d050de --- /dev/null +++ b/Tests/Contracts/AdvisorQueueAndToolIsolationContracts.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using RimMind.Advisor.Advisor; +using RimMind.Advisor.Settings; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Common.Threading; +using RimMind.Domain.Llm; +using RimMind.Domain.ValueObjects; +using RimMind.Presentation.Api; +using RimMind.Testing; +using Xunit; + +namespace RimMind.Advisor.Tests.Contracts +{ + /// + /// Regression contracts for queue identity (B7), per-call tool isolation (B10) and the single + /// activity-scaled cooldown source (B11). + /// + [Collection("Advisor runtime")] + public sealed class AdvisorQueueAndToolIsolationContracts + { + [Fact] + public void Queue_cooldown_key_is_the_envelope_mod_id() + { + ContractCaseRunner.Run( + ("initial request envelope uses the module-owned request id", () => + { + var envelope = AdvisorRequestAugmentationFactory.Create("NPC-1", null, false, null, null); + Assert.Equal(AdvisorRequestIds.RequestModId, envelope.ModId); + }), + ("registered queue cooldown resolves by exact envelope ModId", () => + { + var envelope = AdvisorRequestAugmentationFactory.Create("NPC-1", null, false, null, null); + var cooldown = new AdvisorModCooldown(new RimMindAdvisorSettings()); + Assert.Equal(envelope.ModId, cooldown.Id); + Assert.Equal(envelope.ModId, cooldown.OwnerModId); + }), + ("source never clears or reads a cooldown with a literal key or diverging ModId", () => + { + var literalCooldownKey = new Regex(@"(ClearModCooldown|GetModCooldownTicksLeft)\(\s*"""); + var literalModId = new Regex(@"WithModId\(\s*""(?[^""]*)"""); + foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar))) + { + string text = File.ReadAllText(file); + Assert.False(literalCooldownKey.IsMatch(text), + $"{Path.GetFileName(file)} passes a literal cooldown key; use AdvisorRequestIds.RequestModId."); + foreach (Match match in literalModId.Matches(text)) + Assert.Equal(AdvisorRequestIds.RequestModId, match.Groups["id"].Value); + } + }), + ("queue cooldown follows the same activity scaling as the pawn cooldown", () => + { + var settings = new RimMindAdvisorSettings { requestCooldownTicks = 6000 }; + var cooldown = new AdvisorModCooldown(settings); + float previous = RimMindAPI.Settings.ActivityFrequencyScale; + try + { + RimMindAPI.Settings.ActivityFrequencyScale = 2f; + Assert.Equal(3000, cooldown.CooldownTicks); + RimMindAPI.Settings.ActivityFrequencyScale = 1f; + Assert.Equal(6000, cooldown.CooldownTicks); + } + finally + { + RimMindAPI.Settings.ActivityFrequencyScale = previous; + } + })); + } + + [Fact] + public Task Throwing_tool_call_fails_alone_and_later_calls_still_run() + { + return ContractCaseRunner.RunAsync( + ("executor isolates a synchronously throwing handler", async () => + { + var (executor, third) = CreateExecutor(new ThrowingHandler("tool.two", faulted: false)); + var results = await executor.ExecuteAsync(ThreeCalls(), "npc", "trace", CancellationToken.None); + + AssertOkFailOk(results); + Assert.Contains("InvalidOperationException", results[1].Content, StringComparison.Ordinal); + Assert.Equal(1, third.Calls); + }), + ("executor isolates a handler returning a faulted task", async () => + { + var (executor, third) = CreateExecutor(new ThrowingHandler("tool.two", faulted: true)); + var results = await executor.ExecuteAsync(ThreeCalls(), "npc", "trace", CancellationToken.None); + + AssertOkFailOk(results); + Assert.Equal(1, third.Calls); + }), + ("cancellation still propagates instead of becoming a tool result", async () => + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var handler = new ThrowingHandler("tool.two", faulted: false, cancel: true); + var executor = new AdvisorToolCallExecutor(id => id == handler.Id ? handler : null); + await Assert.ThrowsAnyAsync(() => executor.ExecuteAsync( + new[] { new StructuredToolCall { Id = "c2", Name = "tool.two" } }, null, null, cts.Token)); + })); + } + + [Fact] + public void Main_thread_batch_runner_keeps_real_results_of_other_calls() + { + ContractCaseRunner.Run( + ("call 2 of 3 throwing in the executor fails only call 2", () => + { + var fake = new ScriptedExecutor(call => call.Name == "tool.two" + ? throw new InvalidOperationException("boom") + : Task.FromResult(new List { ToolResult.Ok("done", call.Id, call.Name) })); + var warnings = new List(); + + var results = AdvisorToolCallBatchRunner.ExecuteOnMainThread( + fake, ThreeCalls(), "NPC-1", "trace", playerApproved: true, warnings.Add); + + AssertOkFailOk(results); + Assert.Contains("boom", results[1].Content, StringComparison.Ordinal); + Assert.Equal(new[] { "tool.one", "tool.two", "tool.three" }, fake.Executed); + Assert.All(fake.PlayerApproved, Assert.True); + Assert.Single(warnings); + }), + ("an asynchronous call fails fast without failing completed neighbours", () => + { + var pending = new TaskCompletionSource>(); + var fake = new ScriptedExecutor(call => call.Name == "tool.two" + ? pending.Task + : Task.FromResult(new List { ToolResult.Ok("done", call.Id, call.Name) })); + + var results = AdvisorToolCallBatchRunner.ExecuteOnMainThread( + fake, ThreeCalls(), "NPC-1", null, playerApproved: false); + + AssertOkFailOk(results); + Assert.Equal(SynchronousTaskGate.AsyncNotSupportedMessage, results[1].Content); + Assert.Equal(3, fake.Executed.Count); + }), + ("a malformed executor result is reported for that call only", () => + { + var fake = new ScriptedExecutor(call => Task.FromResult(call.Name == "tool.two" + ? new List() + : new List { ToolResult.Ok("done", call.Id, call.Name) })); + + AssertOkFailOk(AdvisorToolCallBatchRunner.ExecuteOnMainThread( + fake, ThreeCalls(), null, null, playerApproved: false)); + })); + } + + [Fact] + public void Effective_cooldown_is_the_single_activity_scaled_source() + { + ContractCaseRunner.Run( + ("higher activity frequency shortens the effective cooldown", () => + { + Assert.Equal(15000, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 2f)); + Assert.Equal(30000, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 1f)); + Assert.Equal(60000, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 0.5f)); + }), + ("scaling is clamped and invalid scales are neutral", () => + { + Assert.Equal(10500, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 100f)); + Assert.Equal(105000, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 0.1f)); + Assert.Equal(30000, AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 0f)); + }), + ("crisis safety floor still applies after scaling", () => + { + Assert.Equal( + AdvisorCooldownCalculator.MinCrisisCooldownTicks, + AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, true, false, 3f)); + }), + ("a pawn is ready once the scaled cooldown elapsed even if the base one has not", () => + { + int effective = AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 2f); + Assert.Equal(0, AdvisorCooldownCalculator.CooldownTicksLeft(effective, nowTicks: 116000, lastRequestTick: 100000)); + int unscaled = AdvisorCooldownCalculator.CalculateEffectiveCooldown(30000, false, false, false, 1f); + Assert.Equal(14000, AdvisorCooldownCalculator.CooldownTicksLeft(unscaled, nowTicks: 116000, lastRequestTick: 100000)); + })); + } + + private static StructuredToolCall[] ThreeCalls() => new[] + { + new StructuredToolCall { Id = "c1", Name = "tool.one", Arguments = "{}" }, + new StructuredToolCall { Id = "c2", Name = "tool.two", Arguments = "{}" }, + new StructuredToolCall { Id = "c3", Name = "tool.three", Arguments = "{}" } + }; + + private static void AssertOkFailOk(IReadOnlyList results) + { + Assert.Equal(3, results.Count); + Assert.False(results[0].IsError); + Assert.True(results[1].IsError); + Assert.False(results[2].IsError); + Assert.Equal(new[] { "c1", "c2", "c3" }, results.Select(result => result.ToolCallId)); + Assert.Equal(new[] { "tool.one", "tool.two", "tool.three" }, results.Select(result => result.ToolName)); + } + + private static (AdvisorToolCallExecutor executor, CountingHandler third) CreateExecutor(IToolHandler second) + { + var first = new CountingHandler("tool.one"); + var third = new CountingHandler("tool.three"); + var handlers = new Dictionary + { + [first.Id] = first, + [second.Id] = second, + [third.Id] = third + }; + return (new AdvisorToolCallExecutor(id => handlers.TryGetValue(id, out var handler) ? handler : null), third); + } + + private static string SourceRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && + !Directory.Exists(Path.Combine(directory.FullName, "RimMind-Advisor", "Source"))) + directory = directory.Parent; + + return Path.Combine( + directory?.FullName ?? throw new InvalidOperationException("Repository root not found."), + "RimMind-Advisor", + "Source"); + } + + private sealed class ScriptedExecutor : IAdvisorToolCallExecutor + { + private readonly Func>> _script; + public ScriptedExecutor(Func>> script) => _script = script; + public List Executed { get; } = new(); + public List PlayerApproved { get; } = new(); + + public Task> ExecuteAsync( + IReadOnlyList calls, + string? npcId, + string? traceId, + CancellationToken ct, + bool playerApproved = false) + { + var call = Assert.Single(calls); + Executed.Add(call.Name); + PlayerApproved.Add(playerApproved); + return _script(call); + } + } + + private sealed class CountingHandler : IToolHandler + { + public CountingHandler(string id) + { + Id = id; + Definition = new ToolDefinition { Id = id }; + } + + public string Id { get; } + public string OwnerModId => "RimMind.Advisor.Tests"; + public ToolDefinition Definition { get; } + public int Calls { get; private set; } + + public Task> ExecuteAsync(ToolCallArgs args, CancellationToken ct) + { + Calls++; + return Task.FromResult(Result.Ok(ToolResult.Ok("done"))); + } + } + + private sealed class ThrowingHandler : IToolHandler + { + private readonly bool _faulted; + private readonly bool _cancel; + + public ThrowingHandler(string id, bool faulted, bool cancel = false) + { + Id = id; + _faulted = faulted; + _cancel = cancel; + Definition = new ToolDefinition { Id = id }; + } + + public string Id { get; } + public string OwnerModId => "RimMind.Advisor.Tests"; + public ToolDefinition Definition { get; } + + public Task> ExecuteAsync(ToolCallArgs args, CancellationToken ct) + { + if (_cancel) + ct.ThrowIfCancellationRequested(); + var exception = new InvalidOperationException("handler exploded after partial side effects"); + if (_faulted) + return Task.FromException>(exception); + throw exception; + } + } + } +} diff --git a/Tests/Contracts/AdvisorToolDomainMapContracts.cs b/Tests/Contracts/AdvisorToolDomainMapContracts.cs index e3fff21..33c24be 100644 --- a/Tests/Contracts/AdvisorToolDomainMapContracts.cs +++ b/Tests/Contracts/AdvisorToolDomainMapContracts.cs @@ -2,45 +2,241 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using RimMind.Advisor.Advisor; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Application.Common.Models.Tools; +using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Enums; +using RimMind.Domain.ValueObjects; +using RimMind.Presentation.Api; +using Verse; using Xunit; namespace RimMind.Advisor.Tests.Contracts { + [Collection("Advisor runtime")] public sealed class AdvisorToolDomainMapContracts { private static readonly Regex ToolIdPattern = new Regex("override string Id\\s*=>\\s*\"(?[^\"]+)\"", RegexOptions.Compiled); + private static readonly Regex DefaultDomainPattern = + new Regex("DefaultAutonomyDomain\\s*=>\\s*AutonomyDomain\\.(?[A-Za-z0-9_]+)", RegexOptions.Compiled); [Fact] - public void Every_mutating_Actions_composite_tool_id_resolves_an_autonomy_domain() + public void AdvisorToolRiskResolver_Contains_No_Hardcoded_Actions_ToolIds_Or_DomainMap() + { + var resolverPath = Path.Combine( + FindRepoRoot(), + "RimMind-Advisor", + "Source", + "Advisor", + "AdvisorToolRiskResolver.cs"); + Assert.True(File.Exists(resolverPath), $"Missing {resolverPath}"); + var source = File.ReadAllText(resolverPath); + + Assert.DoesNotContain("ToolAutonomyDomainMap", source, StringComparison.Ordinal); + Assert.DoesNotContain("actions_", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("actions.", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ClassifyPolicy", source, StringComparison.Ordinal); + } + + [Fact] + public void Every_mutating_Actions_composite_tool_id_resolves_an_autonomy_domain_via_manifest() { - // Guards against the dotted-vs-underscored ID drift that silently disabled autonomy checks - // for actions.emergency_flee / stabilize_rest / eat_and_recreation / triage_patient. var actionsDir = Path.Combine(FindRepoRoot(), "RimMind-Actions", "Source", "Actions"); Assert.True(Directory.Exists(actionsDir), $"Actions source not found at {actionsDir}"); - var ids = Directory.GetFiles(actionsDir, "*CompositeTool.cs") - .Select(File.ReadAllText) - .Select(src => ToolIdPattern.Match(src)) - .Where(m => m.Success) - .Select(m => m.Groups["id"].Value) - .Where(id => id.IndexOf("query", StringComparison.OrdinalIgnoreCase) < 0) // read-only tools need no domain - .ToList(); - - Assert.NotEmpty(ids); - var unmapped = ids.Where(id => !AdvisorToolRiskResolver.TryGetAutonomyDomain(id, out _)).ToList(); - Assert.True(unmapped.Count == 0, "Unmapped Actions tool ids: " + string.Join(", ", unmapped)); + RimMindAPI.Tools.Definitions.Clear(); + RimMindAPI.Tools.Handlers.Clear(); + try + { + var files = Directory.GetFiles(actionsDir, "*CompositeTool.cs"); + var mutatingIds = new System.Collections.Generic.List(); + + foreach (var file in files) + { + var src = File.ReadAllText(file); + var idMatch = ToolIdPattern.Match(src); + if (!idMatch.Success) continue; + + var id = idMatch.Groups["id"].Value; + if (id.IndexOf("query", StringComparison.OrdinalIgnoreCase) >= 0) continue; + + var domainMatch = DefaultDomainPattern.Match(src); + Assert.True(domainMatch.Success, $"Composite tool '{id}' must declare DefaultAutonomyDomain in its manifest"); + var domain = (AutonomyDomain)Enum.Parse(typeof(AutonomyDomain), domainMatch.Groups["domain"].Value); + + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = id, + Manifest = new ToolManifest + { + OwnerModId = "RimMindActions", + DefaultDomain = domain, + AutonomyDomainId = domain.ToString() + } + }); + mutatingIds.Add(id); + } + + Assert.NotEmpty(mutatingIds); + var unmapped = mutatingIds + .Where(id => !AdvisorToolRiskResolver.TryGetAutonomyDomain(id, out _)) + .ToList(); + Assert.True(unmapped.Count == 0, "Unmapped Actions tool ids: " + string.Join(", ", unmapped)); + } + finally + { + RimMindAPI.Tools.Definitions.Clear(); + RimMindAPI.Tools.Handlers.Clear(); + } } [Theory] [InlineData("actions.emergency_flee")] [InlineData("actions_emergency_flee")] + [InlineData("emergency_flee")] [InlineData(" actions.triage_patient ")] - public void Dotted_and_underscored_ids_resolve_identically(string toolName) + public void Dotted_underscored_and_alias_ids_resolve_identically_from_manifest(string toolName) { - Assert.True(AdvisorToolRiskResolver.TryGetAutonomyDomain(toolName, out var domain)); - Assert.Equal(RimMind.Domain.Agent.Policies.AutonomyDomain.EmergencySurvival, domain); + RimMindAPI.Tools.Definitions.Clear(); + try + { + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = "actions.emergency_flee", + Manifest = new ToolManifest + { + DefaultDomain = AutonomyDomain.EmergencySurvival, + Aliases = new[] { "emergency_flee", "actions_emergency_flee" } + } + }); + RimMindAPI.Tools.Definitions.Add(new ToolDefinition + { + Id = "actions.triage_patient", + Manifest = new ToolManifest + { + DefaultDomain = AutonomyDomain.EmergencySurvival, + Aliases = new[] { "triage_patient", "actions_triage_patient" } + } + }); + + Assert.True(AdvisorToolRiskResolver.TryGetAutonomyDomain(toolName, out var domain)); + Assert.Equal(AutonomyDomain.EmergencySurvival, domain); + } + finally + { + RimMindAPI.Tools.Definitions.Clear(); + } + } + + [Fact] + public void Dynamic_IToolPolicyClassifier_escalates_survival_bed_and_meal_bill_fixing_B4() + { + var pawn = new Pawn { thingIDNumber = 7 }; + RimMindAPI.Autonomy.Policy = AgentAutonomyPolicy.CreateDefault(); + RimMindAPI.Tools.Handlers.Clear(); + RimMindAPI.Tools.Definitions.Clear(); + try + { + RimMindAPI.Tools.Handlers.Add(new DynamicBlueprintClassifierHandler()); + + // Static manifest lookup (no args) resolves to BlueprintPlacement + Assert.Equal( + AutonomyDomain.BlueprintPlacement, + AdvisorToolRiskResolver.ResolveAutonomyDomain("actions_place_blueprint")); + Assert.Equal( + RiskLevel.Medium, + AdvisorToolRiskResolver.Resolve("actions_place_blueprint")); + + // Survival Bed args escalate to EmergencySurvival (FullAutonomous by default -> no approval required!) + var bedArgs = new ToolCallArgs + { + ToolName = "actions_place_blueprint", + ArgumentsJson = "{\"building_def\":\"Bed\"}", + PawnId = pawn.thingIDNumber + }; + Assert.Equal( + AutonomyDomain.EmergencySurvival, + AdvisorToolRiskResolver.ResolveAutonomyDomain("actions_place_blueprint", bedArgs)); + Assert.Equal( + RiskLevel.Low, + AdvisorToolRiskResolver.Resolve("actions_place_blueprint", bedArgs)); + Assert.False( + AdvisorToolRiskResolver.RequiresAutonomyApproval(pawn, "actions_place_blueprint", bedArgs)); + + // Luxury statue args stay in BlueprintPlacement (RequireApproval by default -> requires approval!) + var statueArgs = new ToolCallArgs + { + ToolName = "actions_place_blueprint", + ArgumentsJson = "{\"building_def\":\"SculptureSmall\"}", + PawnId = pawn.thingIDNumber + }; + Assert.Equal( + AutonomyDomain.BlueprintPlacement, + AdvisorToolRiskResolver.ResolveAutonomyDomain("actions_place_blueprint", statueArgs)); + Assert.Equal( + RiskLevel.Medium, + AdvisorToolRiskResolver.Resolve("actions_place_blueprint", statueArgs)); + Assert.True( + AdvisorToolRiskResolver.RequiresAutonomyApproval(pawn, "actions_place_blueprint", statueArgs)); + } + finally + { + RimMindAPI.Tools.Handlers.Clear(); + RimMindAPI.Tools.Definitions.Clear(); + RimMindAPI.Autonomy.Policy = AgentAutonomyPolicy.CreateDefault(); + } + } + + private sealed class DynamicBlueprintClassifierHandler : IToolHandler, IToolPolicyClassifier + { + public string Id => "actions_place_blueprint"; + public string OwnerModId => "RimMindActions"; + + public ToolDefinition Definition { get; } = new ToolDefinition + { + Id = "actions_place_blueprint", + Manifest = new ToolManifest + { + OwnerModId = "RimMindActions", + DefaultDomain = AutonomyDomain.BlueprintPlacement, + RiskLevel = RiskLevel.Medium, + Aliases = new[] { "place_blueprint", "actions.place_blueprint" } + } + }; + + public ToolPolicyDecision Classify(ToolCallArgs args) + { + string? buildingDef = null; + if (!string.IsNullOrWhiteSpace(args?.ArgumentsJson)) + { + try + { + buildingDef = JObject.Parse(args!.ArgumentsJson)["building_def"]?.ToString(); + } + catch + { + } + } + + bool isSurvival = string.Equals(buildingDef, "Bed", StringComparison.OrdinalIgnoreCase) + || string.Equals(buildingDef, "Campfire", StringComparison.OrdinalIgnoreCase); + var domain = isSurvival ? AutonomyDomain.EmergencySurvival : AutonomyDomain.BlueprintPlacement; + return new ToolPolicyDecision + { + Domain = domain, + DomainId = domain.ToString(), + Risk = isSurvival ? RiskLevel.Low : RiskLevel.Medium, + RequiresApproval = false + }; + } + + public Task> ExecuteAsync(ToolCallArgs args, CancellationToken ct) + => Task.FromResult(Result.Ok(ToolResult.Ok("ok", args.ToolCallId, Id))); } private static string FindRepoRoot() diff --git a/Tests/RimMindAdvisor.Tests.csproj b/Tests/RimMindAdvisor.Tests.csproj index 2143c57..da0b035 100644 --- a/Tests/RimMindAdvisor.Tests.csproj +++ b/Tests/RimMindAdvisor.Tests.csproj @@ -27,6 +27,8 @@ + + diff --git a/Tests/VerseStubs.cs b/Tests/VerseStubs.cs index 98f25b9..2f43a0f 100644 --- a/Tests/VerseStubs.cs +++ b/Tests/VerseStubs.cs @@ -234,6 +234,7 @@ public static class Settings public static RimMind.Application.Common.Interfaces.Context.IContextBuilder? ContextEngine { get; set; } public static RimMind.Application.Common.Interfaces.Context.IContextBuilder? GetContextEngine() => ContextEngine; public static bool DebugLogging => false; + public static float ActivityFrequencyScale { get; set; } = 1f; } public static bool IsConfigured() => true; @@ -279,12 +280,96 @@ public StubToolHandler(RimMind.Application.Common.Models.Tools.ToolDefinition de public static class Tools { public static List Definitions { get; } = new(); + public static List Handlers { get; } = new(); + public static List<(string? RequestId, string ToolName, string? ArgumentsJson, bool Succeeded, string? ResultSummary)> RecordedTraces { get; } = new(); + public static IReadOnlyList GetAllDefinitions() => Definitions; + public static RimMind.Application.Common.Interfaces.Tools.IToolHandler? FindById(string id) { - var def = Definitions.Find(definition => definition.Id == id); + if (string.IsNullOrWhiteSpace(id)) return null; + var trimmed = id.Trim(); + var normalized = trimmed.Replace('.', '_'); + + foreach (var handler in Handlers) + { + if (MatchesToolIdOrAlias(handler.Definition, trimmed, normalized)) + return handler; + } + + var def = Definitions.Find(definition => MatchesToolIdOrAlias(definition, trimmed, normalized)); return def == null ? null : new StubToolHandler(def); } + + private static bool MatchesToolIdOrAlias( + RimMind.Application.Common.Models.Tools.ToolDefinition? def, + string trimmed, + string normalized) + { + if (def == null) return false; + if (string.Equals(def.Id, trimmed, StringComparison.OrdinalIgnoreCase) || + string.Equals(def.Id.Replace('.', '_'), normalized, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var aliases = def.Manifest?.Aliases; + if (aliases != null) + { + for (int i = 0; i < aliases.Count; i++) + { + var alias = aliases[i]; + if (!string.IsNullOrWhiteSpace(alias) && + (string.Equals(alias.Trim(), trimmed, StringComparison.OrdinalIgnoreCase) || + string.Equals(alias.Trim().Replace('.', '_'), normalized, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + } + + return false; + } + + public static RimMind.Application.Common.Interfaces.Tools.ToolPolicyDecision? ClassifyPolicy( + string? toolId, + RimMind.Application.Common.Models.Tools.ToolCallArgs? args = null) + { + if (string.IsNullOrWhiteSpace(toolId)) return null; + var handler = FindById(toolId!); + if (handler == null) return null; + + if (args != null && handler is RimMind.Application.Common.Interfaces.Tools.IToolPolicyClassifier classifier) + { + return classifier.Classify(args); + } + + var manifest = handler.Definition?.Manifest ?? RimMind.Application.Common.Models.Tools.ToolManifest.Default; + return new RimMind.Application.Common.Interfaces.Tools.ToolPolicyDecision + { + Domain = manifest.DefaultDomain, + DomainId = manifest.AutonomyDomainId ?? manifest.DefaultDomain?.ToString(), + Risk = manifest.RiskLevel, + RequiresApproval = manifest.RequiresApproval + }; + } + + public static RimMind.Application.Common.Interfaces.Tools.ToolPolicyDecision? Preflight( + string? toolId, + RimMind.Application.Common.Models.Tools.ToolCallArgs? args = null) + => ClassifyPolicy(toolId, args); + + public static void RecordToolCallTrace( + string? requestId, + string toolName, + string? argumentsJson, + bool succeeded, + string? resultSummary) + { + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(toolName)) + return; + RecordedTraces.Add((requestId, toolName, argumentsJson, succeeded, resultSummary)); + } } public static class Request