Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BattleTool and full message collection instead of summary #102

Merged
merged 26 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2c0fdc
BattleTool has been implemented and we provide an option to not
KarmaKamikaze Nov 1, 2024
4a31ad0
Create dummy character in battle and pray archivst is smart
KarmaKamikaze Nov 1, 2024
3fe7024
Adjust battle tool description
KarmaKamikaze Nov 1, 2024
b136ae4
Fixed BattleTool (hopefully) and FindCharacter is better at finding the
KarmaKamikaze Nov 4, 2024
04c2d96
Fix linter error
KarmaKamikaze Nov 4, 2024
40fd3b1
Added LLM debug modes for narrator and archivist chains
KarmaKamikaze Nov 4, 2024
10dfc68
Use lower health values for opponent characters so battles do not take
KarmaKamikaze Nov 4, 2024
8f70fdf
Remove unused CharacterTypeDamageDict
KarmaKamikaze Nov 4, 2024
bc0a0ee
Added examples to FindCharacter instructions and let BattleTool do
KarmaKamikaze Nov 8, 2024
7268974
Switch to Gpt4OmniModel for speed and moved InitializeCampaign to
KarmaKamikaze Nov 8, 2024
30d9c97
FindCharacter and BattleTool fixes
KarmaKamikaze Nov 8, 2024
aeff689
Move scrolling js to every time page is rendered
KarmaKamikaze Nov 8, 2024
6323032
Set ShouldSummarize to true again, or we go broke
KarmaKamikaze Nov 8, 2024
a5b3989
Added markdown removal
Sarmisuper Nov 11, 2024
ad3b7a0
Improved find character so it knows who the player is and who first-p…
Sarmisuper Nov 11, 2024
7b7d9ea
Linter
Sarmisuper Nov 11, 2024
4d383f2
Fixed bug where campaign page keeps scrolling to bottom
Sarmisuper Nov 11, 2024
cbc6d0f
Linter
Sarmisuper Nov 11, 2024
152a59c
Moved the RemoveMarkdown function to ToolUtils
Sarmisuper Nov 11, 2024
0508bae
Linter
Sarmisuper Nov 11, 2024
6f42046
Added examples to Wound and Heal instructions
Sarmisuper Nov 11, 2024
abd1cd7
changed example to avoid degen output
Sarmisuper Nov 11, 2024
e4a8270
Fixed error in example
Sarmisuper Nov 11, 2024
4aa9579
Made saving into a task so players can input next command without wai…
Sarmisuper Nov 12, 2024
169e489
Linter
Sarmisuper Nov 12, 2024
62202d0
Add progress spinner to indicate that archivist is working
KarmaKamikaze Nov 12, 2024
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
9 changes: 5 additions & 4 deletions ChatRPG/API/ReActAgentChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public ReActAgentChain(
string? gameSummary = null,
string inputKey = "input",
string outputKey = "text",
int maxActions = 10)
int maxActions = 20)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pump Up Those Numbers!!!

{
_model = model;
_model.Settings!.StopSequences = ["Observation", "[END]"];
Expand Down Expand Up @@ -116,7 +116,7 @@ public ReActAgentChain(
string? gameSummary = null,
string inputKey = "input",
string outputKey = "text",
int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions)
int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions)
{
_actionPrompt = actionPrompt ?? string.Empty;
}
Expand All @@ -130,7 +130,7 @@ public ReActAgentChain(
string? gameSummary = null,
string inputKey = "input",
string outputKey = "text",
int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions)
int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions)
{
_characters = characters;
_playerCharacter = playerCharacter;
Expand Down Expand Up @@ -215,6 +215,7 @@ await _conversationBufferMemory.ChatHistory.AddMessage(new Message("Thought:", M
}
}

throw new ReActChainNoFinalAnswerReachedException("The ReAct Chain could not reach a final answer", values);
throw new ReActChainNoFinalAnswerReachedException($"The ReAct Chain could not reach a final answer. " +
$"Values: {values}", values);
}
}
55 changes: 46 additions & 9 deletions ChatRPG/API/ReActLlmClient.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ChatRPG.API.Tools;
using ChatRPG.Data.Models;
using LangChain.Chains.StackableChains.Agents.Tools;
using LangChain.Providers;
using LangChain.Providers.OpenAI;
using LangChain.Providers.OpenAI.Predefined;
using static LangChain.Chains.Chain;
Expand All @@ -12,6 +13,7 @@ public class ReActLlmClient : IReActLlmClient
private readonly IConfiguration _configuration;
private readonly OpenAiProvider _provider;
private readonly string _reActPrompt;
private readonly bool _narratorDebugMode;

public ReActLlmClient(IConfiguration configuration)
{
Expand All @@ -20,15 +22,18 @@ public ReActLlmClient(IConfiguration configuration)
_configuration = configuration;
_reActPrompt = _configuration.GetSection("SystemPrompts").GetValue<string>("ReAct")!;
_provider = new OpenAiProvider(_configuration.GetSection("ApiKeys").GetValue<string>("OpenAI")!);
_narratorDebugMode = _configuration.GetValue<bool>("NarrativeChainDebug")!;
}

public async Task<string> GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input)
{
var llm = new Gpt4Model(_provider)
var llm = new Gpt4OmniModel(_provider)
{
Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 }
};
var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary);

var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt,
actionPrompt: actionPrompt, campaign.GameSummary);
var tools = CreateTools(campaign);
foreach (var tool in tools)
{
Expand All @@ -42,13 +47,14 @@ public async Task<string> GetChatCompletionAsync(Campaign campaign, string actio
public async IAsyncEnumerable<string> GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt,
string input)
{
var agentLlm = new Gpt4Model(_provider)
var llm = new Gpt4OmniModel(_provider)
{
Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7 }
};

var eventProcessor = new LlmEventProcessor(agentLlm);
var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary);
var eventProcessor = new LlmEventProcessor(llm);
var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt,
actionPrompt: actionPrompt, campaign.GameSummary);
var tools = CreateTools(campaign);
foreach (var tool in tools)
{
Expand Down Expand Up @@ -81,7 +87,8 @@ private List<AgentTool> CreateTools(Campaign campaign)
"Input to this tool must be in the following RAW JSON format: {\"input\": \"The player's input\", " +
"\"severity\": \"Describes how devastating the injury to the character will be based on the action. " +
"Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " +
"only raw JSON as input. Use this tool only once per character at most.");
"only raw JSON as input. Use this tool only once per character at most and only if they are not engaged " +
"in battle.");
tools.Add(woundCharacterTool);

var healCharacterTool = new HealCharacterTool(_configuration, campaign, utils, "healcharactertool",
Expand All @@ -97,9 +104,39 @@ private List<AgentTool> CreateTools(Campaign campaign)
"Do not use markdown, only raw JSON as input. Use this tool only once per character at most.");
tools.Add(healCharacterTool);

// Use battle when an attack can be mitigated or dodged by the involved participants.
// This tool is appropriate for combat, battle between multiple participants,
// or attacks that can be avoided and a to-hit roll would be needed in order to determine a hit.
var battleTool = new BattleTool(_configuration, campaign, utils, "battletool",
"Use the battle tool to resolve battle or combat between two participants. A participant is " +
"a single character and cannot be a combination of characters. If there are more " +
"than two participants, the tool must be used once per attacker to give everyone a chance at fighting. " +
"The battle tool will give each participant a chance to fight the other participant. The tool should " +
"also be used when an attack can be mitigated or dodged by the involved participants. It is also " +
"possible for either or both participants to miss. A hit chance specifier will help adjust the chance " +
"that a participant gets to retaliate. Example: There are only two combatants. Call the tool only ONCE " +
"since both characters get an attack. Another example: There are three combatants, the Player's character " +
"and two assassins. The battle tool is called first with the Player's character as participant one and " +
"one of the assassins as participant two. Chances are high that the player will hit the assassin but " +
"assassins must be precise, making it harder to hit, however, they deal high damage if they hit. We " +
"observe that the participant one hits participant two and participant two misses participant one. " +
"After this round of battle has been resolved, call the tool again with the Player's character as " +
"participant one and the other assassin as participant two. Since participant one in this case has " +
"already hit once during this narrative, we impose a penalty to their hit chance, which is " +
"accumulative for each time they hit an enemy during battle. The damage severity describes how " +
"powerful the attack is which is derived from the narrative description of the attacks. " +
"If the participants engage in a friendly sparring fight, does not intend to hurt, or does mock battle, " +
"the damage severity is <harmless>. " +
"If there are no direct description, estimate the impact of an attack based on the character type and " +
"their description. Input to this tool must be in the following RAW JSON format: {\"participant1\": " +
"{\"name\": \"<name of participant one>\", \"description\": \"<description of participant one>\"}, " +
"\"participant2\": {\"name\": \"<name of participant two>\", \"description\": " +
"\"<description of participant two>\"}, \"participant1HitChance\": \"<hit chance specifier for " +
"participant one>\", \"participant2HitChance\": \"<hit chance specifier for participant two>\", " +
"\"participant1DamageSeverity\": \"<damage severity for participant one>\", " +
"\"participant2DamageSeverity\": \"<damage severity for participant two>\"} where participant#HitChance " +
"specifiers are one of the following {high, medium, low, impossible} and participant#DamageSeverity is " +
"one of the following {harmless, low, medium, high, extraordinary}. Do not use markdown, only raw JSON as " +
"input. The narrative battle is over when each character has had the chance to attack another character at " +
"most once.");
tools.Add(battleTool);

return tools;
}
Expand Down
58 changes: 58 additions & 0 deletions ChatRPG/API/Tools/BattleInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace ChatRPG.API.Tools;

public class BattleInput
{
private static readonly HashSet<string> ValidChancesToHit =
["high", "medium", "low", "impossible"];

private static readonly HashSet<string> ValidDamageSeverities =
["harmless", "low", "medium", "high", "extraordinary"];

public CharacterInput? Participant1 { get; set; }
public CharacterInput? Participant2 { get; set; }
public string? Participant1HitChance { get; set; }
public string? Participant2HitChance { get; set; }
public string? Participant1DamageSeverity { get; set; }
public string? Participant2DamageSeverity { get; set; }

public bool IsValid(out List<string> validationErrors)
{
validationErrors = [];

if (Participant1 == null)
{
validationErrors.Add("Participant1 is required.");
}
else if (!Participant1.IsValidForBattle(out var participant1Errors))
{
validationErrors.AddRange(participant1Errors.Select(e => $"Participant1: {e}"));
}

if (Participant2 == null)
{
validationErrors.Add("Participant2 is required.");
}
else if (!Participant2.IsValidForBattle(out var participant2Errors))
{
validationErrors.AddRange(participant2Errors.Select(e => $"Participant2: {e}"));
}

if (Participant1HitChance != null && !ValidChancesToHit.Contains(Participant1HitChance))
validationErrors.Add(
"Participant1ChanceToHit must be one of the following: high, medium, low, impossible.");

if (Participant2HitChance != null && !ValidChancesToHit.Contains(Participant2HitChance))
validationErrors.Add(
"Participant2ChanceToHit must be one of the following: high, medium, low, impossible.");

if (Participant1DamageSeverity != null && !ValidDamageSeverities.Contains(Participant1DamageSeverity))
validationErrors.Add(
"Participant1DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary.");

if (Participant2DamageSeverity != null && !ValidDamageSeverities.Contains(Participant2DamageSeverity))
validationErrors.Add(
"Participant2DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary.");

return validationErrors.Count == 0;
}
}
174 changes: 174 additions & 0 deletions ChatRPG/API/Tools/BattleTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using System.Text;
using System.Text.Json;
using ChatRPG.Data.Models;
using LangChain.Chains.StackableChains.Agents.Tools;

namespace ChatRPG.API.Tools;

public class BattleTool(
IConfiguration configuration,
Campaign campaign,
ToolUtilities utilities,
string name,
string? description = null) : AgentTool(name, description)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};

private static readonly Dictionary<string, double> HitChance = new()
{
{ "high", 0.9 },
{ "medium", 0.5 },
{ "low", 0.3 },
{ "impossible", 0.01 }
};

private static readonly Dictionary<string, (int, int)> DamageRanges = new()
{
{ "harmless", (0, 1) },
{ "low", (5, 10) },
{ "medium", (10, 20) },
{ "high", (15, 25) },
{ "extraordinary", (25, 80) }
};

public override async Task<string> ToolTask(string input, CancellationToken token = new CancellationToken())
{
try
{
var battleInput = JsonSerializer.Deserialize<BattleInput>(ToolUtilities.RemoveMarkdown(input), JsonOptions) ??
throw new JsonException("Failed to deserialize");
var instruction = configuration.GetSection("SystemPrompts").GetValue<string>("BattleInstruction")!;

if (!battleInput.IsValid(out var errors))
{
var errorMessage = new StringBuilder();
errorMessage.Append(
"Invalid input provided for the battle. Please provide valid input and correct the following errors:\n");
foreach (var validationError in errors)
{
errorMessage.Append(validationError + "\n");
}

return errorMessage.ToString();
}

var participant1 = await utilities.FindCharacter(campaign,
$"{{\"name\": \"{battleInput.Participant1!.Name}\", " +
$"\"description\": \"{battleInput.Participant1.Description}\"}}", instruction);
var participant2 = await utilities.FindCharacter(campaign,
$"{{\"name\": \"{battleInput.Participant2!.Name}\", " +
$"\"description\": \"{battleInput.Participant2.Description}\"}}", instruction);

// Create dummy characters if they do not exist and pray that the archive chain will update them
participant1 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid,
battleInput.Participant1.Name!, battleInput.Participant1.Description!, false);
participant2 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid,
battleInput.Participant2.Name!, battleInput.Participant2.Description!, false);

var firstHitter = DetermineFirstHitter(participant1, participant2);

Character secondHitter;
string firstHitChance;
string secondHitChance;
string firstHitSeverity;
string secondHitSeverity;

if (firstHitter == participant1)
{
secondHitter = participant2;
firstHitChance = battleInput.Participant1HitChance!;
secondHitChance = battleInput.Participant2HitChance!;
firstHitSeverity = battleInput.Participant1DamageSeverity!;
secondHitSeverity = battleInput.Participant2DamageSeverity!;
}
else
{
secondHitter = participant1;
firstHitChance = battleInput.Participant2HitChance!;
secondHitChance = battleInput.Participant1HitChance!;
firstHitSeverity = battleInput.Participant2DamageSeverity!;
secondHitSeverity = battleInput.Participant1DamageSeverity!;
}

return ResolveCombat(firstHitter, secondHitter, firstHitChance, secondHitChance, firstHitSeverity,
secondHitSeverity) + $" {firstHitter.Name} and {secondHitter.Name}'s battle has " +
"been resolved and this pair can not be used for the battle tool again.";
}
catch (Exception)
{
return
"Could not execute the battle. Tool input format was invalid. " +
"Please provide the input in valid JSON.";
}
}

private static string ResolveCombat(Character firstHitter, Character secondHitter, string firstHitChance,
string secondHitChance, string firstHitSeverity, string secondHitSeverity)
{
var resultString = $"{firstHitter.Name} described as \"{firstHitter.Description}\" fights " +
$"{secondHitter.Name} described as \"{secondHitter.Description}\"\n";


resultString += ResolveAttack(firstHitter, secondHitter, firstHitChance, firstHitSeverity);
if (secondHitter.CurrentHealth <= 0)
{
return resultString;
}

resultString += " " + ResolveAttack(secondHitter, firstHitter, secondHitChance, secondHitSeverity);

return resultString;
}

private static string ResolveAttack(Character damageDealer, Character damageTaker, string hitChance,
string hitSeverity)
{
var resultString = string.Empty;
Random rand = new Random();
var doesAttackHit = rand.NextDouble() <= HitChance[hitChance];

if (doesAttackHit)
{
var (minDamage, maxDamage) = DamageRanges[hitSeverity];
var damage = rand.Next(minDamage, maxDamage);
resultString += $"{damageDealer.Name} deals {damage} damage to {damageTaker.Name}. ";
if (damageTaker.AdjustHealth(-damage))
{
if (damageTaker.IsPlayer)
{
return resultString + $"The player {damageTaker.Name} has no remaining health points. " +
"Their adventure is over. No more actions can be taken.";
}

return resultString +
$"With no health points remaining, {damageTaker.Name} dies and can no longer " +
"perform actions in the narrative.";
}

return resultString +
$"They have {damageTaker.CurrentHealth} health points out of {damageTaker.MaxHealth} remaining.";
}

return $"{damageDealer.Name} misses their attack on {damageTaker.Name}.";
}

private static Character DetermineFirstHitter(Character participant1, Character participant2)
{
var rand = new Random();
var firstHitRoll = rand.NextDouble();

return (participant1.Type - participant2.Type) switch
{
0 => firstHitRoll <= 0.5 ? participant1 : participant2,
1 => firstHitRoll <= 0.4 ? participant1 : participant2,
2 => firstHitRoll <= 0.3 ? participant1 : participant2,
>= 3 => firstHitRoll <= 0.2 ? participant1 : participant2,
-1 => firstHitRoll <= 0.6 ? participant1 : participant2,
-2 => firstHitRoll <= 0.7 ? participant1 : participant2,
<= -3 => firstHitRoll <= 0.8 ? participant1 : participant2
};
}
}
Loading
Loading