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

Filter by extension

Filter by extension


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

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

jobs:
test:
uses: RimWorld-RimMind-Mod/Workflows/.github/workflows/test.yml@main
secrets: inherit
95 changes: 95 additions & 0 deletions Source/Actions/ActionsToolArgumentAdapter.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Adapts raw <see cref="AgentDecision"/> arguments for RimMind-Actions composite tools
/// (such as crop/food designation inference and autonomous recovery reason defaults).
/// Registered via <see cref="CompositeToolRegistrar"/> so Core has zero hard-coded Actions heuristics.
/// </summary>
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";
}
}
}
}
56 changes: 53 additions & 3 deletions Source/Actions/AddWorktableBillCompositeTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string> ToolAliases => Aliases;
protected override bool IsSelfTargetedTool => false;
protected override string? DisplayName => "Add Worktable Bill";
protected override IReadOnlyList<string> SummaryArgKeys => SummaryKeys;

protected override ToolDefinition BuildDefinition() => new ToolDefinition
{
Id = Id,
Expand All @@ -31,6 +53,18 @@ public sealed class AddWorktableBillCompositeTool : CompositeToolCallBase

public override IReadOnlyList<string> RequiredToolIds => Array.Empty<string>();

protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args)
{
string? recipeDefName = root?["recipe_def"]?.ToString();
if (string.IsNullOrWhiteSpace(recipeDefName) && args != null)
{
TryGetArgument<string>(args.ArgumentsJson, "recipe_def", out recipeDefName);
}
return IsEssentialSurvivalRecipe(recipeDefName?.Trim())
? AutonomyDomain.EmergencySurvival
: AutonomyDomain.WorktableBills;
}

public override Task<Result<ToolResult, RimMindError>> ExecuteAsync(
ToolCallArgs args,
CancellationToken ct)
Expand All @@ -42,6 +76,20 @@ public override Task<Result<ToolResult, RimMindError>> ExecuteAsync(
RimMindErrors.InvalidAction(Id, "Missing required parameter 'recipe_def'.")));
}

#if RIMMIND_ACTIONS_TESTS
var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args)
?? AutonomyDomain.WorktableBills;
if (TryGetArgument<int>(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0)
{
var guard = GuardColonistTarget(testPawnId, domain, args);
if (guard.IsErr)
{
return Task.FromResult(Result<ToolResult, RimMindError>.Err(guard.Error));
}
}
return Task.FromResult(Result<ToolResult, RimMindError>.Ok(
ToolResult.Ok($"Added bill {recipeDefName}", args.ToolCallId, Id)));
#else
var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true);
if (actorResolution.Error != null)
{
Expand Down Expand Up @@ -74,9 +122,10 @@ public override Task<Result<ToolResult, RimMindError>> 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)
{
Expand Down Expand Up @@ -209,6 +258,7 @@ public override Task<Result<ToolResult, RimMindError>> ExecuteAsync(
modeDesc),
args.ToolCallId,
Id)));
#endif
}

private static bool IsEssentialSurvivalRecipe(string? recipeDefName)
Expand Down
120 changes: 98 additions & 22 deletions Source/Actions/CompositeToolCallBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,120 @@
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; }

private ToolDefinition? _cachedDefinition;

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<string> ToolAliases => Array.Empty<string>();

protected virtual bool IsSelfTargetedTool => false;

protected virtual string? DisplayName => null;

protected virtual IReadOnlyList<string> SummaryArgKeys => Array.Empty<string>();

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<string> RequiredToolIds { get; }

public abstract Task<Result<ToolResult, RimMindError>> 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);

/// <summary>
Expand Down Expand Up @@ -75,23 +167,7 @@ protected async Task<ToolResult> 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);

/// <summary>
/// Checks whether trusted host code (e.g. an advisor approval card the player clicked) approved this call.
Expand Down Expand Up @@ -163,7 +239,7 @@ internal protected static bool TryGetArgument<T>(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;
Expand Down Expand Up @@ -224,7 +300,7 @@ protected static Result<int, RimMindError> 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<int>(args.ArgumentsJson, key, out int id) && id > 0)
{
Expand Down
Loading
Loading