6. Simulate a Conversational Agent's Turn-Taking State Machine
Problem
A conversational agent's runtime moves through a small set of states while handling a turn: it starts IDLE, begins THINKING once a user message arrives, may need to make a TOOL_CALL and return to THINKING with the result, and eventually starts RESPONDING before returning to IDLE.
Only certain events are valid in each state, and the runtime needs to replay a sequence of events and report where the agent ends up, or that the sequence was invalid for the current state at some point.
Source: src/6_simulate_agent_state_machine.py
def run_state_machine(events: list[str]) -> str:
...
Examples:
>>> run_state_machine(['user_message', 'tool_needed', 'tool_result', 'ready_to_respond', 'response_sent'])
'IDLE'
>>> run_state_machine(['tool_needed'])
'INVALID'
Step-by-Step Approach
- Encode the state machine as data, not as a chain of if/elif statements: a dict mapping (current_state, event) -> next_state covers every valid transition compactly.
- Start a running state variable at 'IDLE'.
- For each event in order, look up (state, event) in the transition table.
- If the lookup misses (there's no entry for that state/event pair), the sequence is invalid — return 'INVALID' immediately, without processing any remaining events.
- If the lookup hits, update state to the returned next_state and continue to the next event.
- After all events are processed without hitting an invalid transition, return the final state.
The key insight is that a transition table turns FSM simulation into pure data lookup — the loop logic never needs to change no matter how many states or events the machine has, which is also why table-driven FSMs are easy to extend and to unit-test exhaustively.
Reference solution
# transition table doubles as the spec: (state, event) -> next_state
_TRANSITIONS = {
("IDLE", "user_message"): "THINKING",
("THINKING", "tool_needed"): "TOOL_CALL",
("THINKING", "ready_to_respond"): "RESPONDING",
("TOOL_CALL", "tool_result"): "THINKING",
("RESPONDING", "response_sent"): "IDLE",
}
def run_state_machine(events: list[str]) -> str:
state = "IDLE"
for event in events:
next_state = _TRANSITIONS.get((state, event))
if next_state is None:
# no valid transition for this (state, event) pair
return "INVALID"
state = next_state
return state
Key Functions & Tricks
dict keyed on (state, event) tuples— compact table encodes the entire transition function as data_TRANSITIONS.get((state, event))— O(1) lookup that returns None for any undefined transitionearly return on None— invalid transitions short-circuit the remaining events instead of silently continuing
How to Recognize This Pattern
The signal is "simulate an entity moving through a fixed set of states in response to a sequence of events/inputs," with a small, enumerable set of legal transitions. Encoding the transition function as a dict keyed on (state, input) is almost always cleaner and less error-prone than nested conditionals, and it also makes the set of legal transitions trivially testable on its own. Common variations add transition side effects (e.g. an action or callback fired on each transition), guard conditions that make a transition valid only under extra state, or ask you to report *how far* through the event sequence processing got before failing rather than just a boolean. A common pitfall is forgetting that an empty event list is a valid input that should return the start state unchanged, not be treated as an error.