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 用源文本断言注册行为) -->
+
+
+
+
+
+
+
+