1
0
Fork 0

use the same events on the react client

This commit is contained in:
Sean Sube 2024-05-09 23:45:10 -05:00
parent 94e02ebfe1
commit 90912d2bfe
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
11 changed files with 198 additions and 188 deletions

View File

@ -125,19 +125,20 @@ python3 -m adventure.main \
# --systems adventure.custom_systems:init_logic # --systems adventure.custom_systems:init_logic
``` ```
This will generate a relatively small world with 3 rooms or areas, run for 30 steps, then shut down. The world will be This will generate a relatively small world with 3 rooms or areas, run for 30 steps, then shut down.
saved to a file named `worlds/outback-animals-1.json` and the state will be saved after each step to another file named
`worlds/outback-animals-1.state.json`. The world can be stopped at any time by pressing Ctrl-C, although the step in The world will be saved to a file named `worlds/outback-animals-1.json` and the state will be saved after each step to
progress will be lost. The saved state can be resumed and played for any number of additional steps. another file named `worlds/outback-animals-1.state.json`. The world can be stopped at any time by pressing Ctrl-C,
although the step in progress will be lost. The saved state can be resumed and played for any number of additional
steps by running the server again with the same arguments.
> Note: `module.name:function_name` and `path/filename.yml:key` are patterns you will see repeated throughout TaleWeave AI. > Note: `module.name:function_name` and `path/filename.yml:key` are patterns you will see repeated throughout TaleWeave AI.
> They indicate a Python module and function within it, or a data file and key within it, respectively. > They indicate a Python module and function within it, or a data file and key within it, respectively.
The `sim_systems` provide many mechanics from popular life simulations, including hunger, thirst, exhaustion, and mood. The `sim_systems` provide many mechanics from popular life simulations, including hunger, thirst, exhaustion, and mood.
Custom actions and systems can be used to provide any other mechanics that are desired for your setting. The logic Custom actions and systems can be used to provide any other mechanics that are desired for your setting. The logic
system uses a combination of Python and YAML to build complex systems that add and modify the attributes on rooms, system uses a combination of Python and YAML to modify the prompts connected to rooms, characters, and items in the
characters, and items. Attributes can become sentences and fragments in the character prompt and entity description, world, influencing the behavior of the language models.
allowing the logic to influence the language models.
## Documentation ## Documentation

View File

@ -3,8 +3,9 @@ from typing import Callable, Dict, Tuple
from packit.agent import Agent from packit.agent import Agent
from adventure.models.entity import Actor, Room, World from adventure.models.entity import Actor, Room, World
from adventure.models.event import GameEvent
current_broadcast: Callable[[str], None] | None = None current_broadcast: Callable[[str | GameEvent], None] | None = None
current_world: World | None = None current_world: World | None = None
current_room: Room | None = None current_room: Room | None = None
current_actor: Actor | None = None current_actor: Actor | None = None
@ -16,7 +17,7 @@ dungeon_master: Agent | None = None
actor_agents: Dict[str, Tuple[Actor, Agent]] = {} actor_agents: Dict[str, Tuple[Actor, Agent]] = {}
def broadcast(message: str): def broadcast(message: str | GameEvent):
if current_broadcast: if current_broadcast:
current_broadcast(message) current_broadcast(message)

View File

@ -3,11 +3,11 @@ from os import environ
from queue import Queue from queue import Queue
from re import sub from re import sub
from threading import Thread from threading import Thread
from typing import Tuple
from discord import Client, Embed, File, Intents from discord import Client, Embed, File, Intents
from adventure.context import ( from adventure.context import (
broadcast,
get_actor_agent_for_name, get_actor_agent_for_name,
get_current_world, get_current_world,
set_actor_agent, set_actor_agent,
@ -16,6 +16,7 @@ from adventure.models.event import (
ActionEvent, ActionEvent,
GameEvent, GameEvent,
GenerateEvent, GenerateEvent,
PlayerEvent,
PromptEvent, PromptEvent,
ReplyEvent, ReplyEvent,
ResultEvent, ResultEvent,
@ -28,7 +29,7 @@ logger = getLogger(__name__)
client = None client = None
active_tasks = set() active_tasks = set()
prompt_queue: Queue[Tuple[GameEvent, Embed | str]] = Queue() event_queue: Queue[GameEvent] = Queue()
def remove_tags(text: str) -> str: def remove_tags(text: str) -> str:
@ -156,8 +157,7 @@ class AdventureClient(Client):
event.prompt, event.prompt,
) )
# TODO: build an embed from the prompt event_queue.put(event)
prompt_queue.put((event, event.prompt))
return True return True
player = RemotePlayer( player = RemotePlayer(
@ -167,19 +167,19 @@ class AdventureClient(Client):
set_player(user_name, player) set_player(user_name, player)
logger.info(f"{user_name} has joined the game as {actor.name}!") logger.info(f"{user_name} has joined the game as {actor.name}!")
await message.channel.send( join_event = PlayerEvent("join", character_name, user_name)
f"{user_name} has joined the game as {actor.name}!" return broadcast(join_event)
)
return
if message.content.startswith("!leave"):
# TODO: revert to LLM agent
logger.info(f"{user_name} has left the game!")
await message.channel.send(f"{user_name} has left the game!")
return
player = get_player(user_name) player = get_player(user_name)
if player and isinstance(player, RemotePlayer): if player:
if message.content.startswith("!leave"):
# TODO: check if player is playing
# TODO: revert to LLM agent
logger.info(f"{user_name} has left the game!")
leave_event = PlayerEvent("leave", player.name, user_name)
return broadcast(leave_event)
if isinstance(player, RemotePlayer):
content = remove_tags(message.content) content = remove_tags(message.content)
player.input_queue.put(content) player.input_queue.put(content)
logger.info( logger.info(
@ -194,41 +194,49 @@ class AdventureClient(Client):
def launch_bot(): def launch_bot():
def bot_main():
global client global client
intents = Intents.default() intents = Intents.default()
# intents.message_content = True # intents.message_content = True
client = AdventureClient(intents=intents) client = AdventureClient(intents=intents)
def bot_main():
if not client:
raise ValueError("No Discord client available")
client.run(environ["DISCORD_TOKEN"]) client.run(environ["DISCORD_TOKEN"])
def prompt_main(): def send_main():
from time import sleep from time import sleep
while True: while True:
sleep(0.1) sleep(0.1)
if prompt_queue.empty(): if event_queue.empty():
# logger.debug("no events to prompt")
continue continue
if len(active_tasks) > 0: if len(active_tasks) > 0:
logger.debug("waiting for active tasks to complete")
continue continue
event, prompt = prompt_queue.get() event = event_queue.get()
logger.info("Prompting for event %s: %s", event, prompt) logger.info("broadcasting event %s", event.type)
if client: if client:
prompt_task = client.loop.create_task(broadcast_event(prompt)) event_task = client.loop.create_task(broadcast_event(event))
active_tasks.add(prompt_task) active_tasks.add(event_task)
prompt_task.add_done_callback(active_tasks.discard) event_task.add_done_callback(active_tasks.discard)
else:
logger.warning("no Discord client available")
bot_thread = Thread(target=bot_main, daemon=True) bot_thread = Thread(target=bot_main, daemon=True)
bot_thread.start() bot_thread.start()
prompt_thread = Thread(target=prompt_main, daemon=True) send_thread = Thread(target=send_main, daemon=True)
prompt_thread.start() send_thread.start()
return [bot_thread, prompt_thread] return [bot_thread, send_thread]
def stop_bot(): def stop_bot():
@ -253,45 +261,51 @@ def get_active_channels():
] ]
async def broadcast_event(message: str | Embed): def bot_event(event: GameEvent):
event_queue.put(event)
async def broadcast_event(message: str | GameEvent):
if not client: if not client:
logger.warning("No Discord client available") logger.warning("no Discord client available")
return return
active_channels = get_active_channels() active_channels = get_active_channels()
if not active_channels: if not active_channels:
logger.warning("No active channels") logger.warning("no active channels")
return return
for channel in active_channels: for channel in active_channels:
if isinstance(message, str): if isinstance(message, str):
logger.info("Broadcasting to channel %s: %s", channel, message) logger.info("broadcasting to channel %s: %s", channel, message)
await channel.send(content=message) await channel.send(content=message)
elif isinstance(message, Embed): elif isinstance(message, GameEvent):
embed = embed_from_event(message)
logger.info( logger.info(
"Broadcasting to channel %s: %s - %s", "broadcasting to channel %s: %s - %s",
channel, channel,
message.title, embed.title,
message.description, embed.description,
) )
await channel.send(embed=message) await channel.send(embed=embed)
def bot_event(event: GameEvent): def embed_from_event(event: GameEvent) -> Embed:
if isinstance(event, GenerateEvent): if isinstance(event, GenerateEvent):
bot_generate(event) return embed_from_generate(event)
elif isinstance(event, ResultEvent): elif isinstance(event, ResultEvent):
bot_result(event) return embed_from_result(event)
elif isinstance(event, (ActionEvent, ReplyEvent)): elif isinstance(event, (ActionEvent, ReplyEvent)):
bot_action(event) return embed_from_action(event)
elif isinstance(event, StatusEvent): elif isinstance(event, StatusEvent):
pass return embed_from_status(event)
elif isinstance(event, PlayerEvent):
return embed_from_player(event)
else: else:
logger.warning("Unknown event type: %s", event) logger.warning("unknown event type: %s", event)
def bot_action(event: ActionEvent | ReplyEvent): def embed_from_action(event: ActionEvent | ReplyEvent):
try:
action_embed = Embed(title=event.room.name, description=event.actor.name) action_embed = Embed(title=event.room.name, description=event.actor.name)
if isinstance(event, ActionEvent): if isinstance(event, ActionEvent):
@ -305,20 +319,41 @@ def bot_action(event: ActionEvent | ReplyEvent):
else: else:
action_embed.add_field(name="Message", value=event.text) action_embed.add_field(name="Message", value=event.text)
prompt_queue.put((event, action_embed)) return action_embed
except Exception as e:
logger.error("Failed to broadcast action: %s", e)
def bot_generate(event: GenerateEvent): def embed_from_generate(event: GenerateEvent) -> Embed:
prompt_queue.put((event, event.name)) generate_embed = Embed(title="Generating", description=event.name)
return generate_embed
def bot_result(event: ResultEvent): def embed_from_result(event: ResultEvent):
text = event.result text = event.result
if len(text) > 1000: if len(text) > 1000:
text = text[:1000] + "..." text = text[:1000] + "..."
result_embed = Embed(title=event.room.name, description=event.actor.name) result_embed = Embed(title=event.room.name, description=event.actor.name)
result_embed.add_field(name="Result", value=text) result_embed.add_field(name="Result", value=text)
prompt_queue.put((event, result_embed)) return result_embed
def embed_from_player(event: PlayerEvent):
if event.status == "join":
title = "New Player"
description = f"{event.client} is now playing as {event.character}"
else:
title = "Player Left"
description = f"{event.client} has left the game, {event.character} will be played by the AI"
player_embed = Embed(title=title, description=description)
return player_embed
def embed_from_status(event: StatusEvent):
# TODO: add room and actor
status_embed = Embed(
title=event.room.name if event.room else "",
description=event.actor.name if event.actor else "",
)
status_embed.add_field(name="Status", value=event.text)
return status_embed

View File

@ -41,6 +41,15 @@ logger = logger_with_colors(__name__, level="DEBUG")
load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True) load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True)
# start the debugger, if needed
if environ.get("DEBUG", "false").lower() == "true":
import debugpy
debugpy.listen(5679)
logger.info("waiting for debugger to attach...")
debugpy.wait_for_client()
# main # main
def parse_args(): def parse_args():
import argparse import argparse

View File

@ -1,5 +1,5 @@
from json import loads from json import loads
from typing import Callable, Dict, Literal from typing import Any, Callable, Dict, List, Literal
from .base import dataclass from .base import dataclass
from .entity import Actor, Item, Room, WorldEntity from .entity import Actor, Item, Room, WorldEntity
@ -11,7 +11,7 @@ class BaseEvent:
A base event class. A base event class.
""" """
event: str type: str
@dataclass @dataclass
@ -20,7 +20,7 @@ class GenerateEvent:
A new entity has been generated. A new entity has been generated.
""" """
event = "generate" type = "generate"
name: str name: str
entity: WorldEntity | None = None entity: WorldEntity | None = None
@ -39,9 +39,9 @@ class ActionEvent:
An actor has taken an action. An actor has taken an action.
""" """
event = "action" type = "action"
action: str action: str
parameters: Dict[str, str] parameters: Dict[str, bool | float | int | str]
room: Room room: Room
actor: Actor actor: Actor
@ -65,7 +65,7 @@ class PromptEvent:
A prompt for an actor to take an action. A prompt for an actor to take an action.
""" """
event = "prompt" type = "prompt"
prompt: str prompt: str
room: Room room: Room
actor: Actor actor: Actor
@ -83,7 +83,7 @@ class ReplyEvent:
This is the non-JSON version of an ActionEvent. This is the non-JSON version of an ActionEvent.
""" """
event = "text" type = "reply"
text: str text: str
room: Room room: Room
actor: Actor actor: Actor
@ -99,7 +99,7 @@ class ResultEvent:
A result of an action. A result of an action.
""" """
event = "result" type = "result"
result: str result: str
room: Room room: Room
actor: Actor actor: Actor
@ -111,19 +111,34 @@ class StatusEvent:
A status broadcast event with text. A status broadcast event with text.
""" """
event = "status" type = "status"
text: str text: str
room: Room | None = None room: Room | None = None
actor: Actor | None = None actor: Actor | None = None
@dataclass
class SnapshotEvent:
"""
A snapshot of the world state.
This one is slightly unusual, because the world has already been dumped to a JSON-compatible dictionary.
That is especially important for the memory, which is a dictionary of actor names to lists of messages.
"""
type = "snapshot"
world: Dict[str, Any]
memory: Dict[str, List[Any]]
step: int
@dataclass @dataclass
class PlayerEvent: class PlayerEvent:
""" """
A player joining or leaving the game. A player joining or leaving the game.
""" """
event = "player" type = "player"
status: Literal["join", "leave"] status: Literal["join", "leave"]
character: str character: str
client: str client: str

View File

@ -30,7 +30,7 @@ if not has_dungeon_master():
def action_explore(direction: str) -> str: def action_explore(direction: str) -> str:
""" """
Explore the room in a new direction. Explore the room in a new direction. You can only explore directions that do not already have a portal.
Args: Args:
direction: The direction to explore: north, south, east, or west. direction: The direction to explore: north, south, east, or west.
@ -44,7 +44,7 @@ def action_explore(direction: str) -> str:
if direction in current_room.portals: if direction in current_room.portals:
dest_room = current_room.portals[direction] dest_room = current_room.portals[direction]
return f"You cannot explore {direction} from here, that direction leads to {dest_room}." return f"You cannot explore {direction} from here, that direction already leads to {dest_room}. Please use the move action to go there."
existing_rooms = [room.name for room in current_world.rooms] existing_rooms = [room.name for room in current_world.rooms]
new_room = generate_room( new_room = generate_room(

View File

@ -7,18 +7,11 @@ from typing import Literal
from uuid import uuid4 from uuid import uuid4
import websockets import websockets
from pydantic import RootModel
from adventure.context import get_actor_agent_for_name, set_actor_agent from adventure.context import broadcast, get_actor_agent_for_name, set_actor_agent
from adventure.models.entity import Actor, Room, World from adventure.models.entity import Actor, Item, Room, World
from adventure.models.event import ( from adventure.models.event import GameEvent, PlayerEvent, PromptEvent
ActionEvent,
GameEvent,
GenerateEvent,
PromptEvent,
ReplyEvent,
ResultEvent,
StatusEvent,
)
from adventure.player import ( from adventure.player import (
RemotePlayer, RemotePlayer,
get_player, get_player,
@ -46,7 +39,7 @@ async def handler(websocket):
dumps( dumps(
{ {
"type": "prompt", "type": "prompt",
"id": id, "client": id,
"character": character, "character": character,
"prompt": prompt, "prompt": prompt,
"actions": [], "actions": [],
@ -153,10 +146,7 @@ static_thread = None
def server_json(obj): def server_json(obj):
if isinstance(obj, Actor): if isinstance(obj, (Actor, Item, Room)):
return obj.name
if isinstance(obj, Room):
return obj.name return obj.name
return world_json(obj) return world_json(obj)
@ -191,68 +181,26 @@ def server_system(world: World, step: int):
global last_snapshot global last_snapshot
json_state = { json_state = {
**snapshot_world(world, step), **snapshot_world(world, step),
"type": "world", "type": "snapshot",
} }
last_snapshot = send_and_append(json_state) last_snapshot = send_and_append(json_state)
def server_result(room: Room, actor: Actor, action: str):
json_action = {
"actor": actor,
"result": action,
"room": room,
"type": "result",
}
send_and_append(json_action)
def server_action(room: Room, actor: Actor, message: str):
json_input = {
"actor": actor,
"input": message,
"room": room,
"type": "action",
}
send_and_append(json_input)
def server_generate(event: GenerateEvent):
json_broadcast = {
"name": event.name,
"type": "generate",
}
send_and_append(json_broadcast)
def server_event(event: GameEvent): def server_event(event: GameEvent):
if isinstance(event, GenerateEvent): json_event = RootModel[event.__class__](event).model_dump()
return server_generate(event) json_event["type"] = event.type
elif isinstance(event, ActionEvent): send_and_append(json_event)
return server_action(event.room, event.actor, event.action)
elif isinstance(event, ReplyEvent):
return server_action(event.room, event.actor, event.text)
elif isinstance(event, ResultEvent):
return server_result(event.room, event.actor, event.result)
elif isinstance(event, StatusEvent):
pass
else:
logger.warning("Unknown event type: %s", event)
def player_event(character: str, id: str, event: Literal["join", "leave"]): def player_event(character: str, client: str, status: Literal["join", "leave"]):
json_broadcast = { event = PlayerEvent(status=status, character=character, client=client)
"type": "player", broadcast(event)
"character": character,
"id": id,
"event": event,
}
send_and_append(json_broadcast)
def player_list(): def player_list():
players = {value: key for key, value in list_players()}
json_broadcast = { json_broadcast = {
"type": "players", "type": "players",
"players": players, "players": list_players(),
} }
# TODO: broadcast this
send_and_append(json_broadcast) send_and_append(json_broadcast)

View File

@ -29,6 +29,7 @@ from adventure.models.entity import Attributes, World
from adventure.models.event import ( from adventure.models.event import (
ActionEvent, ActionEvent,
EventCallback, EventCallback,
GameEvent,
ReplyEvent, ReplyEvent,
ResultEvent, ResultEvent,
StatusEvent, StatusEvent,
@ -70,9 +71,13 @@ def simulate_world(
set_current_world(world) set_current_world(world)
# set up a broadcast callback # set up a broadcast callback
def broadcast_callback(message): def broadcast_callback(message: str | GameEvent):
logger.info(message) logger.info(message)
if isinstance(message, str):
event = StatusEvent(text=message) event = StatusEvent(text=message)
else:
event = message
for callback in callbacks: for callback in callbacks:
callback(event) callback(event)

View File

@ -119,7 +119,7 @@ export function App(props: AppProps) {
if (data.type === 'prompt') { if (data.type === 'prompt') {
// prompts are broadcast to all players // prompts are broadcast to all players
if (data.id === clientId) { if (data.client === clientId) {
// only notify the active player // only notify the active player
setActiveTurn(true); setActiveTurn(true);
} else { } else {
@ -137,7 +137,7 @@ export function App(props: AppProps) {
setHistory((prev) => prev.concat(data)); setHistory((prev) => prev.concat(data));
// if we get a world event, update the last world state // if we get a world event, update the last world state
if (data.type === 'world') { if (data.type === 'snapshot') {
setWorld(data.world); setWorld(data.world);
} }

View File

@ -10,17 +10,17 @@ export interface EventItemProps {
focusRef?: MutableRefObject<any>; focusRef?: MutableRefObject<any>;
} }
export function ActionItem(props: EventItemProps) { export function ActionEventItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { actor, room, type } = event; const { actor, room, type } = event;
const content = formatters[type](event); const content = formatters[type](event);
return <ListItem alignItems="flex-start" ref={props.focusRef}> return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" /> <Avatar alt={actor.name} src="/static/images/avatar/1.jpg" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={room} primary={room.name}
secondary={ secondary={
<React.Fragment> <React.Fragment>
<Typography <Typography
@ -29,7 +29,7 @@ export function ActionItem(props: EventItemProps) {
variant="body2" variant="body2"
color="text.primary" color="text.primary"
> >
{actor} {actor.name}
</Typography> </Typography>
{content} {content}
</React.Fragment> </React.Fragment>
@ -38,7 +38,7 @@ export function ActionItem(props: EventItemProps) {
</ListItem>; </ListItem>;
} }
export function WorldItem(props: EventItemProps) { export function SnapshotEventItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { step, world } = event; const { step, world } = event;
const { theme } = world; const { theme } = world;
@ -63,9 +63,9 @@ export function WorldItem(props: EventItemProps) {
</ListItem>; </ListItem>;
} }
export function MessageItem(props: EventItemProps) { export function ReplyEventItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { message } = event; const { text } = event;
return <ListItem alignItems="flex-start" ref={props.focusRef}> return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar> <ListItemAvatar>
@ -80,26 +80,26 @@ export function MessageItem(props: EventItemProps) {
variant="body2" variant="body2"
color="text.primary" color="text.primary"
> >
{message} {text}
</Typography> </Typography>
} }
/> />
</ListItem>; </ListItem>;
} }
export function PlayerItem(props: EventItemProps) { export function PlayerEventItem(props: EventItemProps) {
const { event } = props; const { event } = props;
const { character, event: innerEvent, id } = event; const { character, status, client } = event;
let primary = ''; let primary = '';
let secondary = ''; let secondary = '';
if (innerEvent === 'join') { if (status === 'join') {
primary = 'New Player'; primary = 'New Player';
secondary = `${id} is now playing as ${character}`; secondary = `${client} is now playing as ${character}`;
} }
if (innerEvent === 'leave') { if (status === 'leave') {
primary = 'Player Left'; primary = 'Player Left';
secondary = `${id} has left the game. ${character} is now controlled by an LLM`; secondary = `${client} has left the game. ${character} is now controlled by an LLM`;
} }
return <ListItem alignItems="flex-start" ref={props.focusRef}> return <ListItem alignItems="flex-start" ref={props.focusRef}>
@ -129,13 +129,13 @@ export function EventItem(props: EventItemProps) {
switch (type) { switch (type) {
case 'action': case 'action':
case 'result': case 'result':
return <ActionItem event={event} focusRef={props.focusRef} />; return <ActionEventItem event={event} focusRef={props.focusRef} />;
case 'event': case 'reply':
return <MessageItem event={event} focusRef={props.focusRef} />; return <ReplyEventItem event={event} focusRef={props.focusRef} />;
case 'player': case 'player':
return <PlayerItem event={event} focusRef={props.focusRef} />; return <PlayerEventItem event={event} focusRef={props.focusRef} />;
case 'world': case 'snapshot':
return <WorldItem event={event} focusRef={props.focusRef} />; return <SnapshotEventItem event={event} focusRef={props.focusRef} />;
default: default:
return <ListItem ref={props.focusRef}> return <ListItem ref={props.focusRef}>
<ListItemText primary={`Unknown event type: ${type}`} /> <ListItemText primary={`Unknown event type: ${type}`} />

View File

@ -6,19 +6,15 @@ export function formatActionName(name: string) {
} }
export function formatAction(data: any) { export function formatAction(data: any) {
const actionName = formatActionName(data.function); const actionName = formatActionName(data.action);
const actionParameters = data.parameters; const actionParameters = data.parameters;
return `Action: ${actionName} - ${Object.entries(actionParameters).map(([key, value]) => `${key}: ${value}`).join(', ')}`; return `Action: ${actionName} - ${Object.entries(actionParameters).map(([key, value]) => `${key}: ${value}`).join(', ')}`;
} }
export function formatInput(data: any) { export function formatInput(data: any) {
try { const action = formatAction(data);
const action = formatAction(JSON.parse(data.input));
return `Starting turn: ${action}`; return `Starting turn: ${action}`;
} catch (err) {
return `Error parsing input: ${err}`;
}
} }
export function formatResult(data: any) { export function formatResult(data: any) {