Writing an agent¶
An agent is any object that satisfies the Agent protocol: a uid attribute and an async choose_action method. This tutorial writes one, then steps the engine by hand the way a search agent would.
The protocol¶
class Agent(Protocol):
uid: int
async def choose_action(
self, state: GameState, valid_actions: tuple[Action, ...]
) -> Action: ...
GameRunner calls choose_action whenever the player has something to do. It passes two things:
stateis already scrubbed withscrub_state, so it only holds what this player is allowed to know. Hidden values are theHIDDENsentinel.valid_actionsis a tuple of the legalActions, each with a human-readabledescription.
Return one of the offered actions. You don't need to inherit from anything.
A trusting agent¶
This agent nominates players it knows are on its team and votes for governments with a chancellor it trusts. Fascists know their teammates, so the agent plays well for them; liberals only know themselves and anyone they investigated. Everything else is random.
import random
from dataclasses import replace
from tyrant import HIDDEN, Vote
from tyrant.models import NominateAction, SpeakAction, VoteAction
class TrustingAgent:
def __init__(self, uid: int):
self.uid = uid
async def choose_action(self, state, valid_actions):
me = next(p for p in state.players if p.uid == self.uid)
known = {p.uid: p.party for p in state.players if p.party is not HIDDEN}
for action in valid_actions:
match action:
case NominateAction(target_uid=uid) if known.get(uid) == me.party:
return action
case VoteAction(vote=vote) if state.chancellor in known:
trusted = known[state.chancellor] == me.party
if (vote is Vote.JA) == trusted:
return action
action = random.choice(valid_actions)
if isinstance(action, SpeakAction):
return replace(action, message=f"Player {self.uid} has nothing to hide.")
return action
Notes on the code:
- Actions are frozen dataclasses, so
matchwith class patterns is the easiest way to read them. The models reference lists every action type. - When discussion is on, a
SpeakActionis offered with an emptymessage. Fill it in withdataclasses.replace. The runner ignores changes tomessageanddescriptionwhen it checks that an action was offered. - If
choose_actionraises, times out, or returns an action that wasn't offered, the runner asks again and eventually plays a random action for the agent. See Agent resilience.
Mix it with random players and run a game with discussion:
import asyncio
from tyrant import GameRunner, RandomAgent
async def main():
agents = [TrustingAgent(0), TrustingAgent(1)]
agents += [RandomAgent(uid) for uid in range(2, 7)]
runner = GameRunner(agents, seed=3, discussion_enabled=True)
final_state = await runner.run()
print(final_state.winner, len(runner.faults), "faults")
asyncio.run(main())
Stepping the engine yourself¶
GameRunner is a thin loop over three pure functions. A search agent such as MCTS can call them directly:
create_gamereturns the startingGameState.get_legal_actionslists what a player may do now, or returns an empty tuple if it's not their turn.apply_actionreturns a new state and leaves the old one untouched.
This loop plays a whole game without any agents:
import random
from tyrant import GamePhase, apply_action, create_game, get_legal_actions
rng = random.Random(0)
state = create_game(uids=(0, 1, 2, 3, 4), seed=1)
while state.phase is not GamePhase.GAME_OVER:
for player in state.players:
actions = get_legal_actions(state, player.uid)
if actions:
state = apply_action(state, rng.choice(actions), player.uid)
break
print(state.winner)
Because apply_action never mutates its input, expanding a search node is just a comprehension:
president = state.players[state.president_index].uid
children = [
apply_action(state, action, president)
for action in get_legal_actions(state, president)
]
The states from create_game and apply_action hold every secret. Before an agent reasons about one, scrub it for that agent's seat, as GameRunner does:
from tyrant import scrub_state
view = scrub_state(state, 0)
print([(p.uid, p.role) for p in view.players])
[(0, <Role.LIBERAL: 'liberal'>), (2, HIDDEN), (1, HIDDEN), (4, HIDDEN), (3, HIDDEN)]
Don't simulate forward from a scrubbed state. The deck, the shuffle state and other players' roles are HIDDEN, so apply_action gives wrong results or fails once it needs them. A search agent has to fill in the hidden values with guesses first. See Private knowledge for what each player keeps.
Debugging with render_text¶
render_text prints a player's view as Markdown. It scrubs the state for you, so it's a quick way to check what your agent could see:
from tyrant import render_text
print(render_text(state, 0))
Next, run a game with LLM agents.