If we think back to the primary difference between synthetic intelligence and artificial intelligence, AI agents are purpose built. Meaning, we engineer AI to address problems narrow in scope. Yet, not all problems are the same type. This is why we have 4 ways to build our AI agents. Let’s check those out right now!
Rule-Based Agents
These agents make decisions based on a set of predefined rules or conditions. They match inputs to specific rules and execute corresponding actions. Rule-based agents are widely used in expert systems and decision-making domains.
Here’s a simple code example demonstrating a rule-based agent in the context of a lumberjack problem. The agent follows a set of predefined rules to decide its actions based on the current state of the environment.
class RuleBasedLumberjack:
def __init__(self, environment):
self.environment = environment
self.position = (0, 0)
def make_decision(self):
if self.environment.has_tree(self.position):
self.chop_tree()
else:
self.explore()
def chop_tree(self):
print("Chopping down the tree!")
# Code for chopping down the tree goes here
def explore(self):
possible_moves = ['up', 'down', 'left', 'right']
valid_moves = []
for move in possible_moves:
new_x, new_y = self.calculate_new_position(move)
if self.environment.is_valid_position(new_x, new_y):
valid_moves.append(move)
if valid_moves:
chosen_move = random.choice(valid_moves)
self.move(chosen_move)
def move(self, direction):
x, y = self.position
if direction == 'up':
y += 1
elif direction == 'down':
y -= 1
elif direction == 'left':
x -= 1
elif direction == 'right':
x += 1
if self.environment.is_valid_position(x, y):
self.position = (x, y)
def calculate_new_position(self, direction):
x, y = self.position
if direction == 'up':
return x, y + 1
elif direction == 'down':
return x, y - 1
elif direction == 'left':
return x - 1, y
elif direction == 'right':
return x + 1, y
# Simulation
environment = Environment(grid_size=5)
lumberjack = RuleBasedLumberjack(environment)
lumberjack.make_decision()
In this example, the RuleBasedLumberjack class encapsulates the rule-based agent. The make_decision method implements the agent’s decision-making process based on the current state of the environment. If the agent is at a position with a tree, it chops down the tree using the chop_tree method. Otherwise, it explores by randomly selecting a valid move from the available directions using the explore method.
The move method updates the agent’s position based on the chosen direction, and the calculate_new_position method helps calculate the new position based on the current position and the direction.
Please note that the code example assumes you have already defined the Environment class as used in the previous examples, which contains the necessary methods has_tree, is_valid_position, and chop_tree.
There is nothing wrong with a rule-based agent. Yet, we have to upgrade the logic as soon as the environment becomes complex enough to warrant flexible decision-making.
Utility-Based Agents
Utility-based agents assess different actions based on a utility function or set of criteria. They evaluate the expected outcomes of different actions and select the one with the highest utility. Utility-based agents are often employed in decision-making problems with multiple objectives.
You see, hardcoded rules cannot account for the potential vectors within the space of uncertainty. Thus, we need an agent with flexibility, utility.
class UtilityBasedLumberjack:
def __init__(self, environment):
self.environment = environment
self.position = (0, 0)
def make_decision(self):
actions = ['chop', 'explore']
action_utilities = [self.calculate_chop_utility(), self.calculate_explore_utility()]
highest_utility_index = action_utilities.index(max(action_utilities))
chosen_action = actions[highest_utility_index]
if chosen_action == 'chop':
self.chop_tree()
else:
self.explore()
def chop_tree(self):
print("Chopping down the tree!")
# Code for chopping down the tree goes here
def explore(self):
possible_moves = ['up', 'down', 'left', 'right']
valid_moves = []
for move in possible_moves:
new_x, new_y = self.calculate_new_position(move)
if self.environment.is_valid_position(new_x, new_y):
valid_moves.append(move)
if valid_moves:
chosen_move = random.choice(valid_moves)
self.move(chosen_move)
def move(self, direction):
x, y = self.position
if direction == 'up':
y += 1
elif direction == 'down':
y -= 1
elif direction == 'left':
x -= 1
elif direction == 'right':
x += 1
if self.environment.is_valid_position(x, y):
self.position = (x, y)
def calculate_new_position(self, direction):
x, y = self.position
if direction == 'up':
return x, y + 1
elif direction == 'down':
return x, y - 1
elif direction == 'left':
return x - 1, y
elif direction == 'right':
return x + 1, y
def calculate_chop_utility(self):
if self.environment.has_tree(self.position):
return 1.0
else:
return 0.0
def calculate_explore_utility(self):
possible_moves = ['up', 'down', 'left', 'right']
valid_moves = 0
for move in possible_moves:
new_x, new_y = self.calculate_new_position(move)
if self.environment.is_valid_position(new_x, new_y):
valid_moves += 1
return valid_moves / len(possible_moves)
# Simulation
environment = Environment(grid_size=5)
lumberjack = UtilityBasedLumberjack(environment)
lumberjack.make_decision()
In this utility-based agent example, the UtilityBasedLumberjack class represents the agent. The make_decision method evaluates the utilities of different actions (chop and explore) based on a utility function. The utility function calculates the utility of chopping a tree (calculate_chop_utility) and the utility of exploring (calculate_explore_utility), respectively.
The action with the highest utility is selected, and if it is chopping, the agent performs the chop_tree action. Otherwise, it explores by moving to a valid neighboring position.
The utility function in this example is simplistic, with the utility of chopping being 1.0 if a tree is present at the current position and 0.0 otherwise. The utility of exploring is calculated based on the number of valid moves compared to the total possible moves.
When we want a more intentional search of a problem space, we turn to search agents.
Search Agents
Search agents navigate problem spaces to find solutions by systematically exploring different states and actions. These agents use search algorithms, such as depth-first search, breadth-first search, or heuristic-based approaches like A* search, to explore and evaluate possible paths. For what it is worth, search can be so much more than getting from A to B on a graph. Keep that mind while we look at a truncated example:
from collections import deque
class SearchAgentLumberjack:
def __init__(self, environment):
self.environment = environment
self.position = (0, 0)
def make_decision(self):
goal_position = self.find_tree()
if goal_position is not None:
path = self.bfs_search(goal_position)
self.follow_path(path)
def find_tree(self):
queue = deque()
visited = set()
queue.append(self.position)
visited.add(self.position)
while queue:
current_position = queue.popleft()
if self.environment.has_tree(current_position):
return current_position
neighbors = self.get_valid_neighbors(current_position)
for neighbor in neighbors:
if neighbor not in visited:
queue.append(neighbor)
visited.add(neighbor)
return None
def bfs_search(self, goal_position):
queue = deque()
visited = set()
path = {}
queue.append(self.position)
visited.add(self.position)
while queue:
current_position = queue.popleft()
if current_position == goal_position:
return self.reconstruct_path(path, current_position)
neighbors = self.get_valid_neighbors(current_position)
for neighbor in neighbors:
if neighbor not in visited:
queue.append(neighbor)
visited.add(neighbor)
path[neighbor] = current_position
return None
def reconstruct_path(self, path, goal_position):
current_position = goal_position
reversed_path = []
while current_position != self.position:
reversed_path.append(current_position)
current_position = path[current_position]
return list(reversed(reversed_path))
def follow_path(self, path):
for next_position in path:
self.move_to_position(next_position)
self.chop_tree()
def get_valid_neighbors(self, position):
x, y = position
possible_moves = [(x, y+1), (x, y-1), (x-1, y), (x+1, y)]
valid_moves = []
for move in possible_moves:
new_x, new_y = move
if self.environment.is_valid_position(new_x, new_y):
valid_moves.append(move)
return valid_moves
def move_to_position(self, position):
self.position = position
def chop_tree(self):
print("Chopping down the tree!")
# Code for chopping down the tree goes here
# Simulation
environment = Environment(grid_size=5)
lumberjack = SearchAgentLumberjack(environment)
lumberjack.make_decision()
Here, we use the make_decision method to initiate the search process. We find the tree using a breadth-first search (BFS) algorithm in the find_tree method. There are a variety of search algorithms with varying performance characteristics. Once the tree is found, it performs the bfs_search to find the shortest path to the tree. The follow_path method moves the agent along the path, chopping down the tree at each step.
Meanwhile, the reconstruct_path method reconstructs the path from the agent’s position to the tree position using the path dictionary. By reconstructing the path, the agent knows which positions to visit in order to reach the goal position efficiently. It allows the agent to traverse the environment along the shortest path or the path discovered by the search algorithm, in this case, the BFS algorithm.
We several support methods. The get_valid_neighbors method retrieves the valid neighboring positions from the current position, and the move_to_position method updates the agent’s current position. Finally, the chop_tree method performs the action of chopping down the tree.
To be fair, search agents can be powerful. They do have a weakness insofar as search agents only have one plan: searching.
Planning Agents
Planning agents generate sequences of actions to achieve specific goals. They analyze the current state, evaluate potential actions, and construct plans to achieve desired outcomes. Almost like internal simulation. For this reason, planning agents are commonly used in domains where long-term decision-making and goal-oriented behavior are crucial.
from collections import deque
class SearchAgentLumberjack:
def __init__(self, environment):
self.environment = environment
self.position = (0, 0)
def make_decision(self):
goal_position = self.find_tree()
if goal_position is not None:
path = self.bfs_search(goal_position)
self.follow_path(path)
def find_tree(self):
queue = deque()
visited = set()
queue.append(self.position)
visited.add(self.position)
while queue:
current_position = queue.popleft()
if self.environment.has_tree(current_position):
return current_position
neighbors = self.get_valid_neighbors(current_position)
for neighbor in neighbors:
if neighbor not in visited:
queue.append(neighbor)
visited.add(neighbor)
return None
def bfs_search(self, goal_position):
queue = deque()
visited = set()
path = {}
queue.append(self.position)
visited.add(self.position)
while queue:
current_position = queue.popleft()
if current_position == goal_position:
return self.reconstruct_path(path, current_position)
neighbors = self.get_valid_neighbors(current_position)
for neighbor in neighbors:
if neighbor not in visited:
queue.append(neighbor)
visited.add(neighbor)
path[neighbor] = current_position
return None
def reconstruct_path(self, path, goal_position):
current_position = goal_position
reversed_path = []
while current_position != self.position:
reversed_path.append(current_position)
current_position = path[current_position]
return list(reversed(reversed_path))
def follow_path(self, path):
for next_position in path:
self.move_to_position(next_position)
self.chop_tree()
def get_valid_neighbors(self, position):
x, y = position
possible_moves = [(x, y+1), (x, y-1), (x-1, y), (x+1, y)]
valid_moves = []
for move in possible_moves:
new_x, new_y = move
if self.environment.is_valid_position(new_x, new_y):
valid_moves.append(move)
return valid_moves
def move_to_position(self, position):
self.position = position
def chop_tree(self):
print("Chopping down the tree!")
# Code for chopping down the tree goes here
# Simulation
environment = Environment(grid_size=5)
lumberjack = SearchAgentLumberjack(environment)
lumberjack.make_decision()
In this example, the PlanningAgentLumberjack class represents the planning agent. The make_decision method initiates the planning process to find the tree and execute the plan. The agent first uses the find_tree method to determine the position of the tree. If a tree is found, it generates a plan using the plan_path method. The plan is a sequence of directions (e.g., ‘up’, ‘down’, ‘left’, ‘right’) that the agent should follow to reach the tree.
Then, the follow_path method iterates over the directions in the plan and executes each step by moving to the next position and then chopping down the tree.
Finally, the plan_path method calculates the plan based on the difference in coordinates between the agent’s position and the goal position (the tree). It determines the appropriate directions to move (left/right and up/down) and constructs the plan accordingly.
Now, in case anyone wonders there are other ways to build AI agents. A timely and topical one I intentionally left out are learning agents (i.e., machine learning agents). I limited our discussion to rule-based, utility, search, and planning because I say these represent the major agent archetypes. I want to explore these one at a time to a great depth before branching out.
4 Ways to Build an AI Agent was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.