Felix Lebel
fix again
76e7160
Raw
History Blame Contribute Delete
38.3 kB
"""
Student Agent for Text Adventure Games
This is your submission file. Implement the StudentAgent class to play
text adventure games using the MCP server you also implement.
Your agent should:
1. Connect to the MCP server via the provided client
2. Use the ReAct pattern (Thought -> Action -> Observation)
3. Call MCP tools to interact with the game
4. Maximize the game score within the step limit
Required method:
async def run(self, client, game, max_steps, seed, verbose) -> RunResult
The 'client' is a FastMCP Client already connected to your MCP server.
Use it to call tools like: await client.call_tool("play_action", {"action": "look"})
Tips:
- Start by looking around and understanding your environment
- Keep track of visited locations to avoid loops
- Pick up useful items (lamp, sword, etc.)
- The seed parameter should be used to set your LLM's seed for reproducibility
"""
import json
import os
import re
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# =============================================================================
# LLM Configuration - DO NOT MODIFY
# =============================================================================
LLM_MODEL = "Qwen/Qwen2.5-72B-Instruct"
USE_LOCAL_MODEL = os.getenv("USE_LOCAL_MODEL", "false").lower() == "true"
_local_pipeline = None
if USE_LOCAL_MODEL:
try:
from transformers import pipeline
LOCAL_MODEL = os.getenv("LOCAL_MODEL", "Qwen/Qwen2.5-3B-Instruct")
_local_pipeline = pipeline("text-generation", model=LOCAL_MODEL, device_map="auto")
except Exception:
USE_LOCAL_MODEL = False
if not USE_LOCAL_MODEL:
from huggingface_hub import InferenceClient
_hf_token = os.getenv("HF_TOKEN")
if not _hf_token:
raise ValueError("HF_TOKEN not found. Set it in your .env file.")
LLM_CLIENT = InferenceClient(token=_hf_token)
def call_llm(prompt: str, system_prompt: str, seed: int, max_tokens: int = 300) -> str:
"""
Call the LLM with the given prompt. Use this function in your agent.
Args:
prompt: The user prompt (current game state, history, etc.)
system_prompt: The system prompt (instructions for the agent)
seed: Random seed for reproducibility
max_tokens: Maximum tokens in response (default: 300)
Returns:
The LLM's response text
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
if USE_LOCAL_MODEL and _local_pipeline is not None:
outputs = _local_pipeline(
messages,
max_new_tokens=max_tokens,
temperature=0.0001,
do_sample=True,
)
return outputs[0]["generated_text"][-1]["content"]
response = LLM_CLIENT.chat.completions.create(
model=LLM_MODEL,
messages=messages,
temperature=0.0,
max_tokens=max_tokens,
seed=seed,
)
return response.choices[0].message.content
@dataclass
class RunResult:
"""Result of running the agent. Do not modify this class."""
final_score: int
max_score: int
moves: int
locations_visited: set[str]
game_completed: bool
error: Optional[str] = None
history: list[tuple[str, str, str]] = field(default_factory=list)
# =============================================================================
# System Prompt
# =============================================================================
SYSTEM_PROMPT = """You are playing a classic text adventure game. Your goal is to EXPLORE widely, COLLECT treasures and MAXIMIZE your score.
AVAILABLE TOOLS (use via MCP):
- play_action: Execute a game command (north, take lamp, open mailbox, etc.)
- location_log: See what actions were tried at the current location, their outcomes and the promising actions to try.
- memory: Get a current game state summary including current location (number of visits, actions tried, promising actions), recent actions and current observation
- get_map: Get a map of explored locations, connections and exits. It also helps you remember what you've tried at the current location and their outcomes, so you can avoid repeating failed actions and focus on promising ones..
- inventory: Have a look at what you're currently carrying.
VALID GAME COMMANDS for play_action:
- Movement: north, south, east, west, northeast, northwest, southeast, southwest, up, down, enter, exit
- Objects: take <item>, drop <item>, open <thing>, close <thing>, examine <thing>
- Light: turn on lamp, turn off lamp
- Combat: attack <enemy> with <weapon>
- Other: inventory, look, read <thing>, wait
- Other: look, examine, listen, speak, look, take, drop, empty, fill, inventory, climb, swim, open, close, set, turn, push, pull, push [direction], throw at, eat, drink, wear, take off, burn, dig, kick, destroy, read, ask for, give, feed, show, ask about, tell about, talk to, kiss, attack, wake, answer, wave, rub , squeeze, jump, jump over, wait, sleep
sing, yell, think, pray
FORBIDDEN (will NOT work): check, inspect, search, grab, use, help
RESPOND IN THIS EXACT FORMAT (no markdown):
THOUGHT: <your reasoning about what to do next>
TOOL: <tool_name>
ARGS: <JSON arguments, e.g., {"action": "look"}>
EXPLORATION STRATEGY (follow this priority):
1. EXPLORE a lot! Try new locations and exits frequently (north, south, east, west, northeast, northwest, southeast, southwest, up, down, enter, exit)
2. ALWAYS EXAMINE everything that could be interesting, especially details in objects, rooms... EXAMINE where you could find some loot or useful items, or clues for puzzles. INTERACT with characters and objects to discover new possibilities.
3. ALWAYS take items that seem useful (lamp, sword, key, etc.)
4. Open containers (mailbox, cases, doors, windows)
5. Try ALL exits from a location before moving on
6. Use get_map and location_log frequently to plan which unexplored exits to try, and what actions to take. It also helps you remember what you've tried at the current location and their outcomes, so you can avoid repeating failed actions and focus on promising ones.
7. Use memory to check if you're repeating yourself
8. If you've been in the same location for 3+ turns, MOVE to a new location
HERE IS THE STRUCTURE OF THE GAME OUTPUT you receive after each action and tool call:
<BEGIN GAME OUTPUT>
- CURRENT LOCATION: <location name>
- STEPS AT THIS LOCATION: <number of steps taken at this location>
- RECENT ACTIONS:
[<location name>] > action -> outcome
[<other location name>] > other action -> other outcome
...
[<other location name>] > other action -> other outcome
- CURRENT SITUATION:
<text describing the current location, visible objects, characters, exits, inventory, map, etc.>
or <map description>
- ACTIONS ALREADY TRIED AT THIS LOCATION:
> action -> outcome
> other action -> other outcome
- ACTIONS SUGGESTED: action1, action2, action3
<END GAME OUTPUT>
"CURRENT SITUATION" is the most important part of the output, it is the direct consequence of your last action and the most up-to-date description of the world. Focus on it to find new interactions, objects, exits, and details to examine.
"RECENT ACTIONS" is a summary of what you've done recently and their outcomes. Use it to avoid repeating failed actions and to focus on promising ones.
DON'T SUGGEST ACTIONS YOU'VE ALREADY TRIED AT THIS LOCATION. If there are too many ACTIONS ALREADY TRIED AT THIS LOCATION, move to another place (use look to see the exits).
IMPORTANT:
- DO NOT repeat the same action multiple times in a row
- If an action doesn't work, try something DIFFERENT or EXAMINE more (precisely) to find new possibilities
Examples:
THOUGHT: I need to remember what I've tried here before. Let me check the location log.
TOOL: location_log
ARGS: {}
THOUGHT: I see an interesting object. Let me examine it.
TOOL: play_action
ARGS: {"action": "examine mailbox"}
THOUGHT: I should check the map to find unexplored exits and to remember what I've tried here before.
TOOL: get_map
ARGS: {}
THOUGHT: Look around to find more details about the room and possible interactions.
TOOL: play_action
ARGS: {"action": "look"}
"""
# =============================================================================
# Prompt for extracting promising actions from observations
# =============================================================================
EXTRACT_ACTIONS_PROMPT = """You are analyzing text adventure game output. Extract promising actions the player should try.
Here is the structure of the GAME OUTPUT you receive:
<BEGIN GAME OUTPUT>
- CURRENT LOCATION: <location name>
- STEPS AT THIS LOCATION: <number of steps taken at this location>
- RECENT ACTIONS:
[<location name>] > action -> outcome
[<other location name>] > other action -> other outcome
...
[<other location name>] > other action -> other outcome
- CURRENT SITUATION:
<text describing the current location, visible objects, characters, exits, inventory, map, etc.>
or <map description>
- ACTIONS ALREADY TRIED AT THIS LOCATION:
> action -> outcome
> other action -> other outcome
- ACTIONS SUGGESTED: action1, action2, action3
<END GAME OUTPUT>
Given the GAME OUTPUT, output a JSON list of action strings. Focus on:
- Objects mentioned in CURRENT SITUATION that can be TAKEN, examined, or opened
- Objects or places to examine mentioned in CURRENT SITUATION that could reveal new information or items
- Directions/exits mentioned in CURRENT SITUATION
- Interactive elements in CURRENT SITUATION (doors, containers, levers, buttons). Suggest interacting with them to discover new possibilities.
- Items that might be useful in CURRENT SITUATION
- Exploration if there is no interesting object to interact with mentioned in CURRENT SITUATION
Follow these additional guidelines:
- "CURRENT SITUATION" is the most important part of the output, it is the direct consequence of your last action and the most up-to-date description of the world. Focus on it to find new interactions, objects, exits, and details to examine.
- "RECENT ACTIONS" is a summary of what you've done recently and their outcomes. Use it to avoid repeating failed actions and to focus on promising ones.
- DON'T SUGGEST ACTIONS YOU'VE ALREADY TRIED AT THIS LOCATION. If there are too many ACTIONS ALREADY TRIED AT THIS LOCATION, move to another place (use look to see the exits).
- ACTIONS SUGGESTED are additionally useful, but make sure to focus on the CURRENT SITUATION and RECENT ACTIONS to find promising actions that are relevant to the current context.
IMPORTANT: If there is a warning 'WARNING', 'EXPLORATION HINT' or 'URGENT' in the GAME OUTPUT, prioritize suggesting actions that address those warnings.
VALID COMMANDS for include:
- Movement: north, south, east, west, northeast, northwest, southeast, southwest, up, down, enter, exit
- Objects: take <item>, drop <item>, open <thing>, close <thing>, examine <thing>
- Light: turn on lamp, turn off lamp
- Combat: attack <enemy> with <weapon>
- Other: inventory, look, read <thing>, wait
- Other: look, examine, listen, speak, look, take, drop, empty, fill, inventory, climb, swim, open, close, set, turn, push, pull, push [direction], throw at, eat, drink, wear, take off, burn, dig, kick, destroy, read, ask for, give, feed, show, ask about, tell about, talk to, kiss, attack, wake, answer, wave, rub , squeeze, jump, jump over, wait, sleep
sing, yell, think, pray
KEEP VALID COMMANDS SIMPLE (e.g., "examine pcture" instead of "examine picture on east wall").
SUGGEST look when you need more information.
Output ONLY a JSON list, no explanation. Example: ["examine table", "take key", "open door", "north"]
If nothing stands out, output: []"""
EXTRACT_ACTIONS_PROMPT_EXIT = """You are analyzing text adventure game output. Extract promising actions or directions the player should try.
Here is the structure of the GAME OUTPUT you receive:
<BEGIN GAME OUTPUT>
- CURRENT LOCATION: <location name>
- STEPS AT THIS LOCATION: <number of steps taken at this location>
- RECENT ACTIONS:
[<location name>] > action -> outcome
[<other location name>] > other action -> other outcome
...
[<other location name>] > other action -> other outcome
- CURRENT SITUATION:
<text describing the current location, visible objects, characters, exits, inventory, map, etc.>
or <map description>
- ACTIONS ALREADY TRIED AT THIS LOCATION:
> action -> outcome
> other action -> other outcome
<END GAME OUTPUT>
GUIDELINES:
The player needs to move to a different location. TRY TO DISCOVER NEW PLACES AND EXITS TO EXPLORE (look at RECENT ACTIONS to avoid going in the same direction again).
If no exits or directions are mentioned in the CURRENT SITUATION, suggest: look, get_map.
Otherwise, suggests exits and directions mentioned in the CURRENT SITUATION among the valid commands: north, south, east, west, northeast, northwest, southeast, southwest.
Output ONLY a JSON list, no explanation. Example: ["north", "look", "southwest", "east"]
If nothing stands out, output: []"""
# =============================================================================
# Student Agent
# =============================================================================
MVMT_COMMANDS = {"look", "north", "south", "east", "west", "up", "down", "northeast", "northwest", "southeast", "southwest"}
class StudentAgent:
"""
ReAct agent with enhanced exploration and location-aware reasoning.
"""
def __init__(self):
"""Initialize your agent here."""
self.history_agent: list[dict] = [] # # location -> history of actions/directions and outcomes at that location
self.history_location: dict[str, list[dict]] = {} # location -> history of actions that are not directions and outcomes at that location
self.remaining_directions: dict[str, set[str]] = {} # location -> unexplored directions
self.recent_actions: list[str] = [] # track recent actions for loop detection
self.score: int = 0
self.previous_location: str = "" # track previous location to detect movement
self.current_location: str = "" # track current location
self.steps_at_current_location: int = 0 # track how many steps we've been at the current location to encourage exploration
self.visited_locations: dict[str, int] = {} # location -> visit count
self.promising_actions: list[str] = [] # promising actions extracted from observation at new locations
self.is_new_location: bool = False # flag to indicate if the last observation was a new location
async def run(
self,
client,
game: str,
max_steps: int,
seed: int,
verbose: bool = False,
) -> RunResult:
"""
Run the agent for a game session.
"""
locations_visited = set()
history = []
moves = 0
# Get list of available tools
tools = await client.list_tools()
tool_names = [t.name for t in tools]
# Get initial observation
result = await client.call_tool("play_action", {"action": "look"})
observation, location, is_new_location = self._extract_result(result)
# Track location (for counting unique locations visited, not necessarily the same as in-game location name)
dummy_location = observation.split("\n")[0] if observation else "Unknown"
locations_visited.add(dummy_location)
# Track location (location = in-game location name = the name of the room or area we're currently in, extracted from the observation)
self.current_location = location
self.previous_location = location
self.visited_locations[location] = 1
self.remaining_directions[location] = set(["north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest"])
if verbose:
print(f"\n{observation}")
# Extract promising actions from initial observation
self.promising_actions = self._extract_promising_actions(observation, seed, EXTRACT_ACTIONS_PROMPT)
if self.promising_actions and verbose:
print(f"[PROMISING] {self.promising_actions}")
# Main ReAct loop
for step in range(1, max_steps + 1):
# Build prompt with context
prompt = self._build_prompt(observation, seed + step)
# Call LLM for reasoning
response = call_llm(prompt, SYSTEM_PROMPT, seed + step)
# Parse the response
thought, tool_name, tool_args = self._parse_response(response)
if verbose:
print(f"\n--- Step {step} ---")
print(f"[THOUGHT] {thought}")
print(f"[TOOL] {tool_name}({tool_args})")
# Validate and fix common issues
tool_name, tool_args = self._validate_tool_call(tool_name, tool_args, tool_names)
action = tool_args.get("action", "")
# Loop detection for play_action
if tool_name == "play_action":
action = tool_args.get("action", "look")
self.recent_actions.append(action)
if len(self.recent_actions) > 7:
self.recent_actions = self.recent_actions[-7:]
# Detect loops - if same action 3 times, force exploration
if len(self.recent_actions) >= 3 and len(set(self.recent_actions[-3:])) == 1:
if verbose:
print(f"[WARNING] Loop detected - forcing exploration")
# Try to move somewhere new
tool_name, tool_args = self._break_loop(tool_names)
self.recent_actions.append(tool_args.get("action", "look"))
# If stuck at same location too long, add exploration pressure
if self.steps_at_current_location >= 5 and tool_name == "play_action":
action = tool_args.get("action", "")
if action not in MVMT_COMMANDS:
if verbose:
print(f"[EXPLORATION BIAS] Been here {self.steps_at_current_location} steps, forcing movement")
moves += 1
# Execute the tool
observation = ""
new_location = self.current_location
is_new_location = False
try:
result = await client.call_tool(tool_name, tool_args)
observation, new_location, is_new_location = self._extract_result(result)
self.is_new_location = is_new_location
if verbose:
print(f"[RESULT] {observation[:200]}...")
except Exception as e:
observation = f"Error: {e}"
if verbose:
print(f"[ERROR] {e}")
# Detect location changes
self.previous_location = self.current_location
self.current_location = new_location
if is_new_location:
self.steps_at_current_location = 0
# Extract promising actions from new location
self.promising_actions = self._extract_promising_actions(observation, seed + step, EXTRACT_ACTIONS_PROMPT)
if self.promising_actions and verbose:
print(f"[PROMISING at new location] {self.promising_actions}")
else:
self.steps_at_current_location += 1
self.promising_actions = [] # Clear promising actions if we haven't moved
# Track number of visits to this location
if self._has_moved():
self.visited_locations[self.current_location] = self.visited_locations.get(self.current_location, 0) + 1
self.steps_at_current_location = 0
# Track location (for counting unique locations visited, not necessarily the same as in-game location name)
dummy_location = observation.split("\n")[0] if observation else "Unknown"
locations_visited.add(dummy_location)
# Update history of actions/directions and outcomes at that location
# Keep this general history not too long
self.history_agent.append({
"step": step,
"thought": thought,
"tool": tool_name,
"args": tool_args,
"result": observation[:200],
"location": self.current_location,
})
if len(self.history_agent) > 15:
self.history_agent = self.history_agent[-15:]
if self.current_location not in self.history_location:
self.history_location[self.current_location] = []
# Update remaining directions for this location if it's new
if self.current_location not in self.remaining_directions:
self.remaining_directions[self.current_location] = set(["north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest"])
# Update history of non-movement actions at this location (to help the LLM learn from what worked and what didn't at this location).
if action not in MVMT_COMMANDS:
self.history_location[self.current_location].append({
"step": step,
"thought": thought,
"tool": tool_name,
"args": tool_args,
"result": observation,
})
else:
# If it's a movement action, remove it from remaining directions for this location
if action in self.remaining_directions[self.current_location]:
self.remaining_directions[self.current_location].remove(action)
# Track score from observation
self._update_score(observation)
# Record in result history (for final output)
history.append((thought, f"{tool_name}({tool_args})", observation[:100]))
# Check for game over
if self._is_game_over(observation):
if verbose:
print("\n*** GAME OVER ***")
break
return RunResult(
final_score=self.score,
max_score=350,
moves=moves,
locations_visited=locations_visited,
game_completed=self._is_game_over(observation),
history=history,
)
def _has_moved(self) -> bool:
"""Check if the player has moved to a new location."""
return self.current_location != self.previous_location
def _parse_location_from_observation(self, observation: str) -> tuple[str, bool]:
"""Extract location name from observation text.
Return also if it's a new location based on tags in the observation."""
is_new_location = False
if not observation:
return "Unknown", False
first_line = observation.split("\n")[0].strip()
# If the first line begins with "[NEW LOCATION:", is_new_location = True
if first_line.startswith("[NEW LOCATION:"):
is_new_location = True
# Extract location from "[NEW/CURRENT LOCATION: location name]" if present
match = re.search(r'\[(?:NEW|CURRENT) LOCATION: (.+?)\]', first_line)
if match:
return match.group(1).strip(), is_new_location
else:
print(f"[ERROR] Could not parse location from observation. Defaulting to first line as location. Observation: \n{observation[:100]}...")
# Otherwise, return the first line as location
return first_line, is_new_location
def _parse_observation_wo_score(self, observation: str) -> str:
"""Remove score information from observation to avoid confusion."""
if not observation:
return ""
return observation.split("[Score:")[0].strip()
def _extract_promising_actions(self, observation: str, seed: int, prompt: str) -> list[str]:
"""
Use the LLM to extract promising actions from an observation.
Returns a list of action strings worth trying.
"""
try:
response = call_llm(
prompt=f"{observation}",
system_prompt=prompt,
seed=seed,
max_tokens=150,
)
# Try to parse JSON list from response
# Find the JSON array in the response
match = re.search(r'\[.*?\]', response, re.DOTALL)
if match:
actions = json.loads(match.group(0))
if isinstance(actions, list):
return [str(a) for a in actions if isinstance(a, str)]
except Exception:
pass
return []
def _break_loop(self, tool_names: list[str]) -> tuple[str, dict]:
"""Break out of a loop by choosing an unexplored action."""
# Try movement directions we haven't tried recently
directions = ["north", "south", "east", "west", "up", "down",
"northeast", "northwest", "southeast", "southwest"]
recent_set = set(self.recent_actions[-5:]) if self.recent_actions else set()
for d in directions:
if d not in recent_set:
return "play_action", {"action": d}
# If all directions tried, try examining or looking
if "get_map" in tool_names:
return "get_map", {}
return "play_action", {"action": "look"}
def _force_movement(self) -> tuple[str, dict]:
"""Force a movement action when stuck too long at a location."""
directions = ["north", "south", "east", "west", "up", "down",
"enter", "northeast", "northwest", "southeast", "southwest"]
recent_set = set(self.recent_actions[-5:]) if self.recent_actions else set()
for d in directions:
if d not in recent_set:
return "play_action", {"action": d}
# Fallback: just try north
return "play_action", {"action": "north"}
def _build_prompt(self, observation: str, seed: int = 0) -> str:
"""
Build the prompt for the LLM with rich context.
"""
parts = []
parts.append(f"- CURRENT LOCATION: {self.current_location}")
parts.append(f"- STEPS AT THIS LOCATION: {self.steps_at_current_location}")
# Recent history
if self.history_agent:
parts.append("\n- RECENT ACTIONS:")
for entry in self.history_agent[-5:]:
loc = entry.get("location", "?")
action = entry.get("args", {}).get("action", entry["tool"])
result = entry.get("result", "")
result = self._parse_observation_wo_score(result)
# replace newlines in result with spaces for better readability
result = result.replace("\n", " ")
result_short = result[:80] + "..." if len(result) > 80 else result
parts.append(f" [{loc}] > {action} -> {result_short}")
# Warn about repeated actions
if self.recent_actions and len(self.recent_actions) >= 4 and len(set(self.recent_actions[-3:])) == 1:
parts.append(f"\n[WARNING: You've been doing '{self.recent_actions[-1]}' repeatedly. TRY SOMETHING COMPLETELY DIFFERENT!]")
# Exploration pressure
if self.steps_at_current_location >= 4:
parts.append(f"\n[EXPLORATION HINT: You have been at '{self.current_location}' for {self.steps_at_current_location} steps. Consider moving to a NEW location soon! Use 'look' to find exits of the room, or 'get_map' to see the discovered map.]")
if self.steps_at_current_location >= 5:
parts.append(f"\n[URGENT: You MUST move to a different location NOW. Pick a direction and go.]")
parts.append(f"\n- CURRENT SITUATION:\n{observation}")
# Actions already tried at this location (to avoid repetition and encourage trying new things)
revisited = self.visited_locations.get(self.current_location, 0) > 1
location_history = self.history_location.get(self.current_location, [])
if revisited:
parts.append(f"\n- ACTIONS ALREADY TRIED AT THIS LOCATION ({self.current_location}):")
for entry in location_history[-20:]:
action = entry.get("args", {}).get("action", entry["tool"])
result = entry.get("result", "")
result = self._parse_observation_wo_score(result)
result = result.replace("\n", " ")
result_short = result[:100] + "..." if len(result) > 100 else result
parts.append(f" > {action} -> {result_short}")
# # Show remaining unexplored directions for current location
# if self.current_location in self.remaining_directions and (self.visited_locations.get(self.current_location, 0) >= 5 or self.steps_at_current_location >= 5):
# # remaining should be a list
# remaining = list(self.remaining_directions[self.current_location])
# if remaining:
# parts.append(f"\n- REMAINING UNEXPLORED DIRECTIONS AT THIS LOCATION: {', '.join(remaining)}")
# Actions suggested by the LLM
if self.promising_actions:
parts.append(f"\n- ACTIONS SUGGESTED AT NEW LOCATION: {', '.join(self.promising_actions)}")
else:
prompt = EXTRACT_ACTIONS_PROMPT
if self.steps_at_current_location >= 5:
prompt = EXTRACT_ACTIONS_PROMPT_EXIT
promising_actions = self._extract_promising_actions("\n".join(parts), seed=seed, prompt=prompt)
if len(location_history) >= 7 or self.visited_locations.get(self.current_location, 0) >= 7:
# If we've been here a lot, prioritize exit directions
directions = ['look', 'get_map', 'north', 'south', 'east', 'west', 'northeast', 'northwest', 'southeast', 'southwest', 'up', 'down', 'enter', 'exit']
# Take 4 random elements from directions to build promising_actions
promising_actions = np.random.choice(directions, size=min(4, len(directions)), replace=False).tolist()
if promising_actions:
parts.append(f"\n- ACTIONS SUGGESTED: {', '.join(promising_actions)}")
parts.append("\nWhat do you do next?")
parts_str = "\n".join(parts)
# print(f"\n----------------- [START DEBUG] PROMPT RICH IN CONTEXT PASSED TO THE AGENT ----------------- \n{parts_str}\n----------------- [END DEBUG] PROMPT RICH IN CONTEXT PASSED TO THE AGENT ----------------- ")
return "\n".join(parts)
def _parse_response(self, response: str) -> tuple[str, str, dict]:
"""
Parse LLM response to extract thought, tool name, and arguments.
"""
thought = "No reasoning provided"
tool_name = "play_action"
tool_args = {"action": "look"}
lines = response.strip().split("\n")
for line in lines:
line_clean = line.strip()
line_upper = line_clean.upper()
if line_upper.startswith("THOUGHT:"):
thought = line_clean.split(":", 1)[1].strip()
elif line_upper.startswith("TOOL:"):
raw_tool = line_clean.split(":", 1)[1].strip().lower()
raw_tool = raw_tool.replace("**", "").replace("*", "").replace("`", "")
tool_name = raw_tool.strip()
elif line_upper.startswith("ARGS:"):
raw_args = line_clean.split(":", 1)[1].strip()
raw_args = raw_args.replace("**", "").replace("*", "").replace("`", "")
try:
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
tool_args = parsed
except json.JSONDecodeError:
# Try to extract action from malformed JSON
match = re.search(r'"action"\s*:\s*"([^"]+)"', raw_args)
if match:
tool_args = {"action": match.group(1)}
else:
# Try bare string
clean = raw_args.strip().strip('"').strip("'")
if clean:
tool_args = {"action": clean}
return thought, tool_name, tool_args
def _validate_tool_call(self, tool_name: str, tool_args: dict, valid_tools: list[str]) -> tuple[str, dict]:
"""Validate and fix common tool call issues."""
# Fix tool name
if tool_name not in valid_tools:
if tool_name in ["action", "do", "command", "play"]:
tool_name = "play_action"
elif tool_name in ["map", "location", "locations"]:
tool_name = "get_map"
elif tool_name in ["mem", "state", "status", "history"]:
tool_name = "memory"
elif tool_name in ["inv", "items", "carrying"]:
tool_name = "inventory"
elif tool_name in ["valid", "valid_actions", "actions", "possible_actions"]:
tool_name = "get_valid_actions"
elif tool_name in ["log", "loc_log", "location_history"]:
tool_name = "location_log"
else:
tool_name = "play_action"
# Fix action verbs
if tool_name == "play_action":
action = tool_args.get("action", "look")
invalid_verb_map = {
"check": "examine",
"inspect": "examine",
"search": "look",
"grab": "take",
"pick": "take",
"pick up": "take",
"get": "take",
"collect": "take",
"use": "turn on",
"switch on": "turn on",
"go north": "north",
"go south": "south",
"go east": "east",
"go west": "west",
"go up": "up",
"go down": "down",
"move north": "north",
"move south": "south",
"move east": "east",
"move west": "west",
}
action_lower = action.lower().strip()
if action_lower in invalid_verb_map:
action = invalid_verb_map[action_lower]
else:
# Check if action starts with an invalid verb
for invalid, valid in invalid_verb_map.items():
if action_lower.startswith(invalid + " "):
remainder = action_lower[len(invalid):].strip()
action = f"{valid} {remainder}"
break
tool_args["action"] = action
return tool_name, tool_args
def _extract_result(self, result) -> str:
"""Extract observation, location, and boolean indicating if it's a new location from MCP tool result."""
if hasattr(result, 'content') and result.content:
obs = result.content[0].text
elif isinstance(result, list) and result:
obs = result[0].text if hasattr(result[0], 'text') else str(result[0])
else:
obs = str(result)
location, is_new_location = self._parse_location_from_observation(obs)
# obs without the first line
obs_without_first_line = "\n".join(obs.split("\n")[1:]).strip() if "\n" in obs else obs
return obs_without_first_line, location, is_new_location
def _update_score(self, text: str) -> None:
"""Update score from game text."""
patterns = [
r'Score:\s*(\d+)',
r'score[:\s]+(\d+)',
r'\[Score:\s*(\d+)',
r'Total:\s*(\d+)',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
self.score = max(self.score, int(match.group(1)))
def _is_game_over(self, text: str) -> bool:
"""Check if the game is over."""
game_over_phrases = [
"game over",
"you have died",
"you are dead",
"*** you have died ***",
"*** you have won ***",
]
text_lower = text.lower()
return any(phrase in text_lower for phrase in game_over_phrases)
def _call_llm(self, prompt: str, system_prompt: str, seed: int) -> str:
"""Convenience wrapper for call_llm()."""
return call_llm(prompt, system_prompt, seed)
# =============================================================================
# For local testing
# =============================================================================
async def test_agent():
"""Test the agent locally."""
from fastmcp import Client
server_path = "mcp_server.py"
agent = StudentAgent()
async with Client(server_path) as client:
result = await agent.run(
client=client,
game="zork1",
max_steps=10,
seed=42,
verbose=True,
)
print(f"\nFinal Score: {result.final_score}")
print(f"Moves: {result.moves}")
print(f"Locations: {result.locations_visited}")
if __name__ == "__main__":
import asyncio
asyncio.run(test_agent())