diff --git a/Makefile b/Makefile index 8802bab..5259b98 100644 --- a/Makefile +++ b/Makefile @@ -36,16 +36,16 @@ lint-check: black --check tests/ flake8 taleweave flake8 tests - isort --check-only --skip __init__.py --filter-files taleweave - isort --check-only --skip __init__.py --filter-files tests + isort --check-only --filter-files taleweave + isort --check-only --filter-files tests lint-fix: black taleweave/ black tests/ flake8 taleweave flake8 tests - isort --skip __init__.py --filter-files taleweave - isort --skip __init__.py --filter-files tests + isort --filter-files taleweave + isort --filter-files tests style: lint-fix diff --git a/prompts/llama-digest.yml b/prompts/llama-digest.yml index 1af5987..eb9694f 100644 --- a/prompts/llama-digest.yml +++ b/prompts/llama-digest.yml @@ -1,13 +1,5 @@ prompts: - # digest system - digest_action_move_other_enter: | - {{event.character | name}} entered the room through the {{source_portal | name}}. - digest_action_move_other_exit: | - {{event.character | name}} left the room, heading through the {{destination_portal | name}}. - digest_action_move_self_enter: | - You entered the room through the {{source_portal | name}}. - digest_action_move_self_exit: | - You left the room, heading through the {{destination_portal | name}}. + # action digest digest_action_move: | {{event.character | name}} entered the room. digest_action_take: | @@ -22,3 +14,13 @@ prompts: {{event.character | name}} told {{event.parameters[character]}} about something. digest_action_examine: | {{event.character | name}} examined the {{event.parameters[target]}}. + + # movement digest + digest_move_other_enter: | + {{event.character | name}} entered the room through the {{source_portal | name}}. + digest_move_other_exit: | + {{event.character | name}} left the room, heading through the {{destination_portal | name}}. + digest_move_self_enter: | + You entered the room through the {{source_portal | name}}. + digest_move_self_exit: | + You left the room, heading through the {{destination_portal | name}}. \ No newline at end of file diff --git a/taleweave/actions/optional.py b/taleweave/actions/optional.py index 1dafbc9..4a27e1a 100644 --- a/taleweave/actions/optional.py +++ b/taleweave/actions/optional.py @@ -252,7 +252,7 @@ def action_use(item: str, target: str) -> str: return outcome -def init() -> List[Callable]: +def init_optional() -> List[Callable]: """ Initialize the custom actions. """ diff --git a/taleweave/context.py b/taleweave/context.py index b3220cc..970d4c3 100644 --- a/taleweave/context.py +++ b/taleweave/context.py @@ -37,6 +37,7 @@ dungeon_master: Agent | None = None # TODO: wrap this into a class that can be passed around character_agents: Dict[str, Tuple[Character, Agent]] = {} event_emitter = EventEmitter() +extra_actions: List[Callable[..., str]] = [] game_config: Config = DEFAULT_CONFIG game_systems: List[GameSystem] = [] prompt_library: PromptLibrary = PromptLibrary(prompts={}) @@ -185,6 +186,10 @@ def get_system_data(system: str) -> Any | None: return system_data.get(system) +def get_extra_actions() -> List[Callable[..., str]]: + return extra_actions + + # endregion @@ -237,6 +242,11 @@ def set_system_data(system: str, data: Any): system_data[system] = data +def set_extra_actions(actions: List[Callable[..., str]]): + global extra_actions + extra_actions = actions + + # endregion diff --git a/taleweave/editor.py b/taleweave/editor.py index 89d3767..1b6b37c 100644 --- a/taleweave/editor.py +++ b/taleweave/editor.py @@ -9,6 +9,7 @@ from taleweave.generate import ( generate_item, generate_portals, generate_room, + link_rooms, ) from taleweave.main import load_or_initialize_system_data from taleweave.models.base import dump_model @@ -27,7 +28,6 @@ from taleweave.utils.search import ( ) from taleweave.utils.world import describe_entity - ENTITY_TYPES = ["room", "portal", "item", "character"] @@ -72,6 +72,9 @@ def parse_args(): "prompt", type=str, help="Prompt to generate the entity" ) generate_parser.add_argument("--room", type=str, help="Room the entity is in") + generate_parser.add_argument( + "--dest-room", type=str, help="Destination room for portals" + ) # Set up the 'delete' command delete_parser = subparsers.add_parser("delete", help="Delete an entity") @@ -91,6 +94,15 @@ def parse_args(): "--description", type=str, help="Description of the entity" ) + # Set up the 'link' command + link_parser = subparsers.add_parser("link", help="Link rooms") + link_parser.add_argument( + "rooms", + type=str, + nargs="*", + help="Rooms to link. Leave blank to link all rooms.", + ) + return parser.parse_args() @@ -212,24 +224,47 @@ def command_generate(args): dungeon_master = get_dungeon_master() systems = get_game_systems() - # TODO: Generate the entity if args.type == "room": room = generate_room(dungeon_master, world, systems) world.rooms.append(room) if args.type == "portal": - portal = generate_portals(dungeon_master, world, "TODO", "TODO", systems) - # TODO: Add portal to room and generate reverse portal from destination room + source_room = find_room(world, args.room) + if not source_room: + print(f"Room {args.room} not found") + return + + destination_room = find_room(world, args.dest_room) + if not destination_room: + print(f"Room {args.dest_room} not found") + return + + outgoing_portal, incoming_portal = generate_portals( + dungeon_master, world, source_room, destination_room, systems + ) + source_room.portals.append(outgoing_portal) + destination_room.portals.append(incoming_portal) if args.type == "item": + # TODO: add item to character or container inventory + room = find_room(world, args.room) + if not room: + print(f"Room {args.room} not found") + return + item = generate_item(dungeon_master, world, systems) - # TODO: Add item to room or character inventory + room.items.append(item) if args.type == "character": + room = find_room(world, args.room) + if not room: + print(f"Room {args.room} not found") + return + character = generate_character( - dungeon_master, world, systems, "TODO", args.prompt + dungeon_master, world, systems, room, args.prompt ) - # TODO: Add character to room + room.characters.append(character) save_world(args.state, args.world, world, state) @@ -291,6 +326,19 @@ def command_update(args): save_world(args.state, args.world, world, state) +def command_link(args): + print(f"Linking rooms {args.rooms}") + world, state = load_world(args.state, args.world) + print(world.name) + + dungeon_master = get_dungeon_master() + systems = get_game_systems() + + link_rooms(dungeon_master, world, systems) + + save_world(args.state, args.world, world, state) + + COMMAND_TABLE = { "list": command_list, "describe": command_describe, @@ -298,6 +346,7 @@ COMMAND_TABLE = { "generate": command_generate, "delete": command_delete, "update": command_update, + "link": command_link, } diff --git a/taleweave/main.py b/taleweave/main.py index e9f318c..3083212 100644 --- a/taleweave/main.py +++ b/taleweave/main.py @@ -1,6 +1,8 @@ +import argparse import atexit from functools import partial from glob import glob +from itertools import count from logging.config import dictConfig from os import environ, path from typing import List @@ -42,11 +44,15 @@ if environ.get("DEBUG", "false").lower() == "true": if True: from taleweave.context import ( + get_current_turn, get_prompt_library, get_system_data, set_current_turn, + set_current_world, set_dungeon_master, + set_extra_actions, set_game_config, + set_game_systems, set_system_data, subscribe, ) @@ -58,8 +64,9 @@ if True: from taleweave.models.files import TemplateFile, WorldPrompt from taleweave.models.prompt import PromptLibrary from taleweave.plugins import load_plugin - from taleweave.simulate import simulate_world from taleweave.state import create_agents, save_world, save_world_state + from taleweave.systems.action import init_action + from taleweave.systems.planning import init_planning from taleweave.utils.template import format_prompt @@ -72,8 +79,6 @@ def int_or_inf(value: str) -> float | int: # main def parse_args(): - import argparse - parser = argparse.ArgumentParser( description="Generate and simulate a text adventure world" ) @@ -364,9 +369,9 @@ def main(): extra_actions = [] if args.optional_actions: logger.info("loading optional actions") - from taleweave.actions.optional import init as init_optional_actions + from taleweave.actions.optional import init_optional - optional_actions = init_optional_actions() + optional_actions = init_optional() logger.info( f"loaded optional actions: {[action.__name__ for action in optional_actions]}" ) @@ -381,63 +386,61 @@ def main(): ) extra_actions.extend(module_actions) + set_extra_actions(extra_actions) + + # set up the game systems + systems: List[GameSystem] = [] + systems.extend(init_planning()) + systems.extend(init_action()) + # load extra systems from plugins - extra_systems: List[GameSystem] = [] for system_name in args.systems or []: logger.info(f"loading extra systems from {system_name}") module_systems = load_plugin(system_name) logger.info(f"loaded extra systems: {module_systems}") - extra_systems.extend(module_systems) + systems.extend(module_systems) # make sure the server system runs after any updates if args.server: from taleweave.server.websocket import server_system - extra_systems.append(GameSystem(name="server", simulate=server_system)) + systems.append(GameSystem(name="server", simulate=server_system)) + + set_game_systems(systems) # load or generate the world world_prompt = get_world_prompt(args) world, world_state_file, world_turn = load_or_generate_world( - args, config, players, extra_systems, world_prompt=world_prompt + args, config, players, systems, world_prompt=world_prompt ) + set_current_world(world) # make sure the snapshot system runs last def snapshot_system(world: World, turn: int, data: None = None) -> None: logger.info("taking snapshot of world state") save_world_state(world, turn, world_state_file) - extra_systems.append(GameSystem(name="snapshot", simulate=snapshot_system)) + systems.append(GameSystem(name="snapshot", simulate=snapshot_system)) # hack: send a snapshot to the websocket server if args.server: server_system(world, world_turn) - # create the DM - llm = agent_easy_connect() - memory_factory = partial( - make_limited_memory, limit=config.world.character.memory_limit - ) - world_builder = Agent( - "dungeon master", - format_prompt( - "world_generate_dungeon_master", - flavor=world_prompt.flavor, - theme=world_prompt.theme, - ), - {}, - llm, - memory_factory=memory_factory, - ) - set_dungeon_master(world_builder) + # run game systems for each turn + logger.info(f"simulating the world for {args.turns} turns using systems: {systems}") + for i in count(): + current_turn = get_current_turn() + logger.info(f"simulating turn {i} of {args.turns} (world turn {current_turn})") - # start the sim - logger.debug("simulating world: %s", world.name) - simulate_world( - world, - turns=args.turns, - actions=extra_actions, - systems=extra_systems, - ) + for system in systems: + if system.simulate: + logger.info(f"running system {system.name}") + system.simulate(world, current_turn) + + set_current_turn(current_turn + 1) + if i >= args.turns: + logger.info("reached turn limit at world turn %s", current_turn + 1) + break if __name__ == "__main__": diff --git a/taleweave/simulate.py b/taleweave/simulate.py deleted file mode 100644 index f66ea9e..0000000 --- a/taleweave/simulate.py +++ /dev/null @@ -1,393 +0,0 @@ -from functools import partial -from itertools import count -from json import loads -from logging import getLogger -from math import inf -from typing import Callable, Sequence - -from packit.agent import Agent -from packit.conditions import condition_or, condition_threshold -from packit.errors import ToolError -from packit.loops import loop_retry -from packit.results import function_result -from packit.toolbox import Toolbox - -from taleweave.actions.base import ( - action_ask, - action_examine, - action_give, - action_move, - action_take, - action_tell, -) -from taleweave.actions.planning import ( - check_calendar, - edit_note, - erase_notes, - get_recent_notes, - read_notes, - schedule_event, - summarize_notes, - take_note, -) -from taleweave.context import ( - broadcast, - get_character_agent_for_name, - get_character_for_agent, - get_current_turn, - get_current_world, - get_game_config, - get_prompt, - set_current_character, - set_current_room, - set_current_turn, - set_current_world, - set_game_systems, -) -from taleweave.errors import ActionError -from taleweave.game_system import GameSystem -from taleweave.models.entity import Character, Room, World -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.search import find_containing_room -from taleweave.utils.template import format_prompt -from taleweave.utils.world import format_attributes - -logger = getLogger(__name__) - - -def world_result_parser(value, agent, **kwargs): - current_world = get_current_world() - if not current_world: - raise ValueError( - "The current world must be set before calling world_result_parser" - ) - - logger.debug(f"parsing action for {agent.name}: {value}") - - current_character = get_character_for_agent(agent) - current_room = next( - (room for room in current_world.rooms if current_character in room.characters), - None, - ) - - set_current_room(current_room) - set_current_character(current_character) - - return function_result(value, agent=agent, **kwargs) - - -def prompt_character_action( - room, character, agent, action_toolbox, current_turn -) -> str: - action_names = action_toolbox.list_tools() - - # collect data for the prompt - notes_prompt, events_prompt = get_notes_events(character, current_turn) - - room_characters = [character.name for character in room.characters] - room_items = [item.name for item in room.items] - room_directions = [portal.name for portal in room.portals] - - character_attributes = format_attributes(character) - # character_effects = [effect.name for effect in character.active_effects] - character_items = [item.name for item in character.items] - - # set up a result parser for the agent - def result_parser(value, **kwargs): - if not room or not character: - raise ValueError("Room and character must be set before parsing results") - - # trim suffixes that are used elsewhere - value = value.removesuffix("END").strip() - - # fix the "action_ move" whitespace issue - if '"action_ ' in value: - value = value.replace('"action_ ', '"action_') - - # fix unbalanced curly braces - if value.startswith("{") and not value.endswith("}"): - open_count = value.count("{") - close_count = value.count("}") - - if open_count > close_count: - fixed_value = value + ("}" * (open_count - close_count)) - try: - loads(fixed_value) - value = fixed_value - except Exception: - pass - - try: - result = world_result_parser(value, **kwargs) - - # TODO: try to avoid parsing the JSON twice - event = ActionEvent.from_json(value, room, character) - broadcast(event) - - return result - except ToolError as e: - e_str = str(e) - if e_str and "Error running tool" in e_str: - # extract the tool name and rest of the message from the error - # the format is: "Error running tool: : " - action_name, message = e_str.split(":", 1) - action_name = action_name.removeprefix("Error running tool").strip() - message = message.strip() - raise ActionError( - format_prompt( - "world_simulate_character_action_error_action", - action=action_name, - message=message, - ) - ) - elif e_str and "Unknown tool" in e_str: - raise ActionError( - format_prompt( - "world_simulate_character_action_error_unknown_tool", - actions=action_names, - ) - ) - else: - raise ActionError( - format_prompt( - "world_simulate_character_action_error_json", - actions=action_names, - ) - ) - - # prompt and act - logger.info("starting turn for character: %s", character.name) - result = loop_retry( - agent, - format_prompt( - "world_simulate_character_action", - actions=action_names, - character_items=character_items, - attributes=character_attributes, - directions=room_directions, - room=room, - visible_characters=room_characters, - visible_items=room_items, - notes_prompt=notes_prompt, - events_prompt=events_prompt, - ), - result_parser=result_parser, - toolbox=action_toolbox, - ) - - logger.debug(f"{character.name} action result: {result}") - if agent.memory: - agent.memory.append(result) - - return result - - -def get_notes_events(character: Character, current_turn: int): - recent_notes = get_recent_notes(character) - upcoming_events = get_upcoming_events(character, current_turn) - - if len(recent_notes) > 0: - notes = "\n".join(recent_notes) - notes_prompt = format_prompt( - "world_simulate_character_planning_notes_some", notes=notes - ) - else: - notes_prompt = format_prompt("world_simulate_character_planning_notes_none") - - if len(upcoming_events) > 0: - current_turn = get_current_turn() - events = [ - 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 = format_prompt( - "world_simulate_character_planning_events_some", events=events - ) - else: - events_prompt = format_prompt("world_simulate_character_planning_events_none") - - return notes_prompt, events_prompt - - -def prompt_character_planning( - room: Room, - character: Character, - agent: Agent, - planner_toolbox: Toolbox, - current_turn: int, - max_steps: int | None = None, -) -> str: - config = get_game_config() - max_steps = max_steps or config.world.turn.planning_steps - - notes_prompt, events_prompt = get_notes_events(character, current_turn) - - event_count = len(character.planner.calendar.events) - note_count = len(character.planner.notes) - - def result_parser(value, **kwargs): - try: - return function_result(value, **kwargs) - except ToolError as e: - e_str = str(e) - if e_str and "Error running tool" in e_str: - # extract the tool name and rest of the message from the error - # the format is: "Error running tool: : " - action_name, message = e_str.split(":", 2) - action_name = action_name.removeprefix("Error running tool").strip() - message = message.strip() - raise ActionError( - format_prompt( - "world_simulate_character_planning_error_action", - action=action_name, - message=message, - ) - ) - elif e_str and "Unknown tool" in e_str: - raise ActionError( - format_prompt( - "world_simulate_character_planning_error_unknown_tool", - actions=planner_toolbox.list_tools(), - ) - ) - else: - raise ActionError( - format_prompt( - "world_simulate_character_planning_error_json", - actions=planner_toolbox.list_tools(), - ) - ) - - logger.info("starting planning for character: %s", character.name) - _, condition_end, result_parser = make_keyword_condition( - get_prompt("world_simulate_character_planning_done"), - result_parser=result_parser, - ) - stop_condition = condition_or( - condition_end, partial(condition_threshold, max=max_steps) - ) - - i = 0 - while not stop_condition(current=i): - result = loop_retry( - agent, - format_prompt( - "world_simulate_character_planning", - event_count=event_count, - events_prompt=events_prompt, - note_count=note_count, - notes_prompt=notes_prompt, - room_summary=summarize_room(room, character), - ), - result_parser=result_parser, - stop_condition=stop_condition, - toolbox=planner_toolbox, - ) - - if agent.memory: - agent.memory.append(result) - - i += 1 - - return result - - -def simulate_world( - world: World, - turns: float | int = inf, - actions: Sequence[Callable[..., str]] = [], - systems: Sequence[GameSystem] = [], -): - logger.info("simulating the world") - set_current_world(world) - set_game_systems(systems) - - # build a toolbox for the actions - action_tools = Toolbox( - [ - action_ask, - action_give, - action_examine, - action_move, - action_take, - action_tell, - *actions, - ] - ) - - # build a toolbox for the planners - planner_toolbox = Toolbox( - [ - check_calendar, - erase_notes, - read_notes, - edit_note, - schedule_event, - summarize_notes, - take_note, - ] - ) - - # simulate each character - for i in count(): - current_turn = get_current_turn() - logger.info(f"simulating turn {i} of {turns} (world turn {current_turn})") - - for character_name in world.order: - character, agent = get_character_agent_for_name(character_name) - if not agent or not character: - logger.error(f"agent or character not found for name {character_name}") - continue - - room = find_containing_room(world, character) - if not room: - logger.error(f"character {character_name} is not in a room") - continue - - # prep context - set_current_room(room) - set_current_character(character) - - # decrement effects on the character and remove any that have expired - expire_effects(character) - expire_events(character, current_turn) - - # give the character a chance to think and check their planner - if agent.memory and len(agent.memory) > 0: - try: - thoughts = prompt_character_planning( - room, character, agent, planner_toolbox, current_turn - ) - logger.debug(f"{character.name} thinks: {thoughts}") - except Exception: - logger.exception( - f"error during planning for character {character.name}" - ) - - try: - result = prompt_character_action( - room, character, agent, action_tools, current_turn - ) - result_event = ResultEvent( - result=result, room=room, character=character - ) - broadcast(result_event) - except Exception: - logger.exception(f"error during action for character {character.name}") - - for system in systems: - if system.simulate: - system.simulate(world, current_turn) - - set_current_turn(current_turn + 1) - if i >= turns: - logger.info("reached turn limit at world turn %s", current_turn + 1) - break diff --git a/taleweave/systems/action.py b/taleweave/systems/action.py new file mode 100644 index 0000000..0f0e60b --- /dev/null +++ b/taleweave/systems/action.py @@ -0,0 +1,218 @@ +from json import loads +from logging import getLogger +from typing import Any + +from packit.errors import ToolError +from packit.loops import loop_retry +from packit.results import function_result +from packit.toolbox import Toolbox + +from taleweave.actions.base import ( + action_ask, + action_examine, + action_give, + action_move, + action_take, + action_tell, +) +from taleweave.context import ( + broadcast, + get_character_agent_for_name, + get_character_for_agent, + get_current_world, + get_extra_actions, + set_current_character, + set_current_room, +) +from taleweave.errors import ActionError +from taleweave.game_system import GameSystem +from taleweave.models.entity import World +from taleweave.models.event import ActionEvent, ResultEvent +from taleweave.utils.effect import expire_effects +from taleweave.utils.search import find_containing_room +from taleweave.utils.template import format_prompt +from taleweave.utils.world import format_attributes + +from .planning import get_notes_events + +logger = getLogger(__name__) + + +def world_result_parser(value, agent, **kwargs): + current_world = get_current_world() + if not current_world: + raise ValueError( + "The current world must be set before calling world_result_parser" + ) + + logger.debug(f"parsing action for {agent.name}: {value}") + + current_character = get_character_for_agent(agent) + current_room = next( + (room for room in current_world.rooms if current_character in room.characters), + None, + ) + + set_current_room(current_room) + set_current_character(current_character) + + return function_result(value, agent=agent, **kwargs) + + +def prompt_character_action( + room, character, agent, action_toolbox, current_turn +) -> str: + action_names = action_toolbox.list_tools() + + # collect data for the prompt + notes_prompt, events_prompt = get_notes_events(character, current_turn) + + room_characters = [character.name for character in room.characters] + room_items = [item.name for item in room.items] + room_directions = [portal.name for portal in room.portals] + + character_attributes = format_attributes(character) + # character_effects = [effect.name for effect in character.active_effects] + character_items = [item.name for item in character.items] + + # set up a result parser for the agent + def result_parser(value, **kwargs): + if not room or not character: + raise ValueError("Room and character must be set before parsing results") + + # trim suffixes that are used elsewhere + value = value.removesuffix("END").strip() + + # fix the "action_ move" whitespace issue + if '"action_ ' in value: + value = value.replace('"action_ ', '"action_') + + # fix unbalanced curly braces + if value.startswith("{") and not value.endswith("}"): + open_count = value.count("{") + close_count = value.count("}") + + if open_count > close_count: + fixed_value = value + ("}" * (open_count - close_count)) + try: + loads(fixed_value) + value = fixed_value + except Exception: + pass + + try: + result = world_result_parser(value, **kwargs) + + # TODO: try to avoid parsing the JSON twice + event = ActionEvent.from_json(value, room, character) + broadcast(event) + + return result + except ToolError as e: + e_str = str(e) + if e_str and "Error running tool" in e_str: + # extract the tool name and rest of the message from the error + # the format is: "Error running tool: : " + action_name, message = e_str.split(":", 1) + action_name = action_name.removeprefix("Error running tool").strip() + message = message.strip() + raise ActionError( + format_prompt( + "world_simulate_character_action_error_action", + action=action_name, + message=message, + ) + ) + elif e_str and "Unknown tool" in e_str: + raise ActionError( + format_prompt( + "world_simulate_character_action_error_unknown_tool", + actions=action_names, + ) + ) + else: + raise ActionError( + format_prompt( + "world_simulate_character_action_error_json", + actions=action_names, + ) + ) + + # prompt and act + logger.info("starting turn for character: %s", character.name) + result = loop_retry( + agent, + format_prompt( + "world_simulate_character_action", + actions=action_names, + character_items=character_items, + attributes=character_attributes, + directions=room_directions, + room=room, + visible_characters=room_characters, + visible_items=room_items, + notes_prompt=notes_prompt, + events_prompt=events_prompt, + ), + result_parser=result_parser, + toolbox=action_toolbox, + ) + + logger.debug(f"{character.name} action result: {result}") + if agent.memory: + agent.memory.append(result) + + return result + + +action_tools: Toolbox | None = None + + +def initialize_action(world: World): + global action_tools + + extra_actions = get_extra_actions() + action_tools = Toolbox( + [ + action_ask, + action_give, + action_examine, + action_move, + action_take, + action_tell, + *extra_actions, + ] + ) + + +def simulate_action(world: World, turn: int, data: Any | None = None): + for character_name in world.order: + character, agent = get_character_agent_for_name(character_name) + if not agent or not character: + logger.error(f"agent or character not found for name {character_name}") + continue + + room = find_containing_room(world, character) + if not room: + logger.error(f"character {character_name} is not in a room") + continue + + # prep context + set_current_room(room) + set_current_character(character) + + # decrement effects on the character and remove any that have expired + expire_effects(character) + + try: + result = prompt_character_action(room, character, agent, action_tools, turn) + result_event = ResultEvent(result=result, room=room, character=character) + broadcast(result_event) + except Exception: + logger.exception(f"error during action for character {character.name}") + + +def init_action(): + return [ + GameSystem("action", initialize=initialize_action, simulate=simulate_action) + ] diff --git a/taleweave/systems/digest.py b/taleweave/systems/digest.py index ed34e91..2cb7ccd 100644 --- a/taleweave/systems/digest.py +++ b/taleweave/systems/digest.py @@ -6,7 +6,7 @@ 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, find_portal, find_room -from taleweave.utils.template import format_str +from taleweave.utils.template import format_prompt, format_str logger = getLogger(__name__) @@ -44,11 +44,12 @@ def create_move_digest( character_mode = "self" if (event.character == active_character) else "other" direction_mode = "enter" if (destination_room == active_room) else "exit" - message = format_str( + message = format_prompt( f"digest_move_{character_mode}_{direction_mode}", destination_portal=destination_portal, destination_room=destination_room, direction=direction, + event=event, source_portal=source_portal, source_room=source_room, ) @@ -118,6 +119,9 @@ def format_digest( if not isinstance(entity, Character): return "" + if perspective != FormatPerspective.SECOND_PERSON: + return "" + buffer = character_buffers[entity.name] world = get_current_world() diff --git a/taleweave/systems/planning.py b/taleweave/systems/planning.py new file mode 100644 index 0000000..c453ba2 --- /dev/null +++ b/taleweave/systems/planning.py @@ -0,0 +1,202 @@ +from functools import partial +from logging import getLogger +from typing import Any + +from packit.agent import Agent +from packit.conditions import condition_or, condition_threshold +from packit.errors import ToolError +from packit.loops import loop_retry +from packit.results import function_result +from packit.toolbox import Toolbox + +from taleweave.actions.planning import ( + check_calendar, + edit_note, + erase_notes, + get_recent_notes, + read_notes, + schedule_event, + summarize_notes, + take_note, +) +from taleweave.context import ( + get_character_agent_for_name, + get_current_turn, + get_game_config, + get_prompt, + set_current_character, + set_current_room, +) +from taleweave.errors import ActionError +from taleweave.game_system import GameSystem +from taleweave.models.entity import Character, Room, World +from taleweave.utils.conversation import make_keyword_condition, summarize_room +from taleweave.utils.planning import expire_events, get_upcoming_events +from taleweave.utils.search import find_containing_room +from taleweave.utils.template import format_prompt + +logger = getLogger(__name__) + +# build a toolbox for the planners +planner_toolbox = Toolbox( + [ + check_calendar, + erase_notes, + read_notes, + edit_note, + schedule_event, + summarize_notes, + take_note, + ] +) + + +def get_notes_events(character: Character, current_turn: int): + recent_notes = get_recent_notes(character) + upcoming_events = get_upcoming_events(character, current_turn) + + if len(recent_notes) > 0: + notes = "\n".join(recent_notes) + notes_prompt = format_prompt( + "world_simulate_character_planning_notes_some", notes=notes + ) + else: + notes_prompt = format_prompt("world_simulate_character_planning_notes_none") + + if len(upcoming_events) > 0: + current_turn = get_current_turn() + events = [ + 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 = format_prompt( + "world_simulate_character_planning_events_some", events=events + ) + else: + events_prompt = format_prompt("world_simulate_character_planning_events_none") + + return notes_prompt, events_prompt + + +def prompt_character_planning( + room: Room, + character: Character, + agent: Agent, + planner_toolbox: Toolbox, + current_turn: int, + max_steps: int | None = None, +) -> str: + config = get_game_config() + max_steps = max_steps or config.world.turn.planning_steps + + notes_prompt, events_prompt = get_notes_events(character, current_turn) + + event_count = len(character.planner.calendar.events) + note_count = len(character.planner.notes) + + def result_parser(value, **kwargs): + try: + return function_result(value, **kwargs) + except ToolError as e: + e_str = str(e) + if e_str and "Error running tool" in e_str: + # extract the tool name and rest of the message from the error + # the format is: "Error running tool: : " + action_name, message = e_str.split(":", 2) + action_name = action_name.removeprefix("Error running tool").strip() + message = message.strip() + raise ActionError( + format_prompt( + "world_simulate_character_planning_error_action", + action=action_name, + message=message, + ) + ) + elif e_str and "Unknown tool" in e_str: + raise ActionError( + format_prompt( + "world_simulate_character_planning_error_unknown_tool", + actions=planner_toolbox.list_tools(), + ) + ) + else: + raise ActionError( + format_prompt( + "world_simulate_character_planning_error_json", + actions=planner_toolbox.list_tools(), + ) + ) + + logger.info("starting planning for character: %s", character.name) + _, condition_end, result_parser = make_keyword_condition( + get_prompt("world_simulate_character_planning_done"), + result_parser=result_parser, + ) + stop_condition = condition_or( + condition_end, partial(condition_threshold, max=max_steps) + ) + + i = 0 + while not stop_condition(current=i): + result = loop_retry( + agent, + format_prompt( + "world_simulate_character_planning", + event_count=event_count, + events_prompt=events_prompt, + note_count=note_count, + notes_prompt=notes_prompt, + room_summary=summarize_room(room, character), + ), + result_parser=result_parser, + stop_condition=stop_condition, + toolbox=planner_toolbox, + ) + + if agent.memory: + agent.memory.append(result) + + i += 1 + + return result + + +def simulate_planning(world: World, turn: int, data: Any | None = None): + for character_name in world.order: + character, agent = get_character_agent_for_name(character_name) + if not agent or not character: + logger.error(f"agent or character not found for name {character_name}") + continue + + room = find_containing_room(world, character) + if not room: + logger.error(f"character {character_name} is not in a room") + continue + + # prep context + set_current_room(room) + set_current_character(character) + + # decrement effects on the character and remove any that have expired + expire_events(character, turn) # TODO: move to planning + + # give the character a chance to think and check their planner + if agent.memory and len(agent.memory) > 0: + try: + thoughts = prompt_character_planning( + room, character, agent, planner_toolbox, turn + ) + logger.debug(f"{character.name} thinks: {thoughts}") + except Exception: + logger.exception( + f"error during planning for character {character.name}" + ) + + +def init_planning(): + return [GameSystem("planning", simulate=simulate_planning)] diff --git a/taleweave/systems/sim/__init__.py b/taleweave/systems/sim/__init__.py index fc144df..e29b874 100644 --- a/taleweave/systems/sim/__init__.py +++ b/taleweave/systems/sim/__init__.py @@ -1,9 +1,9 @@ +from taleweave.systems.logic import load_logic + from .hunger_actions import action_cook, action_eat from .hygiene_actions import action_wash from .sleeping_actions import action_sleep -from taleweave.systems.logic import load_logic - LOGIC_FILES = [ "./taleweave/systems/sim/environment_logic.yaml", "./taleweave/systems/sim/hunger_logic.yaml", diff --git a/taleweave/systems/snapshot.py b/taleweave/systems/snapshot.py new file mode 100644 index 0000000..699eaa5 --- /dev/null +++ b/taleweave/systems/snapshot.py @@ -0,0 +1,18 @@ +from logging import getLogger +from typing import Any + +from taleweave.game_system import GameSystem +from taleweave.models.entity import World +from taleweave.state import save_world_state + +logger = getLogger(__name__) + + +def simulate_snapshot(world: World, turn: int, data: Any | None = None): + logger.info("taking snapshot of world state") + world_state_file = "TODO" # TODO: get world state file from somewhere + save_world_state(world, turn, world_state_file) + + +def init(): + return [GameSystem("snapshot", simulate=simulate_snapshot)] diff --git a/taleweave/systems/weather/__init__.py b/taleweave/systems/weather/__init__.py index 33d0ab6..f3fac4b 100644 --- a/taleweave/systems/weather/__init__.py +++ b/taleweave/systems/weather/__init__.py @@ -1,16 +1,17 @@ from functools import partial -from typing import List -from taleweave.context import get_dungeon_master -from taleweave.models.base import dataclass -from taleweave.models.entity import World -from taleweave.systems.logic import load_logic -from taleweave.game_system import GameSystem -from packit.agent import Agent -from taleweave.models.entity import Room, WorldEntity -from taleweave.utils.string import or_list -from packit.results import enum_result -from packit.loops import loop_retry from logging import getLogger +from typing import List + +from packit.agent import Agent +from packit.loops import loop_retry +from packit.results import enum_result + +from taleweave.context import get_dungeon_master +from taleweave.game_system import GameSystem +from taleweave.models.base import dataclass +from taleweave.models.entity import Room, World, WorldEntity +from taleweave.systems.logic import load_logic +from taleweave.utils.string import or_list logger = getLogger(__name__) diff --git a/taleweave/utils/world.py b/taleweave/utils/world.py index 5cf0447..ae98116 100644 --- a/taleweave/utils/world.py +++ b/taleweave/utils/world.py @@ -58,6 +58,11 @@ def format_attributes( for system in systems if system.format ] + attribute_descriptions = [ + description + for description in attribute_descriptions + if len(description.strip()) > 0 + ] return f"{'. '.join(attribute_descriptions)}" diff --git a/worlds.yml b/worlds.yml index 022aaa2..b4956b2 100644 --- a/worlds.yml +++ b/worlds.yml @@ -28,8 +28,12 @@ templates: theme: post-apocalyptic world where the only survivors are sentient robots flavor: create a world where the only survivors of a nuclear apocalypse are sentient robots, who must now rebuild society from scratch - name: haunted-house - theme: haunted house in the middle of nowhere - flavor: create a spooky and suspenseful world where a group of people are trapped in a haunted house in the middle of nowhere + theme: group of unexpecting friends find a haunted house in the middle of nowhere + flavor: | + create a spooky and suspenseful world where a group of innocent, unexpecting people find and become trapped in a + haunted house in the middle of nowhere. make sure you create some rooms for the road up to the house as well as + the interior of the house itself. include a variety of characters and make sure they will fully utilize all of the + actions available to them in this world, exploring and interacting with each other and the environment. - name: magical-kingdom theme: dangerous magical fantasy world flavor: make a strange and dangerous world where magic winds its way through everything and incredibly powerful beings drink, fight, and wander the halls