From 5184bb1f33d9121b4bf701e623b48f5cedc6cf34 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 7 Nov 2026 04:31:29 +0100 Subject: [PATCH] feat(actions): self-describing composite tools, argument adapter, and CI test workflow feat(actions): add self-describing manifests and policy classification to composite tools Composite tools now expose ToolManifest metadata (owner, kind, default autonomy domain, risk level, aliases, display name, summary keys) and implement IToolPolicyClassifier so domain/risk resolution happens in one place instead of being hard-coded per call site. Survival-relevant arguments (bed blueprints, meal bills, shelter rooms) resolve to the EmergencySurvival domain, keeping classification consistent between runtime permission checks and the policy layer. Add ActionsToolArgumentAdapter to infer crop/food designations and autonomous recovery reasons, registered via CompositeToolRegistrar so Core stays free of Actions-specific heuristics. Gate RimWorld-dependent execution paths behind RIMMIND_ACTIONS_TESTS so all 11 composite tools compile and run in the unit test project, and add contract tests covering manifests, alias resolution, classifier parity, and argument adaptation. Add CI workflow to run the test suite. --- .github/workflows/test.yml | 22 +++ Source/Actions/ActionsToolArgumentAdapter.cs | 95 ++++++++++ .../Actions/AddWorktableBillCompositeTool.cs | 56 +++++- Source/Actions/CompositeToolCallBase.cs | 120 ++++++++++--- Source/Actions/CompositeToolRegistrar.cs | 18 ++ .../DesignateResourcesCompositeTool.cs | 43 ++++- .../Actions/EatAndRecreationCompositeTool.cs | 25 ++- Source/Actions/EmergencyFleeCompositeTool.cs | 22 ++- .../Actions/ManageEquipmentCompositeTool.cs | 45 ++++- Source/Actions/ManageZoneCompositeTool.cs | 42 ++++- Source/Actions/PlaceBlueprintCompositeTool.cs | 60 ++++++- .../Actions/PlanRoomProjectCompositeTool.cs | 65 ++++++- .../QueryBuildableStructuresCompositeTool.cs | 33 ++++ Source/Actions/StabilizeRestCompositeTool.cs | 21 ++- Source/Actions/TriagePatientCompositeTool.cs | 24 ++- Tests/CompositeToolRegistrarTests.cs | 6 +- .../CompositeToolSelfDescriptionContracts.cs | 170 ++++++++++++++++++ Tests/RimMindActions.Tests.csproj | 15 ++ 18 files changed, 837 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Source/Actions/ActionsToolArgumentAdapter.cs create mode 100644 Tests/CompositeToolSelfDescriptionContracts.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/Actions/ActionsToolArgumentAdapter.cs b/Source/Actions/ActionsToolArgumentAdapter.cs new file mode 100644 index 0000000..87595d1 --- /dev/null +++ b/Source/Actions/ActionsToolArgumentAdapter.cs @@ -0,0 +1,95 @@ +using System; +using Newtonsoft.Json.Linq; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Domain.Agent.Modes; + +namespace RimMind.Actions.Actions +{ + /// + /// Adapts raw arguments for RimMind-Actions composite tools + /// (such as crop/food designation inference and autonomous recovery reason defaults). + /// Registered via so Core has zero hard-coded Actions heuristics. + /// + public sealed class ActionsToolArgumentAdapter : IToolArgumentAdapter + { + public string Id => "actions_tool_argument_adapter"; + public string OwnerModId => "RimMindActions"; + + public bool CanAdapt(string toolId) + { + if (string.IsNullOrWhiteSpace(toolId)) return false; + return IsResourceDesignationTool(toolId) || IsAutonomousRecoveryTool(toolId); + } + + public JObject AdaptArguments(string toolId, JObject args, AgentDecision decision, int callerPawnId) + { + var obj = args ?? new JObject(); + + if (IsResourceDesignationTool(toolId) || + (!string.IsNullOrWhiteSpace(decision?.ActionIntent) && IsResourceDesignationTool(decision!.ActionIntent))) + { + AdaptResourceDesignationArguments(obj); + } + else if (IsAutonomousRecoveryTool(toolId) || + (!string.IsNullOrWhiteSpace(decision?.ActionIntent) && IsAutonomousRecoveryTool(decision!.ActionIntent))) + { + if (!obj.ContainsKey("reason")) + { + obj["reason"] = !string.IsNullOrEmpty(decision?.Reason) ? decision!.Reason : "autonomous recovery"; + } + } + + return obj; + } + + private static bool IsResourceDesignationTool(string toolId) + => string.Equals(toolId, "actions_designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions.designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "designate_map_order", StringComparison.OrdinalIgnoreCase); + + private static bool IsAutonomousRecoveryTool(string toolId) + => string.Equals(toolId, "actions.eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions_eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "calm_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions.stabilize_rest", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions_stabilize_rest", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "stabilize_rest", StringComparison.OrdinalIgnoreCase); + + private static void AdaptResourceDesignationArguments(JObject obj) + { + string targetStr = (obj["target"]?.ToString() ?? obj["target_name"]?.ToString() ?? "").Trim(); + bool isFoodOrCropTarget = + targetStr.IndexOf("berr", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("food", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("edible", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("crop", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("rice", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("potato", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("corn", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("healroot", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("agave", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("ambrosia", StringComparison.OrdinalIgnoreCase) >= 0; + + if (obj.TryGetValue("order_type", StringComparison.OrdinalIgnoreCase, out var otVal)) + { + string ot = otVal.ToString(); + if (ot.Equals("CutPlants", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = isFoodOrCropTarget ? "Harvest" : "ChopWood"; + else if (ot.Equals("ChopWood", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "ChopWood"; + else if (ot.Equals("Harvest", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "Harvest"; + else if (ot.Equals("Mine", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "Mine"; + else + obj["designation_type"] = ot; + } + else if (!obj.ContainsKey("designation_type")) + { + obj["designation_type"] = isFoodOrCropTarget ? "Harvest" : "ChopWood"; + } + } + } +} diff --git a/Source/Actions/AddWorktableBillCompositeTool.cs b/Source/Actions/AddWorktableBillCompositeTool.cs index ed1b4a0..e791099 100644 --- a/Source/Actions/AddWorktableBillCompositeTool.cs +++ b/Source/Actions/AddWorktableBillCompositeTool.cs @@ -3,11 +3,15 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +25,24 @@ public sealed class AddWorktableBillCompositeTool : CompositeToolCallBase { public override string Id => "actions_add_worktable_bill"; + private static readonly string[] Aliases = new[] + { + "add_worktable_bill", + "actions.add_worktable_bill" + }; + + private static readonly string[] SummaryKeys = new[] + { + "recipe_def", + "worktable_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.WorktableBills; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Add Worktable Bill"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -31,6 +53,18 @@ public sealed class AddWorktableBillCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? recipeDefName = root?["recipe_def"]?.ToString(); + if (string.IsNullOrWhiteSpace(recipeDefName) && args != null) + { + TryGetArgument(args.ArgumentsJson, "recipe_def", out recipeDefName); + } + return IsEssentialSurvivalRecipe(recipeDefName?.Trim()) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.WorktableBills; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) @@ -42,6 +76,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'recipe_def'."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.WorktableBills; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Added bill {recipeDefName}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -74,9 +122,10 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidDefName($"Recipe '{recipeDefName}' not found in database."))); } - var billAutonomyDomain = IsEssentialSurvivalRecipe(recipeDef.defName) - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.WorktableBills; + var billAutonomyDomain = ResolveAutonomyDomain(new JObject { ["recipe_def"] = recipeDef.defName }, args) + ?? (IsEssentialSurvivalRecipe(recipeDef.defName) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.WorktableBills); var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, billAutonomyDomain, args); if (!permCheck.IsOk) { @@ -209,6 +258,7 @@ public override Task> ExecuteAsync( modeDesc), args.ToolCallId, Id))); +#endif } private static bool IsEssentialSurvivalRecipe(string? recipeDefName) diff --git a/Source/Actions/CompositeToolCallBase.cs b/Source/Actions/CompositeToolCallBase.cs index a675106..feee626 100644 --- a/Source/Actions/CompositeToolCallBase.cs +++ b/Source/Actions/CompositeToolCallBase.cs @@ -6,13 +6,16 @@ using Newtonsoft.Json.Linq; using RimMind.Application.Common.Interfaces.Tools; using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Features.Tools; +using RimMind.Domain.Agent.Policies; using RimMind.Domain.Common; +using RimMind.Domain.Enums; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; namespace RimMind.Actions.Actions { - public abstract class CompositeToolCallBase : ICompositeToolCall + public abstract class CompositeToolCallBase : ICompositeToolCall, IToolPolicyClassifier { public abstract string Id { get; } @@ -20,14 +23,103 @@ public abstract class CompositeToolCallBase : ICompositeToolCall public virtual string OwnerModId => "RimMindActions"; - public virtual ToolDefinition Definition => _cachedDefinition ??= BuildDefinition(); + protected virtual ToolKind ToolKind => ToolKind.Mutation; - protected virtual ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id }; + protected virtual AutonomyDomain? DefaultAutonomyDomain => null; + + protected virtual RiskLevel DefaultRiskLevel => MapDomainToRisk(DefaultAutonomyDomain, ToolKind); + + protected virtual bool DefaultRequiresApproval => false; + + protected virtual IReadOnlyList ToolAliases => Array.Empty(); + + protected virtual bool IsSelfTargetedTool => false; + + protected virtual string? DisplayName => null; + + protected virtual IReadOnlyList SummaryArgKeys => Array.Empty(); + + public virtual ToolManifest Manifest => new ToolManifest + { + OwnerModId = OwnerModId, + Kind = ToolKind, + DefaultDomain = DefaultAutonomyDomain, + AutonomyDomainId = DefaultAutonomyDomain?.ToString(), + RiskLevel = DefaultRiskLevel, + RequiresApproval = DefaultRequiresApproval, + Aliases = ToolAliases, + IsSelfTargeted = IsSelfTargetedTool, + DisplayName = DisplayName, + SummaryArgKeys = SummaryArgKeys + }; + + public virtual ToolDefinition Definition => _cachedDefinition ??= BuildDefinition() with + { + Manifest = Manifest + }; + + protected virtual ToolDefinition BuildDefinition() => new ToolDefinition + { + Id = Id, + Manifest = Manifest + }; public abstract IReadOnlyList RequiredToolIds { get; } public abstract Task> ExecuteAsync(ToolCallArgs args, CancellationToken ct); + protected virtual AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + => DefaultAutonomyDomain; + + public virtual ToolPolicyDecision Classify(ToolCallArgs args) + { + var effectiveArgs = args ?? new ToolCallArgs + { + ToolName = Id, + ArgumentsJson = "{}" + }; + var root = TryParseArgumentsObject(effectiveArgs.ArgumentsJson); + var domain = ResolveAutonomyDomain(root, effectiveArgs); + return new ToolPolicyDecision + { + Domain = domain, + DomainId = domain?.ToString() ?? Manifest.AutonomyDomainId, + Risk = MapDomainToRisk(domain, ToolKind), + RequiresApproval = Manifest.RequiresApproval + }; + } + + protected static JObject? TryParseArgumentsObject(string? argumentsJson) + { + if (string.IsNullOrWhiteSpace(argumentsJson)) return null; + try + { + return JToken.Parse(argumentsJson!) as JObject; + } + catch + { + return null; + } + } + + private static RiskLevel MapDomainToRisk(AutonomyDomain? domain, ToolKind kind) + { + if (!domain.HasValue) + { + return kind == ToolKind.Query ? RiskLevel.Low : RiskLevel.Medium; + } + + return domain.Value switch + { + AutonomyDomain.EmergencySurvival => RiskLevel.Low, + AutonomyDomain.ResourceMarking => RiskLevel.Low, + AutonomyDomain.WorktableBills => RiskLevel.Medium, + AutonomyDomain.BlueprintPlacement => RiskLevel.Medium, + AutonomyDomain.HighRiskOperations => RiskLevel.High, + _ => RiskLevel.Medium + }; + } + protected virtual IToolHandler? FindTool(string toolId) => RimMindAPI.Tools.FindById(toolId); /// @@ -75,23 +167,7 @@ protected async Task ExecuteAtomicAsync( } internal protected static bool TryParsePawnIdString(string? raw, out int parsedId) - { - parsedId = 0; - if (string.IsNullOrWhiteSpace(raw)) return false; - var s = raw!.Trim(); - if (s.StartsWith("NPC-", StringComparison.OrdinalIgnoreCase) || - s.StartsWith("pawn-", StringComparison.OrdinalIgnoreCase)) - { - s = s.Substring(s.IndexOf('-') + 1).Trim(); - } - else if (s.StartsWith("Pawn_", StringComparison.OrdinalIgnoreCase)) - { - int idx = s.Length - 1; - while (idx >= 0 && char.IsDigit(s[idx])) idx--; - s = s.Substring(idx + 1); - } - return int.TryParse(s, out parsedId); - } + => PawnIdParser.TryParse(raw, out parsedId); /// /// Checks whether trusted host code (e.g. an advisor approval card the player clicked) approved this call. @@ -163,7 +239,7 @@ internal protected static bool TryGetArgument(string argumentsJson, string ke } if (typeof(T) == typeof(int) && token.Type == JTokenType.String) { - if (TryParsePawnIdString(token.ToString(), out int parsedInt)) + if (PawnIdParser.TryParse(token.ToString(), out int parsedInt)) { value = (T)(object)parsedInt; return true; @@ -224,7 +300,7 @@ protected static Result TryGetPawnIdOrError( { int contextFallbackId = (args.PawnId.HasValue && args.PawnId.Value > 0) ? args.PawnId.Value - : (TryParsePawnIdString(args.NpcId, out int parsedNpcId) && parsedNpcId > 0 ? parsedNpcId : 0); + : (PawnIdParser.TryParse(args.NpcId, out int parsedNpcId) && parsedNpcId > 0 ? parsedNpcId : 0); if (TryGetArgument(args.ArgumentsJson, key, out int id) && id > 0) { diff --git a/Source/Actions/CompositeToolRegistrar.cs b/Source/Actions/CompositeToolRegistrar.cs index 0ecfbca..fb3ab64 100644 --- a/Source/Actions/CompositeToolRegistrar.cs +++ b/Source/Actions/CompositeToolRegistrar.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Reflection; using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Presentation.Api; using Verse; namespace RimMind.Actions.Actions @@ -43,6 +44,23 @@ public static void RegisterAll(IToolRegistry registry, Assembly assembly) Log.Warning($"[RimMind-Actions] Skipped {type.FullName}: {ex.Message}"); } } + + TryRegisterArgumentAdapter(); + } + + private static void TryRegisterArgumentAdapter() + { +#if !RIMMIND_ACTIONS_TESTS + try + { + var adapterRegistry = RimMindAPI.Ext.Get(); + adapterRegistry?.Register(new ActionsToolArgumentAdapter()); + } + catch + { + // Safe no-op when runtime extension registry is not initialized (e.g. isolated unit tests). + } +#endif } } } diff --git a/Source/Actions/DesignateResourcesCompositeTool.cs b/Source/Actions/DesignateResourcesCompositeTool.cs index cc1a3d6..394b7f5 100644 --- a/Source/Actions/DesignateResourcesCompositeTool.cs +++ b/Source/Actions/DesignateResourcesCompositeTool.cs @@ -6,8 +6,11 @@ using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +24,25 @@ public sealed class DesignateResourcesCompositeTool : CompositeToolCallBase { public override string Id => "actions_designate_resources"; + private static readonly string[] Aliases = new[] + { + "designate_resources", + "designate_map_order", + "actions.designate_resources" + }; + + private static readonly string[] SummaryKeys = new[] + { + "designation_type", + "target_name" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.ResourceMarking; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Designate Resources"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,6 +64,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'designation_type' (Harvest, ChopWood, or Mine)."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Designated {designationTypeStr}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -59,7 +95,9 @@ public override Task> ExecuteAsync( // 1. Resolve Colonist Pawn and Permission Check Pawn? pawn = actorResolution.Pawn; - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.ResourceMarking, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -300,8 +338,10 @@ public override Task> ExecuteAsync( targetSample), args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool IsGenericResourceFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) return true; @@ -331,5 +371,6 @@ private static bool IsGenericResourceFilter(string? filter) || f.Contains("_") || f.Any(c => c > 127); } +#endif } } diff --git a/Source/Actions/EatAndRecreationCompositeTool.cs b/Source/Actions/EatAndRecreationCompositeTool.cs index a894906..5db8e14 100644 --- a/Source/Actions/EatAndRecreationCompositeTool.cs +++ b/Source/Actions/EatAndRecreationCompositeTool.cs @@ -20,6 +20,27 @@ public sealed class EatAndRecreationCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "eat_and_recreation", + "calm_recreation", + "actions_eat_and_recreation", + "actions.calm_recreation", + "actions_calm_recreation" + }; + + private static readonly string[] SummaryKeys = new[] + { + "priority", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Eat & Recreation"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,7 +63,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/EmergencyFleeCompositeTool.cs b/Source/Actions/EmergencyFleeCompositeTool.cs index d100b4f..55ac022 100644 --- a/Source/Actions/EmergencyFleeCompositeTool.cs +++ b/Source/Actions/EmergencyFleeCompositeTool.cs @@ -21,6 +21,24 @@ public sealed class EmergencyFleeCompositeTool : CompositeToolCallBase "pawn.draft.toggle" }; + private static readonly string[] Aliases = new[] + { + "emergency_flee", + "actions_emergency_flee" + }; + + private static readonly string[] SummaryKeys = new[] + { + "danger_level", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Emergency Flee"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -43,7 +61,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/ManageEquipmentCompositeTool.cs b/Source/Actions/ManageEquipmentCompositeTool.cs index 85ad32a..100006f 100644 --- a/Source/Actions/ManageEquipmentCompositeTool.cs +++ b/Source/Actions/ManageEquipmentCompositeTool.cs @@ -7,9 +7,11 @@ using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -21,6 +23,27 @@ public sealed class ManageEquipmentCompositeTool : CompositeToolCallBase { public override string Id => "actions_manage_equipment"; + private static readonly string[] Aliases = new[] + { + "manage_equipment", + "equip_combat_gear", + "actions.manage_equipment", + "actions.equip_combat_gear", + "actions_equip_combat_gear" + }; + + private static readonly string[] SummaryKeys = new[] + { + "target_apparel_def", + "purpose" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Manage Equipment"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -35,6 +58,21 @@ public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) { +#if RIMMIND_ACTIONS_TESTS + var pawnIdResult = TryGetPawnIdOrError(args, "pawn_id"); + if (pawnIdResult.IsErr) + { + return Task.FromResult(Result.Err(pawnIdResult.Error)); + } + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnIdResult.Value, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + return Task.FromResult(Result.Ok(ToolResult.Ok("ok", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -58,7 +96,9 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Colonist pawn not found or invalid."))); } - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -169,6 +209,7 @@ bool CanWearApparel(Apparel a) => pawn.LabelShort, $"{ambientTemp:F1}", bestApparel.LabelCap), args.ToolCallId, Id))); +#endif } } } diff --git a/Source/Actions/ManageZoneCompositeTool.cs b/Source/Actions/ManageZoneCompositeTool.cs index f28dde3..2df8719 100644 --- a/Source/Actions/ManageZoneCompositeTool.cs +++ b/Source/Actions/ManageZoneCompositeTool.cs @@ -6,8 +6,11 @@ using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +24,24 @@ public sealed class ManageZoneCompositeTool : CompositeToolCallBase { public override string Id => "actions_manage_zone"; + private static readonly string[] Aliases = new[] + { + "manage_zone", + "actions.manage_zone" + }; + + private static readonly string[] SummaryKeys = new[] + { + "zone_type", + "crop_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.ResourceMarking; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Manage Zone"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,6 +63,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'zone_type' (Growing or Stockpile)."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Zone {zoneTypeStr}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -59,7 +94,9 @@ public override Task> ExecuteAsync( // 1. Resolve Anchor Colonist & Autonomy Permission Guard Pawn? pawn = actorResolution.Pawn; - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.ResourceMarking, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -184,8 +221,10 @@ public override Task> ExecuteAsync( bestRect.CenterCell.x, bestRect.CenterCell.z, details), args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static int SearchBestZoneRect(Map map, IntVec3 origin, int width, int height, bool isGrowing, out CellRect bestRect) { bestRect = default; @@ -223,5 +262,6 @@ private static int SearchBestZoneRect(Map map, IntVec3 origin, int width, int he return bestValidCells; } +#endif } } diff --git a/Source/Actions/PlaceBlueprintCompositeTool.cs b/Source/Actions/PlaceBlueprintCompositeTool.cs index d0d6100..8e6a82b 100644 --- a/Source/Actions/PlaceBlueprintCompositeTool.cs +++ b/Source/Actions/PlaceBlueprintCompositeTool.cs @@ -4,14 +4,18 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -23,6 +27,24 @@ public sealed class PlaceBlueprintCompositeTool : CompositeToolCallBase { public override string Id => "actions_place_blueprint"; + private static readonly string[] Aliases = new[] + { + "place_blueprint", + "actions.place_blueprint" + }; + + private static readonly string[] SummaryKeys = new[] + { + "building_def", + "stuff_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.BlueprintPlacement; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Place Blueprint"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -33,6 +55,18 @@ public sealed class PlaceBlueprintCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? buildingDefName = root?["building_def"]?.ToString(); + if (string.IsNullOrWhiteSpace(buildingDefName) && args != null) + { + TryGetArgument(args.ArgumentsJson, "building_def", out buildingDefName); + } + return IsEssentialSurvivalStructure(buildingDefName?.Trim()) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) @@ -44,6 +78,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'building_def'."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.BlueprintPlacement; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Placed {buildingDefName}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -120,9 +168,10 @@ public override Task> ExecuteAsync( Pawn? anchorPawn = actorResolution.Pawn; // Autonomy Permission & Resource Safety Guards - var autonomyDomain = IsEssentialSurvivalStructure(thingDef.defName) - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.BlueprintPlacement; + var autonomyDomain = ResolveAutonomyDomain(new JObject { ["building_def"] = thingDef.defName }, args) + ?? (IsEssentialSurvivalStructure(thingDef.defName) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement); var permCheck = ActionPermissionGuard.CheckPermission(anchorPawn, Id, autonomyDomain, args); if (!permCheck.IsOk) { @@ -239,8 +288,10 @@ public override Task> ExecuteAsync( Content = JsonConvert.SerializeObject(successPayload, Formatting.Indented), IsError = false })); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static IntVec3 FindValidPlacementCell(Map map, ThingDef def, IntVec3 anchor, string hint, ref Rot4 rot, ThingDef? stuff) { bool wantsIndoor = hint.Contains("indoor") || hint.Contains("bed") || hint.Contains("room"); @@ -358,6 +409,7 @@ private static IntVec3 FindValidPlacementCell(Map map, ThingDef def, IntVec3 anc return null; } +#endif private static bool IsEssentialSurvivalStructure(string? defName) { diff --git a/Source/Actions/PlanRoomProjectCompositeTool.cs b/Source/Actions/PlanRoomProjectCompositeTool.cs index 87b5caf..5901bc6 100644 --- a/Source/Actions/PlanRoomProjectCompositeTool.cs +++ b/Source/Actions/PlanRoomProjectCompositeTool.cs @@ -4,14 +4,18 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -24,6 +28,23 @@ public sealed class PlanRoomProjectCompositeTool : CompositeToolCallBase { public override string Id => "actions_plan_room_project"; + private static readonly string[] Aliases = new[] + { + "plan_room_project", + "actions.plan_room_project" + }; + + private static readonly string[] SummaryKeys = new[] + { + "room_type" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.BlueprintPlacement; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Plan Room Project"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -34,10 +55,44 @@ public sealed class PlanRoomProjectCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? roomType = root?["room_type"]?.ToString(); + if (string.IsNullOrWhiteSpace(roomType) && args != null) + { + TryGetArgument(args.ArgumentsJson, "room_type", out roomType); + } + if (string.IsNullOrWhiteSpace(roomType)) + { + roomType = "Shelter"; + } + + string normalized = roomType!.Trim(); + bool isSurvivalRoom = normalized.Equals("Shelter", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("Bedroom", StringComparison.OrdinalIgnoreCase); + return isSurvivalRoom + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) { +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.BlueprintPlacement; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok("Planned room", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: false); if (actorResolution.Error != null) { @@ -69,9 +124,8 @@ public override Task> ExecuteAsync( bool isSurvivalRoom = roomType.Equals("Shelter", StringComparison.OrdinalIgnoreCase) || roomType.Equals("Bedroom", StringComparison.OrdinalIgnoreCase); - var roomAutonomyDomain = isSurvivalRoom - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.BlueprintPlacement; + var roomAutonomyDomain = ResolveAutonomyDomain(new JObject { ["room_type"] = roomType }, args) + ?? (isSurvivalRoom ? AutonomyDomain.EmergencySurvival : AutonomyDomain.BlueprintPlacement); var permCheck = ActionPermissionGuard.CheckPermission(builderPawn, Id, roomAutonomyDomain, args); if (!permCheck.IsOk) @@ -253,8 +307,10 @@ public override Task> ExecuteAsync( planMsg, args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool TryFindClearRoomRect(Map map, IntVec3 origin, int size, ThingDef? selectedStuff, out CellRect bestRect) { bestRect = default; @@ -299,5 +355,6 @@ private static bool TryFindClearRoomRect(Map map, IntVec3 origin, int size, Thin } return false; } +#endif } } diff --git a/Source/Actions/QueryBuildableStructuresCompositeTool.cs b/Source/Actions/QueryBuildableStructuresCompositeTool.cs index e4e9c81..b3b6282 100644 --- a/Source/Actions/QueryBuildableStructuresCompositeTool.cs +++ b/Source/Actions/QueryBuildableStructuresCompositeTool.cs @@ -5,8 +5,11 @@ using System.Threading.Tasks; using Newtonsoft.Json; using RimMind.Application.Common.Models.Tools; +using RimMind.Domain.Agent.Policies; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -19,6 +22,24 @@ public sealed class QueryBuildableStructuresCompositeTool : CompositeToolCallBas { public override string Id => "actions_query_buildable_structures"; + private static readonly string[] Aliases = new[] + { + "query_buildable_structures", + "actions.query_buildable_structures" + }; + + private static readonly string[] SummaryKeys = new[] + { + "category" + }; + + protected override ToolKind ToolKind => ToolKind.Query; + protected override AutonomyDomain? DefaultAutonomyDomain => null; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Query Buildable Structures"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -36,6 +57,15 @@ public override Task> ExecuteAsync( TryGetArgument(args.ArgumentsJson, "category", out var categoryFilter); categoryFilter = string.IsNullOrWhiteSpace(categoryFilter) ? "all" : categoryFilter.ToLowerInvariant().Trim(); +#if RIMMIND_ACTIONS_TESTS + return Task.FromResult(Result.Ok(new ToolResult + { + ToolCallId = args.ToolCallId, + ToolName = Id, + Content = JsonConvert.SerializeObject(new { status = "ok", category = categoryFilter }), + IsError = false + })); +#else // Read-only query: prefer the acting colonist's map, fall back to the viewed map. var map = RimMind.Actions.Policies.ActionPermissionGuard.ResolveActorAndMap(args).Map ?? Find.CurrentMap; if (map == null) @@ -123,8 +153,10 @@ public override Task> ExecuteAsync( Content = JsonConvert.SerializeObject(responsePayload), IsError = false })); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool HasActivePowerGrid(Map map) { if (map.powerNetManager == null) return false; @@ -196,6 +228,7 @@ private static bool CheckCanAfford(ThingDef def, int wood, int steel, int comp, if (comp > 0 && compHave < comp) return false; return true; } +#endif private static List GetCuratedStructures() { diff --git a/Source/Actions/StabilizeRestCompositeTool.cs b/Source/Actions/StabilizeRestCompositeTool.cs index ffda842..11cd80d 100644 --- a/Source/Actions/StabilizeRestCompositeTool.cs +++ b/Source/Actions/StabilizeRestCompositeTool.cs @@ -17,6 +17,23 @@ public sealed class StabilizeRestCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "stabilize_rest", + "actions_stabilize_rest" + }; + + private static readonly string[] SummaryKeys = new[] + { + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Force Rest"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -39,7 +56,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/TriagePatientCompositeTool.cs b/Source/Actions/TriagePatientCompositeTool.cs index d383961..b87bc58 100644 --- a/Source/Actions/TriagePatientCompositeTool.cs +++ b/Source/Actions/TriagePatientCompositeTool.cs @@ -20,6 +20,24 @@ public sealed class TriagePatientCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "triage_patient", + "actions_triage_patient" + }; + + private static readonly string[] SummaryKeys = new[] + { + "urgency", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Triage Patient"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,7 +60,9 @@ public override async Task> ExecuteAsync( } int patientId = patientIdResult.Value; - var patientGuard = GuardColonistTarget(patientId, AutonomyDomain.EmergencySurvival, args, "patient_id"); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var patientGuard = GuardColonistTarget(patientId, domain, args, "patient_id"); if (patientGuard.IsErr) { return Result.Err(patientGuard.Error); @@ -52,7 +72,7 @@ public override async Task> ExecuteAsync( bool hasDoctor = TryGetArgument(args.ArgumentsJson, "doctor_id", out doctorId) && doctorId > 0; if (hasDoctor) { - var doctorGuard = GuardColonistTarget(doctorId, AutonomyDomain.EmergencySurvival, args, "doctor_id"); + var doctorGuard = GuardColonistTarget(doctorId, domain, args, "doctor_id"); if (doctorGuard.IsErr) { return Result.Err(doctorGuard.Error); diff --git a/Tests/CompositeToolRegistrarTests.cs b/Tests/CompositeToolRegistrarTests.cs index 97bd823..ce4140f 100644 --- a/Tests/CompositeToolRegistrarTests.cs +++ b/Tests/CompositeToolRegistrarTests.cs @@ -79,11 +79,11 @@ public void RegisterAll_Duplicate_Id_Logs_Warning_And_Skips_Second() { Verse.Log.Messages.Clear(); var registry = new FakeRegistry(); - // RegisterAll scans the test assembly which contains both StabilizeRestCompositeTool - // and DuplicateIdCompositeTool — both declare Id "actions.stabilize_rest". + // RegisterAll scans the test assembly which contains all 11 production composite tools + // plus DuplicateIdCompositeTool — which duplicates Id "actions.stabilize_rest". CompositeToolRegistrar.RegisterAll(registry, typeof(DuplicateIdCompositeTool).Assembly); - Assert.Equal(4, registry.RegisteredHandlers.Count); + Assert.Equal(11, registry.RegisteredHandlers.Count); Assert.Contains(Verse.Log.Messages, m => m.Contains("duplicate Id") && m.Contains("actions.stabilize_rest")); } diff --git a/Tests/CompositeToolSelfDescriptionContracts.cs b/Tests/CompositeToolSelfDescriptionContracts.cs new file mode 100644 index 0000000..376365a --- /dev/null +++ b/Tests/CompositeToolSelfDescriptionContracts.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using RimMind.Actions.Actions; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Features.Tools; +using RimMind.Domain.Agent.Modes; +using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Enums; +using Xunit; + +namespace RimMind.Actions.Tests +{ + public class CompositeToolSelfDescriptionContracts + { + private static readonly CompositeToolCallBase[] AllProductionCompositeTools = new CompositeToolCallBase[] + { + new PlaceBlueprintCompositeTool(), + new PlanRoomProjectCompositeTool(), + new AddWorktableBillCompositeTool(), + new DesignateResourcesCompositeTool(), + new ManageZoneCompositeTool(), + new ManageEquipmentCompositeTool(), + new StabilizeRestCompositeTool(), + new EmergencyFleeCompositeTool(), + new EatAndRecreationCompositeTool(), + new TriagePatientCompositeTool(), + new QueryBuildableStructuresCompositeTool() + }; + + [Fact] + public void All_11_CompositeTools_Declare_RimMindActions_Owner_Valid_Domains_And_Aliases() + { + var registry = new ToolRegistry(); + foreach (var tool in AllProductionCompositeTools) + { + registry.Register(tool); + + var manifest = tool.Definition.Manifest; + Assert.NotNull(manifest); + Assert.Equal("RimMindActions", manifest.OwnerModId); + Assert.False(string.IsNullOrWhiteSpace(manifest.DisplayName), $"Tool '{tool.Id}' must declare DisplayName"); + Assert.NotEmpty(manifest.Aliases); + Assert.NotEmpty(manifest.SummaryArgKeys); + + if (tool is QueryBuildableStructuresCompositeTool) + { + Assert.Equal(ToolKind.Query, manifest.Kind); + Assert.Null(manifest.DefaultDomain); + } + else + { + Assert.Equal(ToolKind.Mutation, manifest.Kind); + Assert.NotNull(manifest.DefaultDomain); + Assert.Equal(manifest.DefaultDomain.Value.ToString(), manifest.AutonomyDomainId); + } + + // Canonical ID and every declared alias must resolve back to the tool handler in ToolRegistry + Assert.Same(tool, registry.FindById(tool.Id)); + foreach (var alias in manifest.Aliases) + { + Assert.Same(tool, registry.FindById(alias)); + } + } + } + + [Fact] + public void SelfTargeted_Metadata_Matches_PawnPersonal_Tools() + { + var selfTargetedIds = AllProductionCompositeTools + .Where(t => t.Manifest.IsSelfTargeted) + .Select(t => t.Id) + .OrderBy(id => id) + .ToList(); + + Assert.Contains("actions.stabilize_rest", selfTargetedIds); + Assert.Contains("actions.eat_and_recreation", selfTargetedIds); + Assert.Contains("actions.emergency_flee", selfTargetedIds); + Assert.Contains("actions.triage_patient", selfTargetedIds); + Assert.Contains("actions_manage_equipment", selfTargetedIds); + Assert.DoesNotContain("actions_place_blueprint", selfTargetedIds); + Assert.DoesNotContain("actions_designate_resources", selfTargetedIds); + } + + [Fact] + public void PolicyClassifier_Classifies_Survival_Vs_NonSurvival_Arguments_Identically_To_Runtime() + { + var placeBlueprint = new PlaceBlueprintCompositeTool(); + var bedPolicy = placeBlueprint.Classify(new ToolCallArgs + { + ToolName = placeBlueprint.Id, + ArgumentsJson = "{\"building_def\":\"Bed\"}" + }); + var statuePolicy = placeBlueprint.Classify(new ToolCallArgs + { + ToolName = placeBlueprint.Id, + ArgumentsJson = "{\"building_def\":\"SculptureSmall\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, bedPolicy.Domain); + Assert.Equal(RiskLevel.Low, bedPolicy.Risk); + Assert.Equal(AutonomyDomain.BlueprintPlacement, statuePolicy.Domain); + Assert.Equal(RiskLevel.Medium, statuePolicy.Risk); + + var addBill = new AddWorktableBillCompositeTool(); + var mealPolicy = addBill.Classify(new ToolCallArgs + { + ToolName = addBill.Id, + ArgumentsJson = "{\"recipe_def\":\"CookMealSimple\"}" + }); + var sculptureBillPolicy = addBill.Classify(new ToolCallArgs + { + ToolName = addBill.Id, + ArgumentsJson = "{\"recipe_def\":\"Make_SculptureSmall\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, mealPolicy.Domain); + Assert.Equal(RiskLevel.Low, mealPolicy.Risk); + Assert.Equal(AutonomyDomain.WorktableBills, sculptureBillPolicy.Domain); + Assert.Equal(RiskLevel.Medium, sculptureBillPolicy.Risk); + + var planRoom = new PlanRoomProjectCompositeTool(); + var shelterPolicy = planRoom.Classify(new ToolCallArgs + { + ToolName = planRoom.Id, + ArgumentsJson = "{\"room_type\":\"Shelter\"}" + }); + var storagePolicy = planRoom.Classify(new ToolCallArgs + { + ToolName = planRoom.Id, + ArgumentsJson = "{\"room_type\":\"Storage\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, shelterPolicy.Domain); + Assert.Equal(RiskLevel.Low, shelterPolicy.Risk); + Assert.Equal(AutonomyDomain.BlueprintPlacement, storagePolicy.Domain); + Assert.Equal(RiskLevel.Medium, storagePolicy.Risk); + } + + [Fact] + public void ActionsToolArgumentAdapter_Adapts_CropDesignations_And_RecoveryReason() + { + var adapter = new ActionsToolArgumentAdapter(); + Assert.True(adapter.CanAdapt("actions_designate_resources")); + Assert.True(adapter.CanAdapt("designate_map_order")); + Assert.True(adapter.CanAdapt("actions.stabilize_rest")); + Assert.False(adapter.CanAdapt("actions_place_blueprint")); + + var berryArgs = adapter.AdaptArguments( + "actions_designate_resources", + JObject.Parse("{\"order_type\":\"CutPlants\",\"target\":\"Plant_Berry\"}"), + new AgentDecision { ActionIntent = "designate_map_order" }, + 1); + Assert.Equal("Harvest", berryArgs["designation_type"]?.ToString()); + + var oakArgs = adapter.AdaptArguments( + "actions_designate_resources", + JObject.Parse("{\"order_type\":\"CutPlants\",\"target\":\"Plant_TreeOak\"}"), + new AgentDecision { ActionIntent = "designate_map_order" }, + 1); + Assert.Equal("ChopWood", oakArgs["designation_type"]?.ToString()); + + var restArgs = adapter.AdaptArguments( + "actions.stabilize_rest", + new JObject(), + new AgentDecision { ActionIntent = "stabilize_rest", Reason = "Exhausted after raid" }, + 1); + Assert.Equal("Exhausted after raid", restArgs["reason"]?.ToString()); + } + } +} diff --git a/Tests/RimMindActions.Tests.csproj b/Tests/RimMindActions.Tests.csproj index 9d0525b..cfdb8e1 100644 --- a/Tests/RimMindActions.Tests.csproj +++ b/Tests/RimMindActions.Tests.csproj @@ -18,16 +18,23 @@ + + + + + + + @@ -38,10 +45,18 @@ 测试通过 CompositeToolRegistrationTests 用源文本断言注册行为) --> + + + + + + + +