diff --git a/client/src/index.html b/client/src/index.html index 220c08b..55bc915 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -1,7 +1,7 @@ - Text World + TaleWeave AI diff --git a/prompts/discord-en-us.yml b/prompts/discord-en-us.yml new file mode 100644 index 0000000..c65dd7a --- /dev/null +++ b/prompts/discord-en-us.yml @@ -0,0 +1,24 @@ +prompts: + discord_help: | + **Commands:** + - `!help` - Show this help message + - `!{{ bot_name }}` - Show the active world + - `!join ` - Join the game as the specified character + - `!leave` - Leave the game + discord_join_error_none: You must specify a character! + discord_join_error_not_found: Character {{ character }} was not found! + discord_join_error_taken: Someone is already playing as {{ character }}! + discord_join_result: | + {{ event.client }} is now playing as {{ event.character }}! + discord_join_title: | + Player Joined + discord_leave_error_none: You are not playing the game yet! + discord_leave_result: | + {{ event.client }} has left the game! {{ event.character }} is now being played by an LLM. + discord_leave_title: | + Player Left + discord_user_new: | + You are not playing the game yet! Use `!join ` to start playing. + discord_world_active: | + Hello! Welcome to {{ bot_name }}. The active world is `{{ world.name }}` (theme: {{ world.theme }}) + discord_world_none: Hello! Welcome to {{ bot_name }}. There is no active world yet. \ No newline at end of file diff --git a/prompts/llama-base.yml b/prompts/llama-base.yml new file mode 100644 index 0000000..0f652eb --- /dev/null +++ b/prompts/llama-base.yml @@ -0,0 +1,378 @@ +prompts: + # base actions + action_examine_error_target: | + You cannot examine the {{target}} because it is not in the room. + action_examine_broadcast_action: | + {{action_character | name}} looks at {{target}}. + action_examine_broadcast_character: | + {{action_character | name}} saw {{target_character | name}} in the {{action_room | name}} room. + action_examine_broadcast_inventory: | + {{action_character | name}} saw the {{target_item | name}} item in their inventory. + action_examine_broadcast_item: | + {{action_character | name}} saw the {{target_item | name}} item in the {{action_room | name}} room. + action_examine_broadcast_room: | + {{action_character | name}} saw the {{action_room | name}} room. + action_examine_result_character: | + You examine the {{target_character | name}}. {{ target_character | describe }}. + action_examine_result_inventory: | + You examine the {{target_item | name}}. {{target_item | describe}}. + action_examine_result_item: | + You examine the {{target_item | name}}. {{target_item | describe}}. + action_examine_result_room: | + You examine the {{target_room | name}}. {{target_room | describe}}. + + action_move_error_direction: | + {{direction}} is not an exit from this room. Please choose a valid direction: {{portals}}. + action_move_error_room: | + You cannot move through {{direction}}, it does not lead anywhere. + action_move_broadcast: | + {{action_character | name}} moves through {{direction}} to {{dest_room | name}}. + action_move_result: | + You move through {{direction}} to {{dest_room | name}}. + + action_take_error_item: | + You cannot take the {{item}} item because it is not in the room. + action_take_broadcast: | + {{action_character | name}} picks up the {{item}} item. + action_take_result: | + You pick up the {{item}} item and put it in your inventory. + + action_ask_error_self: | + You cannot ask yourself a question. Stop talking to yourself. Try another action or a different character. + action_ask_error_target: | + You cannot ask {{character}} a question because they are not in the room. + action_ask_error_agent: | + You cannot ask {{character}} a question because they are not a character. + action_ask_broadcast: | + {{action_character | name}} asks {{character}}: {{question}}. + action_ask_conversation_first: | + {{last_character | name}} asks you: {{response}} + Reply with your response to them. Reply with 'END' to end the conversation. + Do not include the question or any JSON. Only include your answer for {{last_character | name}}. + action_ask_conversation_reply: | + {{last_character | name}} continues the conversation with you. They reply: {{response}} + Reply with your response to them. Reply with 'END' to end the conversation. + Do not include the question or any JSON. Only include your answer for {{last_character | name}}. + action_ask_conversation_end: | + {{last_character | name}} ends the conversation for now. + action_ask_ignore: | + {{character}} does not respond. + + action_tell_error_self: | + You cannot tell yourself a message. Stop talking to yourself. Try taking notes during your planning phase instead. + action_tell_error_target: | + You cannot tell {{character}} a message because they are not in the room. + action_tell_error_agent: | + You cannot tell {{character}} a message because they are not a character. + action_tell_broadcast: | + {{action_character | name}} tells {{character}}: {{message}}. + action_tell_conversation_first: | + {{last_character | name}} starts a conversation with you. They say: {{response}} + Reply with your response to them. Reply with 'END' to end the conversation. + Do not include the question or any JSON. Only include your answer for {{last_character | name}}. + action_tell_conversation_reply: | + {{last_character | name}} continues the conversation with you. They reply: {{response}} + Reply with your response to them. Reply with 'END' to end the conversation. + Do not include the question or any JSON. Only include your answer for {{last_character | name}}. + action_tell_conversation_end: | + {{last_character | name}} ends the conversation for now. + action_tell_ignore: | + {{character}} does not respond. + + action_give_error_target: | + You cannot give the {{item}} item to {{character}} because they are not in the room. + action_give_error_self: | + You cannot give the {{item}} item to yourself. Try giving it to another character in the room. + action_give_error_item: | + You cannot give the {{item}} item because it is not in your inventory or in the room. + action_give_broadcast: | + {{action_character | name}} gives the {{item}} item to {{character}}. + action_give_result: | + You give the {{item}} item to {{character}}. + + action_drop_error_item: | + You cannot drop the {{item}} item because it is not in your inventory. + action_drop_broadcast: | + {{action_character | name}} drops the {{item}} item. + action_drop_result: | + You drop the {{item}} item. + + # optional actions + action_explore_error_direction: | + You cannot explore {{direction}} from here, that direction already leads to {{dest_room}}. Please use action_move to go there. + action_explore_error_generating: | + You cannot explore {{direction}} from here, something strange happened and nothing exists in that direction. + action_explore_broadcast: | + {{action_character | name}} explores {{direction}} from {{action_room | name}} and finds a new room: {{new_room | name}}. + action_explore_result: | + You explore {{direction}} and find a new room: {{new_room | name}}. + + action_search_error_full: | + You find nothing hidden in the room. There is no room for more items. + action_search_error_generating: | + You find nothing hidden in the room. Something strange happened and the item you were looking for is not there. + action_search_broadcast: | + {{action_character | name}} searches the room and finds a new item: {{new_item | name}}. + action_search_result: | + You search the room and find a new item: {{new_item | name}}. + + action_use_error_cooldown: | + You cannot use the {{item}} item again so soon. Please wait a bit before trying again. + action_use_error_exhausted: | + You cannot use the {{item}} item anymore. It has been used too many times. + action_use_error_item: | + The {{item}} item is not available in your inventory or in the room. + action_use_error_target: | + The {{target}} is not in the room, so you cannot use the {{item}} item on it. + action_use_broadcast: | + {{action_character | name}} uses {{item}} on {{target}} and applies the {{effect}} effect. + action_use_dm_effect: | + {{action_character | name}} uses {{item}} on {{target}}. {{item}} can apply any of the following effects: {{effect_names}}. + Which effect should be applied? Specify the effect. Do not include the question or any JSON. Only reply with the effect name. + action_use_dm_outcome: | + {{action_character | name}} uses {{item}} on {{target}} and applies the {{effect | name}} effect. + {{action_character | describe}}. {{target_character | describe}}. + {{action_item | describe}}. What happens? How does {{target_character | name}} react? What is the outcome? + Be creative with the results. The outcome can be positive, negative, or neutral. Describe one possible outcome + based on the characters, items, and effects involved. Do not include the question or any JSON. Only reply with the outcome. + + # planning actions + action_take_note_error_limit: | + You have reached the maximum number of notes. Please delete or summarize some of your existing notes before adding more. + action_take_note_error_length: | + The note is too long. Please keep notes under 200 characters. + action_take_note_error_duplicate: | + You already have a note about that fact. If you want to update the note, please edit or summarize the existing note. + action_take_note_result: | + You make a note of that fact. + + action_erase_notes_error_empty: | + You have no notes to erase. + action_erase_notes_error_match: | + You have no notes that match that text. + action_erase_notes_result: | + You erased {{count}} notes. + + action_edit_note_error_empty: | + You have no notes to edit. + action_edit_note_error_match: | + You have no notes that match that text. + action_edit_note_result: | + You edited that note. + + action_summarize_notes_error_empty: | + You have no notes to summarize. + action_summarize_notes_error_limit: | + You still have too many notes. Please condense them further, you can only have up to {{limit}} notes. + action_summarize_notes_prompt: | + Please summarize your notes. Remove any duplicates and combine similar notes. + If a newer note contradicts an older note, keep the newer note. + Clean up your notes so you can focus on the most important facts. + Respond with one note per line. You can have up to {limit} notes, + so make sure you reply with less than {limit} lines. Do not number the lines + in your response. Do not include any JSON or other information. + Your notes are:\n{notes} + action_summarize_notes_result: | + You summarized your notes. + + action_schedule_event_error_name: | + The event must have a name. + action_schedule_event_result: | + You scheduled an event that will happen in {{turns}} turns. + + action_check_calendar_empty: | + You have no upcoming events on your calendar. You can plan events with other characters during your turn. + Make sure you inform the other characters about the event so they can plan accordingly. + action_check_calendar_each: | + {{event.name}} will happen in {{turns}} turn + + # digest system + digest_action_move: | + {{event.character | name}} entered the room. + digest_action_take: | + {{event.character | name}} picked up the {{event.parameters[item]}}. + digest_action_give: | + {{event.character | name}} gave the {{event.parameters[item]}} to {{event.parameters[character]}}. + digest_action_drop: | + {{event.character | name}} dropped the {{event.parameters[item]}}. + digest_action_ask: | + {{event.character | name}} asked {{event.parameters[character]}} about something. + digest_action_tell: | + {{event.character | name}} told {{event.parameters[character]}} about something. + digest_action_examine: | + {{event.character | name}} examined the {{event.parameters[target]}}. + + # world defaults + world_default_dungeon_master: | + You are the dungeon master in charge of creating an engaging fantasy world full of interesting characters who + interact with each other and explore their environment. Be creative and original, creating a world that is + visually detailed and full of curious details. Do not repeat yourself unless you are given the same prompt with + the same characters, room, and context. + + # world generation + world_generate_dungeon_master: | + You are an experienced dungeon master creating a visually detailed world for a new adventure. Be creative and + original, creating a world that is visually detailed and full of curious details. Do not repeat yourself unless you + are given the same prompt with the same characters, room, and context. {{flavor}}. The theme is: + {{theme}}. + + world_generate_world_broadcast_theme: | + Generating a {{theme}} with {{room_count}} rooms + + world_generate_room_name: | + Generate one room, area, or location that would make sense in the world of {{world_theme}}. + Only respond with the room name in title case, do not include the description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. The existing rooms are: {{existing_rooms}} + world_generate_room_description: | + Generate a detailed description of the {{name}} area. What does it look like? + What does it smell like? What can be seen or heard? + world_generate_room_broadcast_room: | + Generating room: {{name}} + world_generate_room_broadcast_items: | + Generating {{item_count}} items for room: {{name}} + world_generate_room_broadcast_characters: | + Generating {{character_count}} characters for room: {{name}} + + world_generate_portal_name_outgoing: | + Generate the name of a portal that leads from the {{source_room}} room to the {{dest_room}} room and fits the world theme of {{world_theme}}. + Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. + Only respond with the portal name in title case, do not include a description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. + Do not create any duplicate portals in the same room. The existing portals are: {{existing_portals}} + world_generate_portal_name_incoming: | + Generate the opposite name of the portal that leads from the {{dest_room}} room to the {{source_room}} room. + The name should be the opposite of the {{outgoing_name}} portal and should fit the world theme of {{world_theme}}. + Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. + Only respond with the portal name in title case, do not include a description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. + Do not create any duplicate portals in the same room. The existing portals are: {{existing_portals}} + world_generate_portal_broadcast_outgoing: | + Generating portal: {{outgoing_name}} + world_generate_portal_broadcast_incoming: | + Linking {{outgoing_name}} to {{incoming_name}} + + world_generate_item_name: | + Generate a new item or object that would make sense in the world of {{world_theme}}. {{dest_note}}. + Only respond with the item name in title case, do not include the description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. + Do not create any duplicate items in the same room. Do not give characters the same item more than once. + The existing items are: {{existing_items}} + world_generate_item_description: | + Generate a detailed description of the {{name}} item. What does it look like? + What is it made of? What is its purpose or function? + world_generate_item_broadcast_item: | + Generating item: {{name}} + world_generate_item_broadcast_effects: | + Generating {{effect_count}} effects for item: {{name}} + + world_generate_effect_name: | + Generate one effect for an {{entity_type}} named {{entity_name}} that would make sense in the world of {{theme}}. + Only respond with the effect name in title case, do not include a description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. + Do not create any duplicate effects on the same item. The existing effects are: {{existing_effects}}. + Some example effects are: 'fire', 'poison', 'frost', 'haste', 'slow', and 'heal'. + world_generate_effect_description: | + Generate a detailed description of the {{name}} effect. What does it look like? + What does it do? How does it affect the target? Describe the effect from the perspective of an outside observer. + world_generate_effect_application: | + How should the {{name}} effect be applied? Respond with 'temporary' for a temporary effect that lasts for a duration, + or 'permanent' for a permanent effect that immediately modifies the target. + For example, a healing potion would be a permanent effect that increases health every turn, + while bleeding would be a temporary effect that decreases health every turn. + A haste potion would be a temporary effect that increases speed for a duration, + while a slow spell would be a temporary effect that decreases speed for a duration. + Do not include any other text. Do not use JSON. + world_generate_effect_cooldown: | + How many turns should the {{name}} effect wait before it can be used again? + Enter a positive number to set a cooldown, or 0 for no cooldown. + Do not include any other text. Do not use JSON. + world_generate_effect_duration: | + How many turns does the {{name}} effect last? Enter a positive number to set a duration, or 0 for an instant effect. + Do not include any other text. Do not use JSON. + world_generate_effect_uses: | + How many times can the {{name}} effect be used before it is exhausted? + Enter a positive number to set a limit, or -1 for unlimited uses. + Do not include any other text. Do not use JSON. + world_generate_effect_attribute_names: | + Generate a short list of attributes that the {{name}} effect modifies. Include 1 to 3 attributes. + For example, 'heal' increases the target's 'health' attribute, while 'poison' decreases it. + Use a comma-separated list of attribute names, such as 'health, strength, speed'. + Only include the attribute names, do not include the question or any JSON. + world_generate_effect_attribute_value: | + How much does the {{name}} effect modify the {{attribute_name}} attribute? + For example, heal might add 10 to the health attribute, while poison might remove -5 from it. + Enter a positive number to increase the attribute or a negative number to decrease it. + Do not include any other text. Do not use JSON. + world_generate_effect_broadcast_effect: | + Generating effect: {{name}} + world_generate_effect_error_application: | + The application must be either 'temporary' or 'permanent'. + + world_generate_character_name: | + Generate a new character that would make sense in the world of {{world_theme}}. + Characters can be a person, creature, or some other intelligent entity. + The character will be placed in the {{dest_room}} room. {{additional_prompt}}. + Only respond with the character name in title case, do not include a description or any other text. + Do not prefix the name with "the", do not wrap it in quotes. + Do not include the name of the room. Do not give characters any duplicate names. + Do not create any duplicate characters. The existing characters are: {{existing_characters}} + world_generate_character_description: | + Generate a detailed description of {{name}}. {{detail_prompt}}. What do they look like? What are they wearing? + What are they doing? Describe their appearance and demeanor from the perspective of an outside observer. + Do not include the room or any other characters in the description, because they will change over time. + world_generate_character_backstory: | + Generate a backstory for the {{name}} character. {{additional_prompt}}. {{detail_prompt}}. Where are they from? + What are they doing here? What are their goals? + Make sure to phrase the backstory in the second person, starting with "you are" and speaking directly to {{name}}. + world_generate_character_broadcast_name: | + Generating character: {{name}} + world_generate_character_broadcast_items: | + Generating {{item_count}} items for character: {{name}} + + world_generate_link_broadcast_portals: | + Generating {{portal_count}} portals for room: {{name}} + + world_generate_error_name_exists: | + The name '{{name}}' already exists in the world. Please generate a unique name. + world_generate_error_name_json: | + The name '{{name}}' is not valid. The name cannot contain any JSON or function calls. + world_generate_error_name_punctuation: | + The name '{{name}}' is not valid. The name cannot contain any quotes, colons, or other sentence punctuation. + Apostrophes are allowed in names like "O'Connell" or "D'Artagnan". + world_generate_error_name_length: | + The name '{{name}}' is too long. Please generate a shorter name with fewer than 50 characters. + + # world simulation + world_simulate_character_action: | + You are currently in the {{room_name}} room. {{room_description}}. {{attributes}}. + The room contains the following characters: {{visible_characters}}. + The room contains the following items: {{visible_items}}. + Your inventory contains the following items: {{character_items}}. + You can take the following actions: {{actions}}. + You can move in the following directions: {{directions}}. + {{notes_prompt}} {{events_prompt}} + What will you do next? Reply with a JSON function call, calling one of the actions. + You can only perform one action per turn. What is your next action? + + world_simulate_character_planning: | + You are about to start your turn. Plan your next action carefully. Take notes and schedule events to help keep track of your goals. + You can check your notes for important facts or check your calendar for upcoming events. You have {{note_count}} notes. + If you have plans with other characters, schedule them on your calendar. You have {{event_count}} events on your calendar. + {{room_summary}} + Think about your goals and any quests that you are working on, and plan your next action accordingly. + Try to keep your notes accurate and up-to-date. Replace or erase old notes when they are no longer accurate or useful. + Do not keeps notes about upcoming events, use your calendar for that. + You can perform up to 3 planning actions in a single turn. When you are done planning, reply with 'END'. + {{notes_prompt}} {{events_prompt}} + world_simulate_character_planning_done: | + You are done planning your turn. + world_simulate_character_planning_notes_some: | + Your recent notes are: {{notes}} + world_simulate_character_planning_notes_none: | + You have no recent notes. + world_simulate_character_planning_events_some: | + Your upcoming events are: {{events}} + world_simulate_character_planning_events_none: | + You have no upcoming events. + world_simulate_character_planning_events_item: | + {{event.name}} in {{turns}} turns \ No newline at end of file diff --git a/prompts/llama-quest.yml b/prompts/llama-quest.yml new file mode 100644 index 0000000..d5c79ca --- /dev/null +++ b/prompts/llama-quest.yml @@ -0,0 +1,18 @@ +prompts: + action_accept_quest_error_none: No quests are available at the moment. + action_accept_quest_error_name: | + {{character}} does not have a quest named "{{quest_name}}". + action_accept_quest_error_room: | + {{character}} is not in the room. + action_accept_quest_result: | + You have started the quest "{{quest_name}}". + + action_submit_quest_error_active: | + You do not have any active quests. + action_submit_quest_error_none: No quests are available at the moment. + action_submit_quest_error_name: | + {{character}} does not have a quest named "{{quest_name}}". + action_submit_quest_error_room: | + {{character}} is not in the room. + action_submit_quest_result: | + You have completed the quest "{{quest_name}}". \ No newline at end of file diff --git a/taleweave/actions/base.py b/taleweave/actions/base.py index 104a099..a852fee 100644 --- a/taleweave/actions/base.py +++ b/taleweave/actions/base.py @@ -5,10 +5,12 @@ from taleweave.context import ( broadcast, get_agent_for_character, get_character_agent_for_name, + get_prompt, world_context, ) from taleweave.errors import ActionError from taleweave.utils.conversation import loop_conversation +from taleweave.utils.prompt import format_prompt from taleweave.utils.search import ( find_character_in_room, find_item_in_character, @@ -17,7 +19,6 @@ from taleweave.utils.search import ( find_room, ) from taleweave.utils.string import normalize_name -from taleweave.utils.world import describe_entity logger = getLogger(__name__) @@ -33,34 +34,65 @@ def action_examine(target: str) -> str: """ with action_context() as (action_room, action_character): - broadcast(f"{action_character.name} looks at {target}") + broadcast( + format_prompt( + "action_examine_broadcast_action", + action_character=action_character, + target=target, + ) + ) if normalize_name(target) == normalize_name(action_room.name): - broadcast(f"{action_character.name} saw the {action_room.name} room") - return describe_entity(action_room) + broadcast( + format_prompt( + "action_examine_broadcast_room", + action_character=action_character, + action_room=action_room, + ) + ) + return format_prompt("action_examine_result_room", action_room=action_room) target_character = find_character_in_room(action_room, target) if target_character: broadcast( - f"{action_character.name} saw the {target_character.name} character in the {action_room.name} room" + format_prompt( + "action_examine_broadcast_character", + action_character=action_character, + action_room=action_room, + target_character=target_character, + ) + ) + return format_prompt( + "action_examine_result_character", target_character=target_character ) - return describe_entity(target_character) target_item = find_item_in_room(action_room, target) if target_item: broadcast( - f"{action_character.name} saw the {target_item.name} item in the {action_room.name} room" + format_prompt( + "action_examine_broadcast_item", + action_character=action_character, + action_room=action_room, + target_item=target_item, + ) ) - return describe_entity(target_item) + return format_prompt("action_examine_result_item", target_item=target_item) target_item = find_item_in_character(action_character, target) if target_item: broadcast( - f"{action_character.name} saw the {target_item.name} item in their inventory" + format_prompt( + "action_examine_broadcast_inventory", + action_character=action_character, + action_room=action_room, + target_item=target_item, + ) + ) + return format_prompt( + "action_examine_result_inventory", target_item=target_item ) - return describe_entity(target_item) - return "You do not see that item or character in the room." + return format_prompt("action_examine_error_target", target=target) def action_move(direction: str) -> str: @@ -74,20 +106,36 @@ def action_move(direction: str) -> str: with world_context() as (action_world, action_room, action_character): portal = find_portal_in_room(action_room, direction) if not portal: - raise ActionError(f"You cannot move {direction} from here.") + portals = [p.name for p in action_room.portals] + raise ActionError( + format_prompt( + "action_move_error_direction", direction=direction, portals=portals + ) + ) - destination_room = find_room(action_world, portal.destination) - if not destination_room: - raise ActionError(f"The {portal.destination} room does not exist.") + dest_room = find_room(action_world, portal.destination) + if not dest_room: + raise ActionError( + format_prompt( + "action_move_error_room", + direction=direction, + destination=portal.destination, + ) + ) broadcast( - f"{action_character.name} moves through {direction} to {destination_room.name}" + format_prompt( + "action_move_broadcast", + action_character=action_character, + dest_room=dest_room, + direction=direction, + ) ) action_room.characters.remove(action_character) - destination_room.characters.append(action_character) + dest_room.characters.append(action_character) - return ( - f"You move through the {direction} and arrive at {destination_room.name}." + return format_prompt( + "action_move_result", direction=direction, dest_room=dest_room ) @@ -101,12 +149,20 @@ def action_take(item: str) -> str: with action_context() as (action_room, action_character): action_item = find_item_in_room(action_room, item) if not action_item: - raise ActionError(f"The {item} item is not in the room.") + raise ActionError(format_prompt("action_take_error_item", item=item)) - broadcast(f"{action_character.name} takes the {item} item") + broadcast( + format_prompt( + "action_take_broadcast", + action_character=action_character, + action_room=action_room, + item=item, + ) + ) action_room.items.remove(action_item) action_character.items.append(action_item) - return f"You take the {item} item and put it in your inventory." + + return format_prompt("action_take_result", item=item) def action_ask(character: str, question: str) -> str: @@ -122,27 +178,31 @@ def action_ask(character: str, question: str) -> str: # sanity checks question_character, question_agent = get_character_agent_for_name(character) if question_character == action_character: - raise ActionError( - "You cannot ask yourself a question. Stop talking to yourself. Try another action." - ) + raise ActionError(format_prompt("action_ask_error_self")) if not question_character: - raise ActionError(f"The {character} character is not in the room.") + raise ActionError( + format_prompt("action_ask_error_target", character=character) + ) if not question_agent: - raise ActionError(f"The {character} character does not exist.") + raise ActionError( + format_prompt("action_ask_error_agent", character=character) + ) - broadcast(f"{action_character.name} asks {character}: {question}") - first_prompt = ( - "{last_character.name} asks you: {response}\n" - "Reply with your response to them. Reply with 'END' to end the conversation. " - "Do not include the question or any JSON. Only include your answer for {last_character.name}." - ) - reply_prompt = ( - "{last_character.name} continues the conversation with you. They reply: {response}\n" - "Reply with your response to them. Reply with 'END' to end the conversation. " - "Do not include the question or any JSON. Only include your answer for {last_character.name}." + # TODO: make sure they are in the same room + + broadcast( + format_prompt( + "action_ask_broadcast", + action_character=action_character, + character=character, + question=question, + ) ) + first_prompt = get_prompt("action_ask_conversation_first") + reply_prompt = get_prompt("action_ask_conversation_reply") + end_prompt = get_prompt("action_ask_conversation_end") action_agent = get_agent_for_character(action_character) result = loop_conversation( @@ -153,7 +213,7 @@ def action_ask(character: str, question: str) -> str: first_prompt, reply_prompt, question, - "Goodbye", + end_prompt, echo_function=action_tell.__name__, echo_parameter="message", max_length=MAX_CONVERSATION_STEPS, @@ -162,7 +222,7 @@ def action_ask(character: str, question: str) -> str: if result: return result - return f"{character} does not respond." + return format_prompt("action_ask_ignore", character=character) def action_tell(character: str, message: str) -> str: @@ -179,27 +239,22 @@ def action_tell(character: str, message: str) -> str: # sanity checks question_character, question_agent = get_character_agent_for_name(character) if question_character == action_character: - raise ActionError( - "You cannot tell yourself a message. Stop talking to yourself. Try another action." - ) + raise ActionError(format_prompt("action_tell_error_self")) if not question_character: - raise ActionError(f"The {character} character is not in the room.") + raise ActionError( + format_prompt("action_tell_error_target", character=character) + ) if not question_agent: - raise ActionError(f"The {character} character does not exist.") + raise ActionError( + format_prompt("action_tell_error_agent", character=character) + ) broadcast(f"{action_character.name} tells {character}: {message}") - first_prompt = ( - "{last_character.name} starts a conversation with you. They say: {response}\n" - "Reply with your response to them. " - "Do not include the message or any JSON. Only include your reply to {last_character.name}." - ) - reply_prompt = ( - "{last_character.name} continues the conversation with you. They reply: {response}\n" - "Reply with your response to them. " - "Do not include the message or any JSON. Only include your reply to {last_character.name}." - ) + first_prompt = get_prompt("action_tell_conversation_first") + reply_prompt = get_prompt("action_tell_conversation_reply") + end_prompt = get_prompt("action_tell_conversation_end") action_agent = get_agent_for_character(action_character) result = loop_conversation( @@ -210,7 +265,7 @@ def action_tell(character: str, message: str) -> str: first_prompt, reply_prompt, message, - "Goodbye", + end_prompt, echo_function=action_tell.__name__, echo_parameter="message", max_length=MAX_CONVERSATION_STEPS, @@ -219,7 +274,7 @@ def action_tell(character: str, message: str) -> str: if result: return result - return f"{character} does not respond." + return format_prompt("action_tell_ignore", character=character) def action_give(character: str, item: str) -> str: @@ -233,22 +288,29 @@ def action_give(character: str, item: str) -> str: with action_context() as (action_room, action_character): destination_character = find_character_in_room(action_room, character) if not destination_character: - raise ActionError(f"The {character} character is not in the room.") + raise ActionError( + format_prompt("action_give_error_target", character=character) + ) if destination_character == action_character: - raise ActionError( - "You cannot give an item to yourself. Try another action." - ) + raise ActionError(format_prompt("action_give_error_self")) action_item = find_item_in_character(action_character, item) if not action_item: - raise ActionError(f"You do not have the {item} item in your inventory.") + raise ActionError(format_prompt("action_give_error_item", item=item)) - broadcast(f"{action_character.name} gives {character} the {item} item.") + broadcast( + format_prompt( + "action_give_broadcast", + action_character=action_character, + character=character, + item=item, + ) + ) action_character.items.remove(action_item) destination_character.items.append(action_item) - return f"You give the {item} item to {character}." + return format_prompt("action_give_result", character=character, item=item) def action_drop(item: str) -> str: @@ -262,10 +324,14 @@ def action_drop(item: str) -> str: with action_context() as (action_room, action_character): action_item = find_item_in_character(action_character, item) if not action_item: - raise ActionError(f"You do not have the {item} item in your inventory.") + raise ActionError(format_prompt("action_drop_error_item", item=item)) - broadcast(f"{action_character.name} drops the {item} item") + broadcast( + format_prompt( + "action_drop_broadcast", action_character=action_character, item=item + ) + ) action_character.items.remove(action_item) action_room.items.append(action_item) - return f"You drop the {item} item." + return format_prompt("action_drop_result", item=item) diff --git a/taleweave/actions/optional.py b/taleweave/actions/optional.py index 740eadf..0883ef1 100644 --- a/taleweave/actions/optional.py +++ b/taleweave/actions/optional.py @@ -15,11 +15,17 @@ from taleweave.context import ( world_context, ) from taleweave.errors import ActionError -from taleweave.generate import generate_item, generate_room, link_rooms +from taleweave.generate import ( + generate_item, + generate_portals, + generate_room, + link_rooms, +) from taleweave.utils.effect import apply_effects, is_effect_ready +from taleweave.utils.prompt import format_prompt from taleweave.utils.search import find_character_in_room from taleweave.utils.string import normalize_name -from taleweave.utils.world import describe_character, describe_entity +from taleweave.utils.world import describe_entity logger = getLogger(__name__) @@ -29,7 +35,7 @@ if not has_dungeon_master(): set_dungeon_master( Agent( "dungeon master", - "You are the dungeon master in charge of a fantasy world.", + format_prompt("world_default_dungeon_master"), {}, llm, ) @@ -50,8 +56,11 @@ def action_explore(direction: str) -> str: if direction in action_room.portals: dest_room = action_room.portals[direction] raise ActionError( - f"You cannot explore {direction} from here, that direction already leads to {dest_room}. " - "Please use the move action to go there." + format_prompt( + "action_explore_error_direction", + direction=direction, + dest_room=dest_room, + ) ) try: @@ -59,16 +68,34 @@ def action_explore(direction: str) -> str: new_room = generate_room(dungeon_master, action_world, systems) action_world.rooms.append(new_room) - # link the rooms together + # link the rooms together, starting with the current room + outgoing_portal, incoming_portal = generate_portals( + dungeon_master, + action_world, + action_room, + new_room, + systems, + outgoing_name=direction, + ) + action_room.portals.append(outgoing_portal) + new_room.portals.append(incoming_portal) link_rooms(dungeon_master, action_world, systems, [new_room]) broadcast( - f"{action_character.name} explores {direction} of {action_room.name} and finds a new room: {new_room.name}" + format_prompt( + "action_explore_broadcast", + action_character=action_character, + action_room=action_room, + direction=direction, + new_room=new_room, + ) + ) + return format_prompt( + "action_explore_result", direction=direction, new_room=new_room ) - return f"You explore {direction} and find a new room: {new_room.name}" except Exception: logger.exception("error generating room") - return f"You cannot explore {direction} from here, there is no room in that direction." + return format_prompt("action_explore_error_generating", direction=direction) def action_search(unused: bool) -> str: @@ -80,9 +107,7 @@ def action_search(unused: bool) -> str: dungeon_master = get_dungeon_master() if len(action_room.items) > 2: - return ( - "You find nothing hidden in the room. There is no room for more items." - ) + return format_prompt("action_search_error_full") try: systems = get_game_systems() @@ -95,12 +120,17 @@ def action_search(unused: bool) -> str: action_room.items.append(new_item) broadcast( - f"{action_character.name} searches {action_room.name} and finds a new item: {new_item.name}" + format_prompt( + "action_search_broadcast", + action_character=action_character, + action_room=action_room, + new_item=new_item, + ) ) - return f"You search the room and find a new item: {new_item.name}" + return format_prompt("action_search_result", new_item=new_item) except Exception: logger.exception("error generating item") - return "You find nothing hidden in the room." + return format_prompt("action_search_error_generating") def action_use(item: str, target: str) -> str: @@ -118,12 +148,12 @@ def action_use(item: str, target: str) -> str: ( search_item for search_item in (action_character.items + action_room.items) - if search_item.name == item + if normalize_name(search_item.name) == normalize_name(item) ), None, ) if not action_item: - raise ActionError(f"The {item} item is not available to use.") + raise ActionError(format_prompt("action_use_error_item", item=item)) if target == "self": target_character = action_character @@ -132,19 +162,22 @@ def action_use(item: str, target: str) -> str: # TODO: allow targeting the room itself and items in the room target_character = find_character_in_room(action_room, target) if not target_character: - return f"The {target} character is not in the room." + return format_prompt("action_use_error_target", target=target) effect_names = [effect.name for effect in action_item.effects] # TODO: should use a retry loop and enum result parser chosen_name = dungeon_master( - f"{action_character.name} uses {item} on {target}. " - f"{item} has the following effects: {effect_names}. " - "Which effect should be applied? Specify the name of the effect to apply." - "Do not include the question or any JSON. Only include the name of the effect to apply." + format_prompt( + "action_use_dm_effect", + action_character=action_character, + item=item, + target=target, + effect_names=effect_names, + ) ) chosen_name = normalize_name(chosen_name) - chosen_effect = next( + effect = next( ( search_effect for search_effect in action_item.effects @@ -152,46 +185,56 @@ def action_use(item: str, target: str) -> str: ), None, ) - if not chosen_effect: + if not effect: raise ValueError(f"The {chosen_name} effect is not available to apply.") current_turn = get_current_turn() - effect_ready = is_effect_ready(chosen_effect, current_turn) + effect_ready = is_effect_ready(effect, current_turn) if effect_ready == "cooldown": raise ActionError( - f"The {chosen_name} effect of {item} is still cooling down and is not ready to use yet." + format_prompt("action_use_error_cooldown", effect=effect, item=item) ) elif effect_ready == "exhausted": raise ActionError( - f"The {chosen_name} effect of {item} has no uses remaining." + format_prompt("action_use_error_exhausted", effect=effect, item=item) ) - elif chosen_effect.uses is not None: - chosen_effect.uses -= 1 + elif effect.uses is not None: + effect.uses -= 1 - chosen_effect.last_used = current_turn + effect.last_used = current_turn try: - apply_effects(target_character, [chosen_effect]) + apply_effects(target_character, [effect]) except Exception: - logger.exception("error applying effect: %s", chosen_effect) + logger.exception("error applying effect: %s", effect) raise ValueError( f"There was a problem applying the {chosen_name} effect while using the {item} item." ) broadcast( - f"{action_character.name} uses the {chosen_name} effect of {item} on {target}" + format_prompt( + "action_use_broadcast", + action_character=action_character, + effect=effect, + item=item, + target=target, + ) ) outcome = dungeon_master( - f"{action_character.name} uses the {chosen_name} effect of {item} on {target}. " - f"{describe_character(action_character)}. " - f"{describe_character(target_character)}. " - f"{describe_entity(action_item)}. " - f"What happens? How does {target} react? Be creative with the results. The outcome can be good, bad, or neutral." - "Decide based on the characters involved and the item being used." - "Specify the outcome of the action. Do not include the question or any JSON. Only include the outcome of the action." + format_prompt( + "action_use_dm_outcome", + action_character=action_character, + action_item=action_item, + describe_entity=describe_entity, + effect=effect, + item=item, + target_character=target_character, + ) ) - broadcast(f"The action resulted in: {outcome}") + broadcast( + f"The action resulted in: {outcome}" + ) # TODO: should this be removed or moved to the prompt library? # make sure both agents remember the outcome target_agent = get_agent_for_character(target_character) diff --git a/taleweave/actions/planning.py b/taleweave/actions/planning.py index b1e0793..fd98459 100644 --- a/taleweave/actions/planning.py +++ b/taleweave/actions/planning.py @@ -1,8 +1,14 @@ -from taleweave.context import action_context, get_agent_for_character, get_current_turn +from taleweave.context import ( + action_context, + get_agent_for_character, + get_current_turn, + get_prompt, +) from taleweave.errors import ActionError from taleweave.models.config import DEFAULT_CONFIG from taleweave.models.planning import CalendarEvent from taleweave.utils.planning import get_recent_notes +from taleweave.utils.prompt import format_prompt character_config = DEFAULT_CONFIG.world.character @@ -18,19 +24,14 @@ def take_note(fact: str): with action_context() as (_, action_character): if fact in action_character.planner.notes: - raise ActionError( - "You already have a note about that fact. You do not need to take duplicate notes. " - "If you have too many notes, consider erasing, replacing, or summarizing them." - ) + raise ActionError(get_prompt("action_take_note_error_duplicate")) if len(action_character.planner.notes) >= character_config.note_limit: - raise ActionError( - "You have reached the limit of notes you can take. Please erase, replace, or summarize some notes." - ) + raise ActionError(get_prompt("action_take_note_error_limit")) action_character.planner.notes.append(fact) - return "You make a note of that fact." + return get_prompt("action_take_note_result") def read_notes(unused: bool, count: int = 10): @@ -55,21 +56,25 @@ def erase_notes(prefix: str) -> str: """ with action_context() as (_, action_character): + if len(action_character.planner.notes) == 0: + raise ActionError(get_prompt("action_erase_notes_error_empty")) + matches = [ note for note in action_character.planner.notes if note.startswith(prefix) ] if not matches: - return "No notes found with that prefix." + raise ActionError(get_prompt("action_erase_notes_error_match")) action_character.planner.notes[:] = [ note for note in action_character.planner.notes if note not in matches ] - return f"Erased {len(matches)} notes." + + return format_prompt("action_erase_notes_result", count=len(matches)) -def replace_note(old: str, new: str) -> str: +def edit_note(old: str, new: str) -> str: """ - Replace a note with a new note. + Modify a note with new details. Args: old: The old note to replace. @@ -77,13 +82,17 @@ def replace_note(old: str, new: str) -> str: """ with action_context() as (_, action_character): + if len(action_character.planner.notes) == 0: + raise ActionError(get_prompt("action_edit_note_error_empty")) + if old not in action_character.planner.notes: - return "Note not found." + raise ActionError(get_prompt("action_edit_note_error_match")) action_character.planner.notes[:] = [ new if note == old else note for note in action_character.planner.notes ] - return "Note replaced." + + return get_prompt("action_edit_note_result") def summarize_notes(limit: int) -> str: @@ -96,19 +105,16 @@ def summarize_notes(limit: int) -> str: with action_context() as (_, action_character): notes = action_character.planner.notes + if len(notes) == 0: + raise ActionError(get_prompt("action_summarize_notes_error_empty")) + action_agent = get_agent_for_character(action_character) if not action_agent: raise ActionError("Agent missing for character {action_character.name}") summary = action_agent( - "Please summarize your notes. Remove any duplicates and combine similar notes. " - "If a newer note contradicts an older note, keep the newer note. " - "Clean up your notes so you can focus on the most important facts. " - "Respond with one note per line. You can have up to {limit} notes, " - "so make sure you reply with less than {limit} lines. Do not number the lines " - "in your response. Do not include any JSON or other information. " - "Your notes are:\n{notes}", + get_prompt("action_summarize_notes_prompt"), limit=limit, notes=notes, ) @@ -116,11 +122,14 @@ def summarize_notes(limit: int) -> str: new_notes = [note.strip() for note in summary.split("\n") if note.strip()] if len(new_notes) > character_config.note_limit: raise ActionError( - f"Too many notes. You can only have up to {character_config.note_limit} notes." + format_prompt( + "action_summarize_notes_error_limit", + limit=character_config.note_limit, + ) ) action_character.planner.notes[:] = new_notes - return "Notes were summarized successfully." + return get_prompt("action_summarize_notes_result") def schedule_event(name: str, turns: int): @@ -138,9 +147,12 @@ def schedule_event(name: str, turns: int): # TODO: limit the number of events that can be scheduled with action_context() as (_, action_character): + if not name: + raise ActionError(get_prompt("action_schedule_event_error_name")) + event = CalendarEvent(name, turns) action_character.planner.calendar.events.append(event) - return f"{name} is scheduled to happen in {turns} turns." + return format_prompt("action_schedule_event_result", name=name, turns=turns) def check_calendar(count: int): @@ -156,15 +168,16 @@ def check_calendar(count: int): with action_context() as (_, action_character): if len(action_character.planner.calendar.events) == 0: - return ( - "You have no upcoming events scheduled. You can plan events with other characters or on your own. " - "Make sure to inform others about events that involve them." - ) + return get_prompt("action_check_calendar_empty") events = action_character.planner.calendar.events[:count] return "\n".join( [ - f"{event.name} will happen in {event.turn - current_turn} turns" + format_prompt( + "action_check_calendar_each", + name=event.name, + turn=event.turn - current_turn, + ) for event in events ] ) diff --git a/taleweave/actions/quest.py b/taleweave/actions/quest.py index 7f9422a..6b42803 100644 --- a/taleweave/actions/quest.py +++ b/taleweave/actions/quest.py @@ -1,4 +1,5 @@ from taleweave.context import action_context, get_system_data +from taleweave.errors import ActionError from taleweave.systems.quest import ( QUEST_SYSTEM, complete_quest, @@ -6,6 +7,7 @@ from taleweave.systems.quest import ( get_quests_for_character, set_active_quest, ) +from taleweave.utils.prompt import format_prompt from taleweave.utils.search import find_character_in_room @@ -17,20 +19,30 @@ def accept_quest(character: str, quest: str) -> str: with action_context() as (action_room, action_character): quests = get_system_data(QUEST_SYSTEM) if not quests: - return "No quests available." + raise ActionError( + format_prompt("action_accept_quest_error_none", character=character) + ) target_character = find_character_in_room(action_room, character) if not target_character: - return f"{character} is not in the room." + raise ActionError( + format_prompt("action_accept_quest_error_room", character=character) + ) available_quests = get_quests_for_character(quests, target_character) for available_quest in available_quests: if available_quest.name == quest: set_active_quest(quests, action_character, available_quest) - return f"You have accepted the quest: {quest}" + return format_prompt( + "action_accept_quest_result", character=character, quest=quest + ) - return f"{character} does not have the quest: {quest}" + raise ActionError( + format_prompt( + "action_accept_quest_error_name", character=character, quest=quest + ) + ) def submit_quest(character: str) -> str: @@ -41,18 +53,32 @@ def submit_quest(character: str) -> str: with action_context() as (action_room, action_character): quests = get_system_data(QUEST_SYSTEM) if not quests: - return "No quests available." + raise ActionError( + format_prompt("action_submit_quest_error_none", character=character) + ) active_quest = get_active_quest(quests, action_character) if not active_quest: - return "You do not have an active quest." + raise ActionError( + format_prompt("action_submit_quest_error_active", character=character) + ) target_character = find_character_in_room(action_room, character) if not target_character: - return f"{character} is not in the room." + raise ActionError( + format_prompt("action_submit_quest_error_room", character=character) + ) if active_quest.giver.character == target_character.name: complete_quest(quests, action_character, active_quest) - return f"You have completed the quest: {active_quest.name}" + return format_prompt( + "action_submit_quest_result", + character=character, + quest=active_quest.name, + ) - return f"{character} is not the quest giver for your active quest." + return format_prompt( + "action_submit_quest_error_name", + character=character, + quest=active_quest.name, + ) diff --git a/taleweave/bot/discord.py b/taleweave/bot/discord.py index 7e1b147..c34e147 100644 --- a/taleweave/bot/discord.py +++ b/taleweave/bot/discord.py @@ -34,6 +34,7 @@ from taleweave.player import ( set_player, ) from taleweave.render.comfy import render_event +from taleweave.utils.prompt import format_prompt logger = getLogger(__name__) client = None @@ -86,28 +87,38 @@ class AdventureClient(Client): ): world = get_current_world() if world: - active_world = f"Active world: {world.name} (theme: {world.theme})" + world_message = format_prompt( + "discord_world_active", bot_name=bot_config.name_title, world=world + ) else: - active_world = "No active world" + world_message = format_prompt( + "discord_world_none", bot_name=bot_config.name_title + ) - await message.channel.send( - f"Hello! Welcome to {bot_config.name_title}! {active_world}" - ) + await message.channel.send(world_message) return if message.content.startswith("!help"): - await message.channel.send("Type `!join` to start playing!") + await message.channel.send( + format_prompt("discord_help", bot_name=bot_config.name_command) + ) return if message.content.startswith("!join"): character_name = remove_tags(message.content).replace("!join", "").strip() if has_player(character_name): - await channel.send(f"{character_name} has already been taken!") + await channel.send( + format_prompt("discord_join_error_taken", character=character_name) + ) return character, agent = get_character_agent_for_name(character_name) if not character: - await channel.send(f"Character `{character_name}` not found!") + await channel.send( + format_prompt( + "discord_join_error_not_found", character=character_name + ) + ) return def prompt_player(event: PromptEvent): @@ -156,9 +167,7 @@ class AdventureClient(Client): ) return - await message.channel.send( - "You are not currently playing Adventure! Type `!join` to start playing!" - ) + await message.channel.send(format_prompt("discord_user_new")) return @@ -317,8 +326,10 @@ def embed_from_event(event: GameEvent) -> Embed | None: return embed_from_generate(event) elif isinstance(event, ResultEvent): return embed_from_result(event) - elif isinstance(event, (ActionEvent, ReplyEvent)): + elif isinstance(event, ActionEvent): return embed_from_action(event) + elif isinstance(event, ReplyEvent): + return embed_from_reply(event) elif isinstance(event, StatusEvent): return embed_from_status(event) elif isinstance(event, PlayerEvent): @@ -329,23 +340,25 @@ def embed_from_event(event: GameEvent) -> Embed | None: logger.warning("unknown event type: %s", event) -def embed_from_action(event: ActionEvent | ReplyEvent): - action_embed = Embed(title=event.room.name, description=event.speaker.name) +def embed_from_action(event: ActionEvent): + action_embed = Embed(title=event.room.name, description=event.character.name) + action_name = event.action.replace("action_", "").title() + action_parameters = event.parameters - if isinstance(event, ActionEvent): - action_name = event.action.replace("action_", "").title() - action_parameters = event.parameters + action_embed.add_field(name="Action", value=action_name) - action_embed.add_field(name="Action", value=action_name) - - for key, value in action_parameters.items(): - action_embed.add_field(name=key.replace("_", " ").title(), value=value) - else: - action_embed.add_field(name="Message", value=event.text) + for key, value in action_parameters.items(): + action_embed.add_field(name=key.replace("_", " ").title(), value=value) return action_embed +def embed_from_reply(event: ReplyEvent): + reply_embed = Embed(title=event.room.name, description=event.speaker.name) + reply_embed.add_field(name="Reply", value=event.text) + return reply_embed + + def embed_from_generate(event: GenerateEvent) -> Embed: generate_embed = Embed(title="Generating", description=event.name) return generate_embed @@ -363,11 +376,11 @@ def embed_from_result(event: ResultEvent): def embed_from_player(event: PlayerEvent): if event.status == "join": - title = "Player Joined" - description = f"{event.client} is now playing as {event.character}" + title = format_prompt("discord_join_title", event=event) + description = format_prompt("discord_join_result", event=event) else: - title = "Player Left" - description = f"{event.client} has left the game. {event.character} is now controlled by an LLM" + title = format_prompt("discord_leave_title", event=event) + description = format_prompt("discord_leave_result", event=event) player_embed = Embed(title=title, description=description) return player_embed diff --git a/taleweave/context.py b/taleweave/context.py index 54af28e..3fdfa81 100644 --- a/taleweave/context.py +++ b/taleweave/context.py @@ -20,6 +20,7 @@ from pyee.base import EventEmitter from taleweave.game_system import GameSystem from taleweave.models.entity import Character, Room, World from taleweave.models.event import GameEvent, StatusEvent +from taleweave.models.prompt import PromptLibrary from taleweave.utils.string import normalize_name logger = getLogger(__name__) @@ -34,6 +35,7 @@ dungeon_master: Agent | None = None # game context event_emitter = EventEmitter() game_systems: List[GameSystem] = [] +prompt_library: PromptLibrary = PromptLibrary(prompts={}) system_data: Dict[str, Any] = {} @@ -44,7 +46,7 @@ STRING_EVENT_TYPE = "message" def get_event_name(event: GameEvent | Type[GameEvent]): - return f"event:{event.type}" + return f"event.{event.type}" def broadcast(message: str | GameEvent): @@ -162,6 +164,14 @@ def get_game_systems() -> List[GameSystem]: return game_systems +def get_prompt(name: str) -> str: + return prompt_library.prompts[name] + + +def get_prompt_library() -> PromptLibrary: + return prompt_library + + def get_system_data(system: str) -> Any | None: return system_data.get(system) @@ -204,6 +214,11 @@ def set_game_systems(systems: Sequence[GameSystem]): game_systems = list(systems) +def set_prompt_library(library: PromptLibrary): + global prompt_library + prompt_library = library + + def set_system_data(system: str, data: Any): system_data[system] = data diff --git a/taleweave/generate.py b/taleweave/generate.py index a89826b..cbc9d09 100644 --- a/taleweave/generate.py +++ b/taleweave/generate.py @@ -7,7 +7,7 @@ from packit.loops import loop_retry from packit.results import enum_result, int_result from packit.utils import could_be_json -from taleweave.context import broadcast, set_current_world, set_system_data +from taleweave.context import broadcast, get_prompt, set_current_world, set_system_data from taleweave.game_system import GameSystem from taleweave.models.config import DEFAULT_CONFIG, WorldConfig from taleweave.models.effect import ( @@ -20,6 +20,7 @@ from taleweave.models.entity import Character, Item, Portal, Room, World, WorldE from taleweave.models.event import GenerateEvent from taleweave.utils import try_parse_float, try_parse_int from taleweave.utils.effect import resolve_int_range +from taleweave.utils.prompt import format_prompt from taleweave.utils.search import ( list_characters, list_characters_in_room, @@ -40,16 +41,24 @@ def duplicate_name_parser(existing_names: List[str]): logger.debug(f"validating generated name: {value}") if value in existing_names: - raise ValueError(f'"{value}" has already been used.') + raise ValueError( + format_prompt("world_generate_error_name_exists", name=value) + ) if could_be_json(value): - raise ValueError("The name cannot contain JSON or other commands.") + raise ValueError( + format_prompt("world_generate_error_name_json", name=value) + ) if '"' in value or ":" in value: - raise ValueError("The name cannot contain quotes or colons.") + raise ValueError( + format_prompt("world_generate_error_name_punctuation", name=value) + ) if len(value) > 50: - raise ValueError("The name cannot be longer than 50 characters.") + raise ValueError( + format_prompt("world_generate_error_name_length", name=value) + ) return value @@ -88,9 +97,7 @@ def generate_room( name = loop_retry( agent, - "Generate one room, area, or location that would make sense in the world of {world_theme}. " - "Only respond with the room name in title case, do not include the description or any other text. " - 'Do not prefix the name with "the", do not wrap it in quotes. The existing rooms are: {existing_rooms}', + get_prompt("world_generate_room_name"), context={ "world_theme": world.theme, "existing_rooms": existing_rooms, @@ -99,18 +106,18 @@ def generate_room( toolbox=None, ) - broadcast_generated(message=f"Generating room: {name}") - desc = agent( - "Generate a detailed description of the {name} area. What does it look like? " - "What does it smell like? What can be seen or heard?", - name=name, - ) + broadcast_generated(format_prompt("world_generate_room_broadcast_room", name=name)) + desc = agent(get_prompt("world_generate_room_description"), name=name) actions = {} room = Room(name=name, description=desc, items=[], characters=[], actions=actions) item_count = resolve_int_range(world_config.size.room_items) or 0 - broadcast_generated(f"Generating {item_count} items for room: {name}") + broadcast_generated( + format_prompt( + "world_generate_room_broadcast_items", item_count=item_count, name=name + ) + ) for _ in range(item_count): try: @@ -128,7 +135,11 @@ def generate_room( character_count = resolve_int_range(world_config.size.room_characters) or 0 broadcast_generated( - message=f"Generating {character_count} characters for room: {name}" + format_prompt( + "world_generate_room_broadcast_characters", + character_count=character_count, + name=name, + ) ) for _ in range(character_count): @@ -155,17 +166,14 @@ def generate_portals( source_room: Room, dest_room: Room, systems: List[GameSystem], + outgoing_name: str | None = None, ) -> Tuple[Portal, Portal]: existing_source_portals = [portal.name for portal in source_room.portals] existing_dest_portals = [portal.name for portal in dest_room.portals] - outgoing_name = loop_retry( + outgoing_name = outgoing_name or loop_retry( agent, - "Generate the name of a portal that leads from the {source_room} room to the {dest_room} room and fits the world theme of {world_theme}. " - "Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. " - "Only respond with the portal name in title case, do not include a description or any other text. " - 'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. ' - "Do not create any duplicate portals in the same room. The existing portals are: {existing_portals}", + get_prompt("world_generate_portal_name_outgoing"), context={ "source_room": source_room.name, "dest_room": dest_room.name, @@ -175,16 +183,15 @@ def generate_portals( result_parser=duplicate_name_parser(existing_source_portals), toolbox=None, ) - broadcast_generated(message=f"Generating portal: {outgoing_name}") + broadcast_generated( + message=format_prompt( + "world_generate_portal_broadcast_outgoing", outgoing_name=outgoing_name + ) + ) incoming_name = loop_retry( agent, - "Generate the opposite name of the portal that leads from the {dest_room} room to the {source_room} room. " - "The name should be the opposite of the {outgoing_name} portal and should fit the world theme of {world_theme}. " - "Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. " - "Only respond with the portal name in title case, do not include a description or any other text. " - 'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. ' - "Do not create any duplicate portals in the same room. The existing portals are: {existing_portals}", + get_prompt("world_generate_portal_name_incoming"), context={ "source_room": source_room.name, "dest_room": dest_room.name, @@ -196,7 +203,15 @@ def generate_portals( toolbox=None, ) - broadcast_generated(message=f"Linking {outgoing_name} to {incoming_name}") + broadcast_generated( + message=format_prompt( + "world_generate_portal_broadcast_incoming", + incoming_name=incoming_name, + outgoing_name=outgoing_name, + ) + ) + + # TODO: generate descriptions for the portals outgoing_portal = Portal( name=outgoing_name, @@ -242,11 +257,7 @@ def generate_item( name = loop_retry( agent, - "Generate one item or object that would make sense in the world of {world_theme}. {dest_note}. " - "Only respond with the item name in title case, do not include a description or any other text. Do not prefix the " - 'name with "the", do not wrap it in quotes. Do not include the name of the room. Use a unique name. ' - "Do not create any duplicate items in the same room. Do not give characters any duplicate items. " - "The existing items are: {existing_items}", + get_prompt("world_generate_item_name"), context={ "dest_note": dest_note, "existing_items": existing_items, @@ -256,18 +267,23 @@ def generate_item( toolbox=None, ) - broadcast_generated(message=f"Generating item: {name}") - desc = agent( - "Generate a detailed description of the {name} item. What does it look like? What is it made of? What does it do?", - name=name, + broadcast_generated( + message=format_prompt("world_generate_item_broadcast_item", name=name) ) + desc = agent(get_prompt("world_generate_item_description"), name=name) actions = {} item = Item(name=name, description=desc, actions=actions) generate_system_attributes(agent, world, item, systems) effect_count = resolve_int_range(world_config.size.item_effects) or 0 - broadcast_generated(message=f"Generating {effect_count} effects for item: {name}") + broadcast_generated( + message=format_prompt( + "world_generate_item_broadcast_effects", + effect_count=effect_count, + name=name, + ) + ) for _ in range(effect_count): try: @@ -294,12 +310,7 @@ def generate_character( name = loop_retry( agent, - "Generate a new character that would make sense in the world of {world_theme}. Characters can be a person, creature, or some other intelligent entity." - "The character will be placed in the {dest_room} room. {additional_prompt}. " - "Only respond with the character name in title case, do not include a description or any other text. " - 'Do not prefix the name with "the", do not wrap it in quotes. ' - "Do not include the name of the room. Do not give characters any duplicate names." - "Do not create any duplicate characters. The existing characters are: {existing_characters}", + get_prompt("world_generate_character_name"), context={ "additional_prompt": additional_prompt, "dest_room": dest_room.name, @@ -310,18 +321,17 @@ def generate_character( toolbox=None, ) - broadcast_generated(message=f"Generating character: {name}") + broadcast_generated( + message=format_prompt("world_generate_character_broadcast_name", name=name) + ) description = agent( - "Generate a detailed description of the {name} character. {additional_prompt}. {detail_prompt}. What do they look like? What are they wearing? " - "What are they doing? Describe their appearance from the perspective of an outside observer." - "Do not include the room or any other characters in the description, because they will move around.", + get_prompt("world_generate_character_description"), additional_prompt=additional_prompt, detail_prompt=detail_prompt, name=name, ) backstory = agent( - "Generate a backstory for the {name} character. {additional_prompt}. {detail_prompt}. Where are they from? What are they doing here? What are their " - 'goals? Make sure to phrase the backstory in the second person, starting with "you are" and speaking directly to {name}.', + get_prompt("world_generate_character_backstory"), additional_prompt=additional_prompt, detail_prompt=detail_prompt, name=name, @@ -334,7 +344,11 @@ def generate_character( # generate the character's inventory item_count = resolve_int_range(world_config.size.character_items) or 0 - broadcast_generated(f"Generating {item_count} items for character {name}") + broadcast_generated( + message=format_prompt( + "world_generate_character_broadcast_items", item_count=item_count, name=name + ) + ) for k in range(item_count): try: @@ -352,6 +366,7 @@ def generate_character( logger.exception("error generating item") if add_to_world_order: + # TODO: make sure characters have an agent logger.info(f"adding character {name} to end of world turn order") world.order.append(name) @@ -364,11 +379,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: name = loop_retry( agent, - "Generate one effect for an {entity_type} named {entity_name} that would make sense in the world of {theme}. " - "Only respond with the effect name in title case, do not include a description or any other text. " - 'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. ' - "Do not create any duplicate effects on the same item. The existing effects are: {existing_effects}. " - "Some example effects are: 'fire', 'poison', 'frost', 'haste', 'slow', and 'heal'.", + get_prompt("world_generate_effect_name"), context={ "entity_name": entity.name, "entity_type": entity_type, @@ -378,18 +389,18 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: result_parser=duplicate_name_parser(existing_effects), toolbox=None, ) - broadcast_generated(message=f"Generating effect: {name}") + broadcast_generated( + message=format_prompt("world_generate_effect_broadcast_effect", name=name) + ) description = agent( - "Generate a detailed description of the {name} effect. What does it look like? What does it do? " - "How does it affect the target? Describe the effect from the perspective of an outside observer.", + get_prompt("world_generate_effect_description"), name=name, ) cooldown = loop_retry( agent, - f"How many turns should the {name} effect wait before it can be used again? Enter a positive number to set a cooldown, or 0 for no cooldown. " - "Do not include any other text. Do not use JSON.", + get_prompt("world_generate_effect_cooldown"), context={ "name": name, }, @@ -399,8 +410,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: uses = loop_retry( agent, - f"How many times can the {name} effect be used before it is exhausted? Enter a positive number to set a limit, or -1 for unlimited uses. " - "Do not include any other text. Do not use JSON.", + get_prompt("world_generate_effect_uses"), context={ "name": name, }, @@ -412,10 +422,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: uses = None attribute_names = agent( - "Generate a short list of attributes that the {name} effect modifies. Include 1 to 3 attributes. " - "For example, 'heal' increases the target's 'health' attribute, while 'poison' decreases it. " - "Use a comma-separated list of attribute names, such as 'health, strength, speed'. " - "Only include the attribute names, do not include the question or any JSON.", + get_prompt("world_generate_effect_attribute_names"), name=name, ) @@ -424,10 +431,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: attribute_name = normalize_name(attribute_name) if attribute_name: value = agent( - f"How much does the {name} effect modify the {attribute_name} attribute? " - "For example, heal might add 10 to the health attribute, while poison might remove -5 from it." - "Enter a positive number to increase the attribute or a negative number to decrease it. " - "Do not include any other text. Do not use JSON.", + get_prompt("world_generate_effect_attribute_value"), name=name, attribute_name=attribute_name, ) @@ -452,8 +456,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: duration = loop_retry( agent, - f"How many turns does the {name} effect last? Enter a positive number to set a duration, or 0 for an instant effect. " - "Do not include any other text. Do not use JSON.", + get_prompt("world_generate_effect_duration"), context={ "name": name, }, @@ -466,19 +469,11 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: if value: return value - raise ValueError("The application must be 'temporary' or 'permanent'.") + raise ValueError(get_prompt("world_generate_effect_error_application")) application = loop_retry( agent, - ( - f"How should the {name} effect be applied? Respond with 'temporary' for a temporary effect that lasts for a duration, " - "or 'permanent' for a permanent effect that immediately modifies the target. " - "For example, a healing potion would be a permanent effect that increases health every turn, " - "while bleeding would be a temporary effect that decreases health every turn. " - "A haste potion would be a temporary effect that increases speed for a duration, " - "while a slow spell would be a temporary effect that decreases speed for a duration. " - "Do not include any other text. Do not use JSON." - ), + get_prompt("world_generate_effect_application"), context={ "name": name, }, @@ -513,7 +508,11 @@ def link_rooms( continue broadcast_generated( - message=f"Generating {num_portals} portals for room: {room.name}" + format_prompt( + "world_generate_room_broadcast_portals", + num_portals=num_portals, + name=room.name, + ) ) for _ in range(num_portals): @@ -553,7 +552,7 @@ def generate_world( ) -> World: room_count = room_count or resolve_int_range(world_config.size.rooms) or 0 - broadcast_generated(message=f"Generating a {theme} with {room_count} rooms") + broadcast_generated(message=format_prompt("world_generate_world_broadcast_theme")) world = World(name=name, rooms=[], theme=theme, order=[]) set_current_world(world) diff --git a/taleweave/main.py b/taleweave/main.py index 04253ba..9e918be 100644 --- a/taleweave/main.py +++ b/taleweave/main.py @@ -31,6 +31,7 @@ load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True) if True: from taleweave.context import ( + get_prompt_library, get_system_data, set_current_turn, set_dungeon_master, @@ -43,6 +44,7 @@ if True: from taleweave.models.entity import World, WorldState from taleweave.models.event import GenerateEvent from taleweave.models.files import PromptFile, WorldPrompt + from taleweave.models.prompt import PromptLibrary from taleweave.plugins import load_plugin from taleweave.simulate import simulate_world from taleweave.state import ( @@ -51,6 +53,7 @@ if True: save_world, save_world_state, ) + from taleweave.utils.prompt import format_prompt # start the debugger, if needed if environ.get("DEBUG", "false").lower() == "true": @@ -116,6 +119,12 @@ def parse_args(): type=str, help="The name of the character to play as", ) + parser.add_argument( + "--prompts", + type=str, + nargs="*", + help="The file to load game prompts from", + ) parser.add_argument( "--render", action="store_true", @@ -191,6 +200,18 @@ def get_world_prompt(args) -> WorldPrompt: ) +def load_prompt_library(args) -> None: + if args.prompts: + for prompt_file in args.prompts: + with open(prompt_file, "r") as f: + new_library = PromptLibrary(**load_yaml(f)) + logger.info(f"loaded prompt library from {args.prompts}") + library = get_prompt_library() + library.prompts.update(new_library.prompts) + + return None + + def load_or_initialize_system_data(args, systems: List[GameSystem], world: World): for system in systems: if system.data: @@ -232,8 +253,11 @@ def load_or_generate_world( llm = agent_easy_connect() world_builder = Agent( "World Builder", - f"You are an experienced game master creating a visually detailed world for a new adventure. " - f"{world_prompt.flavor}. The theme is: {world_prompt.theme}.", + format_prompt( + "world_generate_dungeon_master", + flavor=world_prompt.flavor, + theme=world_prompt.theme, + ), {}, llm, memory_factory=memory_factory, @@ -291,6 +315,8 @@ def main(): else: config = DEFAULT_CONFIG + load_prompt_library(args) + players = [] if args.player: players.append(args.player) @@ -378,10 +404,8 @@ def main(): llm = agent_easy_connect() world_builder = Agent( "dungeon master", - ( - f"You are the dungeon master in charge of a {world.theme} world. Be creative and original, and come up with " - f"interesting events that will keep players interested. {args.flavor}" - "Do not to repeat yourself unless you are given the same prompt with the same characters and actions." + format_prompt( + "world_generate_dungeon_master", flavor=args.flavor, theme=world.theme ), {}, llm, diff --git a/taleweave/models/files.py b/taleweave/models/files.py index 5ce6b17..655b13c 100644 --- a/taleweave/models/files.py +++ b/taleweave/models/files.py @@ -10,6 +10,7 @@ class WorldPrompt: flavor: str = "" +# TODO: rename to WorldTemplates @dataclass class PromptFile: prompts: List[WorldPrompt] diff --git a/taleweave/models/prompt.py b/taleweave/models/prompt.py new file mode 100644 index 0000000..971b603 --- /dev/null +++ b/taleweave/models/prompt.py @@ -0,0 +1,8 @@ +from typing import Dict + +from .base import dataclass + + +@dataclass +class PromptLibrary: + prompts: Dict[str, str] diff --git a/taleweave/simulate.py b/taleweave/simulate.py index 5b46160..2306e0e 100644 --- a/taleweave/simulate.py +++ b/taleweave/simulate.py @@ -22,10 +22,10 @@ from taleweave.actions.base import ( ) from taleweave.actions.planning import ( check_calendar, + edit_note, erase_notes, get_recent_notes, read_notes, - replace_note, schedule_event, summarize_notes, take_note, @@ -36,6 +36,7 @@ from taleweave.context import ( get_character_for_agent, get_current_turn, get_current_world, + get_prompt, set_current_character, set_current_room, set_current_turn, @@ -49,6 +50,7 @@ from taleweave.models.event import ActionEvent, ResultEvent from taleweave.utils.conversation import make_keyword_condition, summarize_room from taleweave.utils.effect import expire_effects from taleweave.utils.planning import expire_events, get_upcoming_events +from taleweave.utils.prompt import format_prompt from taleweave.utils.search import find_containing_room from taleweave.utils.world import describe_entity, format_attributes @@ -115,10 +117,13 @@ def prompt_character_action( pass if could_be_json(value): - # TODO: only emit valid actions that parse and run correctly + # TODO: only emit valid actions that parse and run correctly, and try to avoid parsing the JSON twice event = ActionEvent.from_json(value, room, character) else: # TODO: this path should be removed and throw + logger.warning( + "invalid action, emitting as result event - this is a bug somewhere" + ) event = ResultEvent(value, room, character) broadcast(event) @@ -129,17 +134,7 @@ def prompt_character_action( logger.info("starting turn for character: %s", character.name) result = loop_retry( agent, - ( - "You are currently in the {room_name} room. {room_description}. {attributes}. " - "The room contains the following characters: {visible_characters}. " - "The room contains the following items: {visible_items}. " - "Your inventory contains the following items: {character_items}." - "You can take the following actions: {actions}. " - "You can move in the following directions: {directions}. " - "{notes_prompt} {events_prompt}" - "What will you do next? Reply with a JSON function call, calling one of the actions." - "You can only perform one action per turn. What is your next action?" - ), + get_prompt("world_simulate_character_action"), context={ "actions": action_names, "character_items": character_items, @@ -158,7 +153,6 @@ def prompt_character_action( logger.debug(f"{character.name} action result: {result}") if agent.memory: - # TODO: make sure this is not duplicating memories and wasting space agent.memory.append(result) return result @@ -170,25 +164,33 @@ def get_notes_events(character: Character, current_turn: int): if len(recent_notes) > 0: notes = "\n".join(recent_notes) - notes_prompt = f"Your recent notes are: {notes}\n" + notes_prompt = format_prompt( + "world_simulate_character_planning_notes_some", notes=notes + ) else: - notes_prompt = "You have no recent notes.\n" + notes_prompt = format_prompt("world_simulate_character_planning_notes_none") if len(upcoming_events) > 0: current_turn = get_current_turn() events = [ - f"{event.name} in {event.turn - current_turn} turns" + format_prompt( + "world_simulate_character_planning_events_item", + event=event, + turns=event.turn - current_turn, + ) for event in upcoming_events ] events = "\n".join(events) - events_prompt = f"Upcoming events are: {events}\n" + events_prompt = format_prompt( + "world_simulate_character_planning_events_some", events=events + ) else: - events_prompt = "You have no upcoming events.\n" + events_prompt = format_prompt("world_simulate_character_planning_events_none") return notes_prompt, events_prompt -def prompt_character_think( +def prompt_character_planning( room: Room, character: Character, agent: Agent, @@ -204,7 +206,9 @@ def prompt_character_think( note_count = len(character.planner.notes) logger.info("starting planning for character: %s", character.name) - _, condition_end, result_parser = make_keyword_condition("You are done planning.") + _, condition_end, result_parser = make_keyword_condition( + get_prompt("world_simulate_character_planning_done") + ) stop_condition = condition_or( condition_end, partial(condition_threshold, max=max_steps) ) @@ -213,15 +217,7 @@ def prompt_character_think( while not stop_condition(current=i): result = loop_retry( agent, - "You are about to start your turn. Plan your next action carefully. Take notes and schedule events to help keep track of your goals. " - "You can check your notes for important facts or check your calendar for upcoming events. You have {note_count} notes. " - "If you have plans with other characters, schedule them on your calendar. You have {event_count} events on your calendar. " - "{room_summary}" - "Think about your goals and any quests that you are working on, and plan your next action accordingly. " - "Try to keep your notes accurate and up-to-date. Replace or erase old notes when they are no longer accurate or useful. " - "Do not keeps notes about upcoming events, use your calendar for that. " - "You can perform up to 3 planning actions in a single turn. When you are done planning, reply with 'END'." - "{notes_prompt} {events_prompt}", + get_prompt("world_simulate_character_planning"), context={ "event_count": event_count, "events_prompt": events_prompt, @@ -272,7 +268,7 @@ def simulate_world( check_calendar, erase_notes, read_notes, - replace_note, + edit_note, schedule_event, summarize_notes, take_note, @@ -306,7 +302,7 @@ def simulate_world( # give the character a chance to think and check their planner if agent.memory and len(agent.memory) > 0: try: - thoughts = prompt_character_think( + thoughts = prompt_character_planning( room, character, agent, planner_toolbox, current_turn ) logger.debug(f"{character.name} thinks: {thoughts}") diff --git a/taleweave/systems/digest.py b/taleweave/systems/digest.py index 158e347..bd3462d 100644 --- a/taleweave/systems/digest.py +++ b/taleweave/systems/digest.py @@ -1,42 +1,31 @@ +from logging import getLogger from typing import Dict, List -from taleweave.context import get_current_world, subscribe +from taleweave.context import get_current_world, get_prompt_library, subscribe from taleweave.game_system import FormatPerspective, GameSystem from taleweave.models.entity import Character, Room, World, WorldEntity from taleweave.models.event import ActionEvent, GameEvent from taleweave.utils.search import find_containing_room +logger = getLogger(__name__) + def create_turn_digest( active_room: Room, active_character: Character, turn_events: List[GameEvent] ) -> List[str]: + library = get_prompt_library() messages = [] for event in turn_events: if isinstance(event, ActionEvent): if event.character == active_character or event.room == active_room: - if event.action == "move": - # TODO: differentiate between entering and leaving - messages.append(f"{event.character.name} entered the room.") - elif event.action == "take": - messages.append( - f"{event.character.name} picked up the {event.parameters['item']}." - ) - elif event.action == "give": - messages.append( - f"{event.character.name} gave {event.parameters['item']} to {event.parameters['character']}." - ) - elif event.action == "ask": - messages.append( - f"{event.character.name} asked {event.parameters['character']} about something." - ) - elif event.action == "tell": - messages.append( - f"{event.character.name} told {event.parameters['character']} something." - ) - elif event.action == "examine": - messages.append( - f"{event.character.name} examined the {event.parameters['target']}." - ) + prompt_key = f"digest_{event.action}" + if prompt_key in library.prompts: + try: + template = library.prompts[prompt_key] + message = template.format(event=event) + messages.append(message) + except Exception: + logger.exception("error formatting digest event: %s", event) return messages @@ -48,8 +37,8 @@ def digest_listener(event: GameEvent): if isinstance(event, ActionEvent): character = event.character.name - # append the event to every character's buffer except the one who triggered it - # the actor should have their buffer reset, because they can only act on their turn + # append the event to every character's buffer except the one who triggered it. the + # acting character should have their buffer reset, because they can only act on their turn for name, buffer in character_buffers.items(): if name == character: diff --git a/taleweave/utils/conversation.py b/taleweave/utils/conversation.py index 6cd5643..5cea46e 100644 --- a/taleweave/utils/conversation.py +++ b/taleweave/utils/conversation.py @@ -12,6 +12,7 @@ from taleweave.context import broadcast from taleweave.models.config import DEFAULT_CONFIG from taleweave.models.entity import Character, Room from taleweave.models.event import ReplyEvent +from taleweave.utils.prompt import format_str from .string import and_list, normalize_name @@ -143,7 +144,12 @@ def loop_conversation( # summarize the room and present the last response summary = summarize_room(room, character) response = agent( - prompt, response=response, summary=summary, last_character=last_character + format_str( + prompt, + response=response, + summary=summary, + last_character=last_character, + ) ) response = result_parser(response) @@ -155,4 +161,4 @@ def loop_conversation( i += 1 last_character = character - return f"{last_character.name} ends the conversation for now" + return format_str(end_message, response=response, last_character=last_character) diff --git a/taleweave/utils/prompt.py b/taleweave/utils/prompt.py new file mode 100644 index 0000000..c01141a --- /dev/null +++ b/taleweave/utils/prompt.py @@ -0,0 +1,27 @@ +from logging import getLogger + +from jinja2 import Environment + +from taleweave.context import get_prompt_library +from taleweave.utils.world import describe_entity, name_entity + +logger = getLogger(__name__) + + +def format_prompt(prompt_key: str, **kwargs) -> str: + try: + library = get_prompt_library() + template_str = library.prompts[prompt_key] + return format_str(template_str, **kwargs) + except Exception as e: + logger.exception("error formatting prompt: %s", prompt_key) + raise e + + +def format_str(template_str: str, **kwargs) -> str: + env = Environment() + env.filters["describe"] = describe_entity + env.filters["name"] = name_entity + + template = env.from_string(template_str) + return template.render(**kwargs) diff --git a/taleweave/utils/world.py b/taleweave/utils/world.py index 141e3af..6aa2161 100644 --- a/taleweave/utils/world.py +++ b/taleweave/utils/world.py @@ -51,3 +51,12 @@ def format_attributes( ] return f"{'. '.join(attribute_descriptions)}" + + +def name_entity( + entity: str | WorldEntity, +) -> str: + if isinstance(entity, str): + return entity + + return entity.name diff --git a/taleweave/prompts.yml b/worlds.yml similarity index 100% rename from taleweave/prompts.yml rename to worlds.yml