stephecw's picture
Upload 6 files
e4885d4 verified
Raw
History Blame Contribute Delete
82.7 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
from collections import deque
from dotenv import load_dotenv
from huggingface_hub import InferenceClient
# Load environment variables
load_dotenv()
# =============================================================================
# LLM Configuration - DO NOT MODIFY
# =============================================================================
# Model to use (fixed for fair evaluation)
LLM_MODEL = "Qwen/Qwen2.5-72B-Instruct"
# Initialize the LLM client (uses HF_TOKEN from environment)
_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
Example:
response = call_llm(
prompt="You are in a forest. What do you do?",
system_prompt=SYSTEM_PROMPT,
seed=42,
)
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
response = LLM_CLIENT.chat.completions.create(
model=LLM_MODEL,
messages=messages,
temperature=0.0, # Deterministic for reproducibility
max_tokens=max_tokens,
seed=seed,
)
return response.choices[0].message.content
SYNTH_SYSTEM = """You are a memory manager for a Zork-like agent.
Your job: compress recent experience into DECISION-USEFUL memory: what happened AND why it matters.
Return ONLY valid JSON with keys:
facts, blocking, inventory_goals, open_threads, visited, last_update_move.
Rules:
- Each entry must be <= 12 words, start with a strong noun/verb, no filler.
- Prefer durable, actionable info: "X is locked -> need key" beats "saw a door".
- Do NOT restate raw room description unless it implies a new affordance.
- Track failed attempts as blocking only if they should NOT be retried.
- If something changed (inventory, location access, score), capture the consequence.
- Deduplicate aggressively across all lists.
- Keep each list length <= 6. Keep only highest-value items.
Interpretation guide:
facts: stable world knowledge learned (locations, items, mechanics).
blocking: obstacles + what is needed; include "avoid retry" if relevant.
inventory_goals: items/tools to seek next (lamp, key, etc.).
open_threads: unresolved leads worth returning to.
visited: important locations only (not every room).
last_update_move: copy STATE_MINIMAL.moves if present; else use prior value.
"""
def build_synth_prompt(mem_json, recent_history, state_obj):
state_obj = state_obj or {}
minimal_state = {
"location": state_obj.get("location"),
"moves": state_obj.get("moves"),
"score": state_obj.get("score"),
"inventory": state_obj.get("inventory"),
"visible_objects": state_obj.get("visible_objects"),
"last_observation_head": (state_obj.get("last_observation") or "")[:220],
}
return f"""
CURRENT_MEMORY_JSON:
{json.dumps(mem_json, ensure_ascii=False)}
RECENT_STEPS (action -> observation head):
{recent_history}
STATE_MINIMAL (json):
{json.dumps(minimal_state, ensure_ascii=False)}
Update the memory JSON. Only output JSON.
"""
PLANNER_SYSTEM = """You are an objective planner for a Zork-like agent.
You DO NOT act in the game. You only output a plan.
Return ONLY valid JSON with keys:
- objectives: list of {type, description, priority, status, evidence}
- suggested_actions: list of strings (game commands)
- notes: short string
Rules:
- Keep objectives <= 8, deduplicate, prefer durable goals.
- priority: 0 (highest) .. 5 (lowest)
- status: "open" | "done" | "blocked"
- evidence: <= 12 words
- suggested_actions: max 3 actions; MUST respect the agent command grammar.
- Use short nouns from observation (mailbox, leaflet, grating, egg, etc.)
- If valid_actions_list is provided, prefer actions from it exactly.
"""
def build_planner_prompt(
observation: str,
state_obj: dict,
synth_memory: dict,
objectives_text: str,
valid_actions_list: list[str],
tried_here: list[str],
) -> str:
return f"""
OBSERVATION:
{observation}
STATE (json):
{json.dumps(state_obj or {}, ensure_ascii=False)}
SYNTH_MEMORY (json):
{json.dumps(synth_memory or {}, ensure_ascii=False)}
CURRENT_OBJECTIVES (text):
{objectives_text}
VALID_ACTIONS_LIST:
{json.dumps(valid_actions_list or [], ensure_ascii=False)}
TRIED_ACTIONS_HERE:
{json.dumps(tried_here or [], ensure_ascii=False)}
Update objectives and propose up to 3 suggested_actions.
Output ONLY JSON.
"""
@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)
@dataclass
class Objective:
id: str
type: str # "explore", "get_item", "unlock", "solve", "return"
description: str
priority: int # 0 = top
status: str # "open" | "done" | "blocked"
evidence: list[str] # traces courtes
class ObjectiveManager:
def __init__(self):
self.objectives = []
self.counter = 0
def add(self, type_, desc, priority, evidence=""):
oid = f"obj{self.counter}"
self.counter += 1
self.objectives.append(Objective(oid, type_, desc, priority, "open", [evidence] if evidence else []))
def update_from_observation(self, obs: str, state_obj: dict):
low = (obs or "").lower()
vis = [str(x).lower() for x in (state_obj.get("visible_objects") or [])]
inv = " ".join([str(x).lower() for x in (state_obj.get("inventory") or [])])
# darkness
if "dark" in low and "lamp" not in inv and "lantern" not in inv:
if not self._has_open("get_item", "lamp"):
self.add("get_item", "Find a lamp/lantern", priority=0, evidence="It is dark")
# grating locked
if "grating" in low and "locked" in low:
if not self._has_open("get_item", "key"):
self.add("get_item", "Find a key", priority=1, evidence="Grating locked")
if not self._has_open("unlock", "grating"):
self.add("unlock", "Unlock the grating", priority=2, evidence="Grating locked")
# containers
for c in ["mailbox", "chest", "box"]:
if c in low and not self._has_open("open", c):
self.add("open", f"Open the {c}", priority=2, evidence=f"Seen {c}")
# visited
loc = (state_obj.get("location") or "").strip()
if loc and not self._has_open("visit", loc) and not self._has_done("visit", loc):
# pas forcément un "objectif", mais utile si tu veux "return"
pass
def propose_actions(self, state_obj: dict, valid_actions_list: list[str]) -> list[str]:
"""Return ordered action candidates."""
cands = []
# Sort objectives by priority then FIFO
open_objs = sorted([o for o in self.objectives if o.status == "open"], key=lambda o: (o.priority, o.id))
for o in open_objs[:3]:
if o.type == "get_item":
target = "lamp" if "lamp" in o.description.lower() else "key"
# propose "take lamp" if visible
vis = [str(x).lower() for x in (state_obj.get("visible_objects") or [])]
if target in vis:
cands.append(f"take {target}")
elif o.type == "open":
noun = o.description.split()[-1]
cands.append(f"open {noun}")
elif o.type == "unlock":
noun = o.description.split()[-1]
cands.append(f"unlock {noun}")
if valid_actions_list:
va = {re.sub(r"\s+", " ", a.strip().lower()) for a in valid_actions_list}
cands = [a for a in cands if re.sub(r"\s+", " ", a.strip().lower()) in va]
return cands
def _has_open(self, type_, keyword):
k = keyword.lower()
return any(o.status=="open" and o.type==type_ and k in o.description.lower() for o in self.objectives)
def _has_done(self, type_, keyword):
k = keyword.lower()
return any(o.status=="done" and o.type==type_ and k in o.description.lower() for o in self.objectives)
def render(self, k: int = 6) -> str:
open_objs = sorted([o for o in self.objectives if o.status == "open"], key=lambda o: (o.priority, o.id))
if not open_objs:
return "- (none)"
lines = []
for o in open_objs[:k]:
lines.append(f"- [{o.priority}] {o.type}: {o.description}")
return "\n".join(lines)
def mark_done_if_progress(self, before_state: dict, after_state: dict, action: str, obs: str):
a = (action or "").lower().strip()
low = (obs or "").lower()
# mark "open X" as done if it didn't say "closed/locked/can't"
if a.startswith("open "):
noun = a.split(" ", 1)[1]
if "can't" not in low and "locked" not in low and "does not" not in low:
for o in self.objectives:
if o.status == "open" and o.type == "open" and noun in o.description.lower():
o.status = "done"
def replace_from_llm(self, llm_objectives: list[dict]):
"""
Replace internal objectives with the list coming from the planner LLM.
Expected dict keys: type, description, priority, status, evidence
"""
self.objectives = []
self.counter = 0
if not llm_objectives:
return
for o in llm_objectives[:12]:
try:
type_ = str(o.get("type", "explore")).strip()
desc = str(o.get("description", "")).strip()
if not desc:
continue
pr = int(o.get("priority", 3))
st = str(o.get("status", "open")).strip()
ev = o.get("evidence", "")
if isinstance(ev, list):
evidence = [str(x)[:80] for x in ev[:2]]
else:
evidence = [str(ev)[:80]] if ev else []
oid = f"obj{self.counter}"
self.counter += 1
self.objectives.append(Objective(
id=oid,
type=type_,
description=desc,
priority=max(0, min(pr, 5)),
status=st if st in {"open", "done", "blocked"} else "open",
evidence=evidence
))
except Exception:
continue
# =============================================================================
# System Prompt - Customize this for your agent
# =============================================================================
SYSTEM_PROMPT = """You are playing a Zork-style text adventure.
GOAL:
Explore, solve puzzles, collect treasures, maximize score.
YOU CONTROL THE GAME ONLY USING TOOLS.
You never speak to the game directly.
============================================================
TOOLS (ONLY THESE EXIST)
- play_action
- memory
- get_map
- inventory
- valid_actions
- tried_actions
- hint
- state
- exits
- graph
- checkpoint_save
- checkpoint_restore
- action_probe
ARGS RULE:
- play_action -> {"action": "<command>"}
- checkpoint_save/checkpoint_restore -> {"name": "<string>"} (optional)
- action_probe -> {"action": "<command>"}
- all others -> {}
ABSOLUTE TOOL RULE:
TOOL must be exactly one of the 13 names above.
Everything else (look, north, open mailbox, etc.) is a GAME COMMAND used only with play_action.
============================================================
OUTPUT FORMAT (MANDATORY, EXACT)
THOUGHT: <1 short sentence>
TOOL: <tool_name>
ARGS: <json>
============================================================
COMMAND GRAMMAR
Normally, your play_action command CAN be one of:
A) Movement (single word only):
north / south / east / west / up / down / in / out / northeast / northwest / southeast / southwest
B) Simple verb + noun (2–3 words max):
look
inventory
take <noun>
drop <noun>
open <noun>
examine <noun>
read <noun>
climb <noun>
enter <noun>
pull <noun>
push <noun>
unlock <noun>
FORBIDDEN (never use):
- "look around"
- "go north", "go west", "go northwest"
- "look south"
- placeholders like "<item>", "<thing>", "<object>"
SPECIAL EXCEPTION:
If (and only if) you previously called valid_actions, you may use a multi-word command ONLY if it appears EXACTLY in that valid_actions list.
Example: if valid_actions includes "go around forest", then you may use "go around forest".
Otherwise, do not invent it.
NOUN RULE:
Use the shortest noun from the latest observation (egg, nest, tree, grating, mailbox, leaflet).
Do not invent adjectives (say "egg", not "jewel-encrusted egg").
============================================================
TURN POLICY (ANTI-SPAM)
- Default tool is play_action.
- Do NOT call valid_actions unless you hit an error or you are stuck.
- Do NOT call memory unless confused. Never call memory twice in a row.
- get_map is occasional (only if lost).
- Call tried_actions only when stuck/looping OR when you have valid_actions and you want to pick a NEW action not yet tried in this location.
- Call hint when you are stuck or after a parser failure / loop OR after a special description with new possibilities.
============================================================
TREASURE RULE (CRITICAL)
If you see something valuable/rare (jewels, gold, treasure, ornate, precious, encrusted, crystal, egg, crown, painting):
YOUR NEXT ACTION MUST BE: take <item>.
Secure it first. Open/examine later.
If you try open/examine and the game says locked / no tools / no expertise:
STOP trying. KEEP the item. Leave to search for tools/keys elsewhere.
Do not retry the same blocked action.
============================================================
LOCAL BEFORE LEAVING (CRITICAL)
When entering a location:
1) If full description is not shown, do: look
2) Interact locally ONCE with the most important object(s):
- take treasure
- open container
- examine new object
3) Only then move.
============================================================
VALID_ACTIONS EXPLORATION (IMPORTANT)
When you have a valid_actions list for the current location:
- Before leaving the location, try at most 1–2 NEW high-value actions from that list that you have not tried here yet.
- High-value actions (try in this order): take*, open*, unlock*, enter*, climb*, up, down, pull*, push*, read*, examine*.
- Avoid low-value management actions unless clearly needed: "put down ...", "put ... in ...", "close ...".
- Never repeat the same action in the same location if it produced no progress or an error message.
- Exception: you may retry an action ONLY if your inventory has changed since the last attempt.
- Use tried_actions to know which actions you already attempted in this location.
============================================================
EXPLICIT POSSIBILITY OVERRIDE (CRITICAL)
If the observation explicitly says something is possible/available
(e.g., "It is possible to climb down", "You can enter", "A door leads ..."):
TRY the corresponding canonical command EVEN IF it is not listed in valid_actions.
Mapping (canonical):
- "possible to climb down" / "climb down" / "descend" -> down
- "possible to climb up" / "climb up" / "ascend" -> up
- "possible to enter" / "you can enter" / "way in" / "entrance" -> in
- "possible to leave" / "way out" -> out
Do this only once per location; if it fails, do not spam it—switch strategy or call valid_actions.
============================================================
MOVEMENT PRIORITY (IMPORTANT)
If you decide to MOVE and multiple movement actions are available, use this priority order:
1) Prefer "in", then "up", then "down" (these often unlock new areas/puzzles).
2) Then prefer a movement you have NOT tried recently from this location.
3) Only then choose cardinal directions: north / east / south / west (and diagonals if present).
Notes:
- This is only a preference when you are moving (not a rule to always move).
- If you just arrived in a room, follow LOCAL BEFORE LEAVING first (look + one local interaction), then move.
Examples:
- If valid_actions includes: in, up, north, east -> choose "in" (unless you just tried it and it failed).
- If valid_actions includes: up, north, south -> choose "up" (unless you just tried it and it failed).
============================================================
EXAMINE POLICY (ANTI-SPAM, CRITICAL)
- Do NOT use "examine X" as a default action.
- Use "examine X" ONLY if:
A) X is NEW in the latest observation, OR
B) X looks interactive/blocking (door, window, grating, trapdoor, gate, chest, mailbox, leaves/pile, rope, lever, button), OR
C) you just got a blocking message ("locked", "not enough to allow entry", "can't", etc.) and you need more detail.
- If the game replied "nothing special" (or equivalent) for the same object at the same location:
DO NOT examine it again there. Change strategy (open/take/pull/enter/move).
- Informational items (leaflet, note, inscription):
Read/examine ONCE, then ignore. Never put them in containers.
============================================================
============================================================
ERROR RECOVERY (CRITICAL)
If the game replies:
- "I don't know the word ..."
- "That sentence isn't one I recognize"
- "You can't see any X here"
- "locked" / "no tools"
Then:
1) Do NOT repeat the same command.
2) Simplify: shorter noun, simpler verb (look / examine <noun> / take <noun>).
3) If still stuck: call valid_actions {} ONCE, then pick ONE action from that list.
============================================================
ANTI-LOOP (CRITICAL)
If you already tried the same interaction with the same object and it gave no progress:
STOP interacting with that object.
Move to a new location.
Informational items:
If an item only prints text (like a leaflet), read once then ignore it (do not put in containers, do not shuffle it).
============================================================
EXAMPLES
THOUGHT: There is a mailbox; it may contain something.
TOOL: play_action
ARGS: {"action": "open mailbox"}
THOUGHT: Valuable item spotted; secure it first.
TOOL: play_action
ARGS: {"action": "take egg"}
THOUGHT: My last command failed; I need valid options.
TOOL: valid_actions
ARGS: {}
============================================================
STRATEGY TIPS
1. Explore systematically, but prefer in/up/down if available; otherwise try one new direction at a time.
2. Read documents once. Examine only new/blocking/valuable objects.
3. Use get_map() to track explored locations
4. Light is essential - find a light source before dark areas!
5. Manage inventory - you can only carry limited items
"""
# =============================================================================
# Student Agent - IMPLEMENT THIS CLASS
# =============================================================================
class StudentAgent:
"""
Your ReAct agent implementation.
TODO:
1. Implement the run() method with the ReAct loop
2. Parse LLM responses to extract tool calls
3. Track state and avoid loops
Use the provided call_llm() function to interact with the LLM.
"""
def __init__(self):
"""Initialize your agent here."""
# Internal trace (used to build prompts)
# Each entry: {"thought": str, "tool": str, "args": dict, "result": str}
self.history: list[dict] = []
# Stats/state (for RunResult)
self.locations_visited: set[str] = set()
# Track room changes (so we can reset recommendation cache on new rooms)
self._last_room_line: str | None = None
# Keep last play_action to prevent trivial repeats
self._last_action: str | None = None
# Loop detection based on server state hash
self._recent_state_hashes = deque(maxlen=20)
# Checkpoint management
self._checkpoint_enabled = True
self._checkpoint_best = "best"
self._checkpoint_loop = "loop"
self._last_score_seen: int | None = None
# synthetic memory
self.synth_memory = {
"facts": [],
"blocking": [],
"inventory_goals": [],
"open_threads": [],
"visited": [],
"last_update_move": 0
}
# objective manager
self.objman = ObjectiveManager()
# LLM planner cache
self._planner_last_step = 0
self._planner_cooldown = 5 # run planner at most every 5 steps (tweak)
self._planner_suggested_actions: list[str] = []
self._planner_notes: str = ""
async def run(
self,
client, # FastMCP Client connected to your MCP server
game: str,
max_steps: int,
seed: int,
verbose: bool = False,
) -> RunResult:
"""
Run the agent for a game session.
Args:
client: FastMCP Client connected to your MCP server
game: Name of the game being played (e.g., "zork1")
max_steps: Maximum number of steps to take
seed: Random seed for reproducibility (use for LLM calls)
verbose: Whether to print detailed output
Returns:
RunResult with final score and statistics
"""
# Utilities for robustness
def _tool_text(res) -> str:
"""
FastMCP returns different shapes depending on version:
- sometimes an object with .content[0].text
- sometimes a list of parts with .text
- sometimes already a string
"""
if res is None:
return ""
if isinstance(res, str):
return res
if isinstance(res, dict):
return json.dumps(res)
# Newer fastmcp style: result.content[0].text
content = getattr(res, "content", None)
if content:
try:
if isinstance(content, list) and content and hasattr(content[0], "text"):
return content[0].text or ""
except Exception:
pass
# Older / alternate: list of parts
if isinstance(res, list) and res:
try:
if hasattr(res[0], "text"):
return res[0].text or ""
except Exception:
pass
# Fallback
return str(res)
def _extract_location(obs: str) -> str | None:
"""Heuristic: first plausible room-title line."""
if not obs:
return None
for line in obs.splitlines():
s = line.strip()
if not s:
continue
low = s.lower()
# Skip common headers
if low.startswith(("copyright", "revision", "serial number")):
continue
if "trademark" in low:
continue
# Zork titles: short, not a full sentence
if len(s) > 50:
continue
if s.endswith((".", "!", "?", ":", ";")):
continue
bad_starts = (
"you ", "it ", "i ", "there ", "the ", "a ", "an ",
"what ", "can't ", "i don't", "unknown", "error"
)
if low.startswith(bad_starts):
continue
return s
return None
def _parse_score_moves_from_memory(mem: str) -> tuple[int | None, int | None]:
"""Parse lines like 'Score: X' / 'Moves: Y' (best-effort)."""
if not mem:
return (None, None)
score = None
moves = None
m = re.search(r"\bScore:\s*(\d+)\b", mem)
if m:
score = int(m.group(1))
m = re.search(r"\bMoves:\s*(\d+)\b", mem)
if m:
moves = int(m.group(1))
return (score, moves)
async def _force_valid_actions_feedback(msg: str) -> str:
"""
Retourne un feedback + la liste des actions valides (si dispo).
"""
va_text = ""
if "valid_actions" in available_tool_names:
try:
va_text = _tool_text(await client.call_tool("valid_actions", {}))
except Exception as e:
va_text = f"(valid_actions failed: {e})"
return f"{msg}\n\nValid actions:\n{va_text}".strip()
def should_summarize(step_idx, observation, state_obj):
if step_idx % 10 == 0:
return True
low = (observation or "").lower()
triggers = [
"locked", "dark", "can't", "you don't know", "you can't see",
"grating", "trapdoor", "door", "key", "lamp"
]
return any(t in low for t in triggers)
def _print_step(step_idx: int, thought: str, tool: str, args: dict):
if not verbose:
return
print("\n" + "─" * 40)
print(f"Step {step_idx}/{max_steps}")
print("THOUGHT:", thought)
print("TOOL:", tool)
print("ARGS:", args)
def _parse_points_from_obs(obs: str) -> tuple[int | None, int | None]:
"""
Returns (delta_points, total_points) if present in observation, else (None, None)
Matches patterns like: "+10 points! (Total: 15)"
"""
if not obs:
return (None, None)
delta = None
total = None
m = re.search(r"\+(\d+)\s*point(?:s)?!", obs)
if m:
delta = int(m.group(1))
m = re.search(r"\(Total:\s*(\d+)\)", obs)
if m:
total = int(m.group(1))
return (delta, total)
# Discover tools
default_tools = {
"play_action",
"memory",
"inventory",
"get_map",
"valid_actions",
"tried_actions",
"hint",
"state",
"exits",
"graph",
"checkpoint_save",
"checkpoint_restore",
"action_probe"
}
available_tool_names = set(default_tools)
if hasattr(client, "list_tools"):
try:
tools = await client.list_tools()
# tools can be list of objects with `.name`
names = []
for t in tools or []:
n = getattr(t, "name", None)
if n:
names.append(n.strip().lower())
if names:
available_tool_names = set(names)
except Exception:
# If list_tools isn't available / fails, keep defaults
pass
# session initialization
self.history.clear()
self.locations_visited.clear()
self._last_room_line = None
self._last_action = None
self._recent_state_hashes.clear()
self._last_score_seen = None
self.objman = ObjectiveManager()
# 1) Initial look
try:
res = await client.call_tool("play_action", {"action": "look"})
observation = _tool_text(res)
except Exception as e:
return RunResult(
final_score=0,
max_score=350 if game == "zork1" else 0,
moves=0,
locations_visited=set(),
game_completed=False,
error=f"Initial call_tool failed: {e}",
history=[],
)
loc = _extract_location(observation)
if loc:
self.locations_visited.add(loc)
self._last_room_line = loc.strip().lower()
# Save an initial checkpoint if supported (for loop recovery)
if self._checkpoint_enabled and "checkpoint_save" in available_tool_names:
try:
await client.call_tool("checkpoint_save", {"name": self._checkpoint_loop})
except Exception:
pass
if verbose:
print("=" * 60)
print(f"Starting agent on game={game} | max_steps={max_steps}")
print("=" * 60)
print("\nInitial observation:\n", observation)
# If we detect loops / no-ops, force valid actions next
force_valid_actions_next = False
# Track run history for grading
run_history: list[tuple[str, str, str]] = []
# We'll keep best-known score/moves
best_score: int | None = None
best_moves: int | None = None
# anti-loop state
# actions that produced "no progress" recently in this location
blocked_actions_by_loc: dict[str, set[str]] = {}
# recommended actions in this location (to avoid repeating same suggestions)
recommended_actions_by_loc: dict[str, set[str]] = {}
def _result_short(txt: str) -> str:
return re.sub(r"\s+", " ", (txt or "").strip())[:180]
def _cur_loc_key() -> str:
# use last known room line if available, else fallback to empty
if self._last_room_line:
return self._last_room_line
return "unknown:" + _result_short(observation)
def _is_no_progress_result(txt: str) -> bool:
t = (txt or "").lower()
triggers = [
"but thing not happen",
"not see any way",
"too heavy",
"stuck",
"not happen",
"that not thing",
"grunk not see that there",
"you can't see any",
"not know how",
"nothing special",
]
return any(x in t for x in triggers)
# 2) ReAct loop
for step_idx in range(1, max_steps + 1):
memory_text = None
map_text = None
valid_actions_text = None
hint_text = None
state_text = None
state_obj = None
if "state" in available_tool_names:
try:
state_text = _tool_text(await client.call_tool("state", {}))
state_obj = json.loads(state_text) if state_text else None
except Exception:
state_obj = None
# update objectives from latest observation/state
if state_obj and isinstance(state_obj, dict):
try:
self.objman.update_from_observation(observation, state_obj)
except Exception:
pass
# deterministic overrides (before LLM)
if state_obj and isinstance(state_obj, dict):
visible = [str(x).lower() for x in (state_obj.get("visible_objects") or [])]
inv = " ".join([str(x).lower() for x in (state_obj.get("inventory") or [])])
# treasure rule (simple keyword scan)
treasure_words = {"treasure","gold","jewel","jewels","diamond","emerald","ruby","sapphire","crown","painting","egg","crystal"}
if any(w in visible for w in treasure_words):
# pick first matching visible object
item = next((x for x in visible if x in treasure_words), None)
if item:
# force play_action take <item>
tool_name = "play_action"
tool_args = {"action": f"take {item}"}
thought = "Valuable item spotted; secure it first."
# execute immediately (skip LLM)
_print_step(step_idx, thought, tool_name, tool_args)
res = await client.call_tool(tool_name, tool_args)
observation = _tool_text(res)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {json.dumps(tool_args)}", observation))
continue
# darkness handling
obs_low = (state_obj.get("last_observation") or "").lower()
if "dark" in obs_low and ("lamp" in inv or "lantern" in inv):
# try turning on if valid_actions allows it, else skip
# (only do this if you decide to allow multi-word exotic via valid_actions)
pass
# Loop detection using state_hash (server-side)
if isinstance(state_obj, dict) and state_obj.get("state_hash"):
h = str(state_obj["state_hash"])
self._recent_state_hashes.append(h)
# If the exact same hash repeats 3 times IN A ROW, we are looping
if len(self._recent_state_hashes) >= 3 and \
self._recent_state_hashes[-1] == self._recent_state_hashes[-2] == self._recent_state_hashes[-3]:
force_valid_actions_next = True
# Try to rollback to last checkpoint if possible (loop detected)
if self._checkpoint_enabled and "checkpoint_restore" in available_tool_names:
try:
# --- DEBUG: before restore ---
if verbose:
before_score = state_obj.get("score") if isinstance(state_obj, dict) else None
before_moves = state_obj.get("moves") if isinstance(state_obj, dict) else None
before_hash = state_obj.get("state_hash") if isinstance(state_obj, dict) else None
print(
f"[DEBUG] RESTORE requested checkpoint={self._checkpoint_loop} "
f"at step={step_idx} (before score={before_score}, moves={before_moves}, hash={before_hash})"
)
# --- restore checkpoint (capture tool output) ---
restore_res = await client.call_tool("checkpoint_restore", {"name": self._checkpoint_loop})
if verbose:
print("[DEBUG] checkpoint_restore result:", _tool_text(restore_res))
# --- refresh observation after restore ---
res = await client.call_tool("play_action", {"action": "look"})
observation = _tool_text(res)
self._recent_state_hashes.clear()
# --- fetch state AFTER restore (so debug + correct state_obj) ---
after_state_obj = None
if "state" in available_tool_names:
try:
st = _tool_text(await client.call_tool("state", {}))
after_state_obj = json.loads(st) if st else None
if isinstance(after_state_obj, dict):
state_obj = after_state_obj
try:
if isinstance(after_state_obj, dict) and "score" in after_state_obj:
self._last_score_seen = int(after_state_obj["score"])
except Exception:
pass
if verbose:
after_score = after_state_obj.get("score") if isinstance(after_state_obj, dict) else None
after_moves = after_state_obj.get("moves") if isinstance(after_state_obj, dict) else None
after_hash = after_state_obj.get("state_hash") if isinstance(after_state_obj, dict) else None
print(
f"[DEBUG] state after restore: score={after_score}, moves={after_moves}, hash={after_hash}"
)
except Exception as e:
after_state_obj = None
if verbose:
print("[DEBUG] state after restore failed:", e)
# --- optional: mark objective progress using AFTER state (not before) ---
try:
if isinstance(after_state_obj, dict):
self.objman.mark_done_if_progress({}, after_state_obj, self._last_action or "", observation)
except Exception:
pass
except Exception as e:
if verbose:
print("[DEBUG] checkpoint_restore block failed:", e)
pass
# Occasional tools (only if available)
if step_idx % 10 == 0 and "memory" in available_tool_names:
try:
memory_text = _tool_text(await client.call_tool("memory", {}))
s, m = _parse_score_moves_from_memory(memory_text)
if s is not None:
best_score = s
if m is not None:
best_moves = m
except Exception:
memory_text = None
if (force_valid_actions_next or step_idx % 25 == 0) and "get_map" in available_tool_names:
try:
map_text = _tool_text(await client.call_tool("get_map", {}))
except Exception:
map_text = None
if force_valid_actions_next and "hint" in available_tool_names:
try:
hint_text = _tool_text(await client.call_tool("hint", {}))
except Exception:
hint_text = None
tried_here_cached: set[str] | None = None
# Forced valid_actions on loop / parser failure
force_before = force_valid_actions_next
if force_valid_actions_next and "valid_actions" in available_tool_names:
try:
valid_actions_text = _tool_text(await client.call_tool("valid_actions", {}))
va_list = self._extract_valid_actions(valid_actions_text)
tried_here = set()
if "tried_actions" in available_tool_names:
try:
tried_text = _tool_text(await client.call_tool("tried_actions", {}))
tried_here = self._extract_tried_actions_for_current_location(tried_text)
except Exception:
tried_here = set()
tried_here_cached = tried_here
loc_key = _cur_loc_key()
blocked_here = blocked_actions_by_loc.setdefault(loc_key, set())
recommended_here = recommended_actions_by_loc.setdefault(loc_key, set())
# candidates = not tried, not blocked, not already recommended (if possible)
cands = [a for a in va_list
if self._norm(a) not in tried_here
and self._norm(a) not in blocked_here
and self._norm(a) not in recommended_here]
if not cands:
# we relax the "recommended_here" constraint
cands = [a for a in va_list
if self._norm(a) not in tried_here
and self._norm(a) not in blocked_here]
if not cands:
cands = va_list
best_act = await self._choose_with_probe(client, cands, available_tool_names) if cands else None
if best_act:
recommended_here.add(self._norm(best_act))
# Inject a recommendation BUT do not execute anything
valid_actions_text = (
valid_actions_text.strip()
+ ("\n\nAlready tried here:\n- " + "\n- ".join(sorted(tried_here)) if tried_here else "")
+ ("\n\nBlocked here:\n- " + "\n- ".join(sorted(blocked_here)) if blocked_here else "")
+ (f"\n\nRECOMMENDED NEXT (choose exactly ONE from valid_actions):\n- {best_act}" if best_act else "")
+ "\n\nSYSTEM: If recommended fails, choose a DIFFERENT action from valid_actions."
)
except Exception as e:
valid_actions_text = f"(valid_actions failed: {e})"
force_valid_actions_next = False
# Build helpers for planner inputs
va_list = self._extract_valid_actions(valid_actions_text) if valid_actions_text else []
tried_here = []
if tried_here_cached is not None:
tried_here = sorted(list(tried_here_cached))
elif "tried_actions" in available_tool_names:
try:
tried_text = _tool_text(await client.call_tool("tried_actions", {}))
tried_here = sorted(list(self._extract_tried_actions_for_current_location(tried_text)))
except Exception:
tried_here = []
# Update synth memory
if should_summarize(step_idx, observation, state_obj):
recent = self.history[-8:]
recent_lines = "\n".join(
f"- {h['args'].get('action','')} -> {(h['result'].splitlines()[0] if h['result'] else '')}"
for h in recent if h.get("tool") == "play_action"
)
prompt = build_synth_prompt(self.synth_memory, recent_lines, state_obj or {})
try:
txt = call_llm(prompt, SYNTH_SYSTEM, seed=seed + 10_000 + step_idx, max_tokens=350)
new_mem = json.loads(txt)
for k in ["facts", "blocking", "inventory_goals", "open_threads", "visited"]:
if k not in new_mem or not isinstance(new_mem[k], list):
new_mem[k] = []
new_mem["last_update_move"] = int((state_obj or {}).get("moves", step_idx))
self.synth_memory = new_mem
except Exception:
pass
# Run planner after synth memory update
if self._planner_should_run(step_idx, observation, force_before):
self._run_planner_llm(
observation=observation,
state_obj=state_obj or {},
valid_actions_list=va_list,
tried_here_list=tried_here,
seed=seed,
step_idx=step_idx,
)
# Build prompt + call LLM
prompt = self._build_prompt(
observation=observation,
memory_text=memory_text,
map_text=map_text,
valid_actions_text=valid_actions_text,
hint_text=hint_text,
)
va_list = self._extract_valid_actions(valid_actions_text) if valid_actions_text else []
llm_response = self._call_llm(prompt=prompt, system_prompt=SYSTEM_PROMPT, seed=seed + step_idx)
thought, tool_name, tool_args = self._parse_response(llm_response)
tool_name = (tool_name or "").strip().lower()
if valid_actions_text:
va_list = self._extract_valid_actions(valid_actions_text)
if tool_name == "play_action":
act = self._norm(tool_args.get("action", ""))
if va_list and not self._is_allowed_exotic(act, va_list) and not self._is_canonical_action(act):
# action non-canonique et pas dans valid_actions -> re-trigger recovery
force_valid_actions_next = True
if force_valid_actions_next:
observation = "SYSTEM FEEDBACK: Non-canonical action not in valid_actions. Recomputing valid_actions."
continue
# avoid calling tried_actions/valid_actions twice in a row
if self.history:
last_tool = (self.history[-1].get("tool") or "").strip().lower()
if tool_name in {"tried_actions", "valid_actions"} and last_tool == tool_name:
observation = (
f"SYSTEM FEEDBACK: Do not call {tool_name} twice in a row. "
"Choose ONE concrete play_action from the last valid_actions list, or move."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
# Hard tool validation: if unknown tool, coach and continue without spending a move
if tool_name not in available_tool_names:
va_text = ""
if "valid_actions" in available_tool_names:
try:
va_text = _tool_text(await client.call_tool("valid_actions", {}))
except Exception as e:
va_text = f"(valid_actions failed: {e})"
observation = (
"SYSTEM FEEDBACK: You requested an UNKNOWN TOOL.\n"
f"Tool must be one of: {', '.join(sorted(available_tool_names))}.\n"
"Use play_action with ARGS {\"action\": \"...\"} for game commands.\n\n"
f"Suggested valid actions:\n{va_text}"
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
# Enforce args shape
if tool_name not in {"play_action", "checkpoint_save", "checkpoint_restore", "action_probe"}:
tool_args = {}
if tool_name in {"checkpoint_save", "checkpoint_restore"}:
if not isinstance(tool_args, dict):
tool_args = {}
if "name" in tool_args and not isinstance(tool_args["name"], str):
tool_args["name"] = "auto"
if tool_name == "action_probe":
if not isinstance(tool_args, dict):
tool_args = {"action": "look"}
if not isinstance(tool_args.get("action",""), str) or not tool_args["action"].strip():
tool_args["action"] = "look"
# Normalize / validate play_action command a bit
if tool_name == "play_action":
if not isinstance(tool_args, dict):
# au lieu de "look"
observation = await _force_valid_actions_feedback(
"SYSTEM FEEDBACK: Invalid ARGS for play_action. Call valid_actions and pick ONE exact action."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
raw_action = str(tool_args.get("action", "") or "").strip()
if not raw_action:
# au lieu de "look"
observation = await _force_valid_actions_feedback(
"SYSTEM FEEDBACK: Missing action. Call valid_actions and pick ONE exact action."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
action = self._normalize_action(raw_action) if hasattr(self, "_normalize_action") else raw_action.lower()
action = action.strip().lower()
if not action:
observation = await _force_valid_actions_feedback(
"SYSTEM FEEDBACK: Empty/invalid action after normalization. Call valid_actions and pick ONE exact action."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
tool_args["action"] = action
# movement priority (in/up/down) when valid_actions known
if valid_actions_text and self._is_move(action):
va_list = [self._norm(x) for x in self._extract_valid_actions(valid_actions_text)]
if action in {"north","south","east","west"}:
if any(m in va_list for m in {"in","up","down"}):
observation = (
"SYSTEM FEEDBACK: Movement priority: prefer in/up/down when available. "
"Call valid_actions and pick one of those if present."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
# If not canonical, allow only if it appears in valid_actions (when available).
# If we don't have valid_actions_text yet, fetch it once and decide.
if not self._is_canonical_action(action):
va_text = valid_actions_text
va_list: list[str] = self._extract_valid_actions(va_text) if va_text else []
if not va_list and "valid_actions" in available_tool_names:
try:
va_text = _tool_text(await client.call_tool("valid_actions", {}))
va_list = self._extract_valid_actions(va_text)
except Exception:
va_text = None
va_list = []
if not va_list or not self._is_allowed_exotic(action, va_list):
# coach and force valid_actions next
observation = (
"SYSTEM FEEDBACK: Your command is not canonical and is not allowed unless it appears "
"EXACTLY in valid_actions. Simplify (look / north / take X / open X / examine X / read X / etc.) "
"or call valid_actions then pick ONE action from it.\n"
)
if va_text:
observation += f"\nvalid_actions for this location:\n{va_text}"
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
# Simple anti-repeat (soft): if same as last action and we got no new info previously, force valid actions
if self._last_action and action.strip().lower() == self._last_action:
# Don’t block always; only if last observation looked identical-ish
prev_short = (self.history[-1]["result"] if self.history else "")[:200].strip()
cur_short = (observation or "")[:200].strip()
if prev_short and prev_short == cur_short:
observation = (
"SYSTEM FEEDBACK: You are repeating the same action with no progress. "
"Call valid_actions and choose ONE different action."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
# per-location blocked actions (avoid "pull lever" spam etc.)
if tool_name == "play_action":
loc_key = _cur_loc_key()
blocked = blocked_actions_by_loc.setdefault(loc_key, set())
act_norm = self._norm(tool_args.get("action", ""))
if act_norm in blocked:
observation = (
"SYSTEM FEEDBACK: This action already produced no progress in this location. "
"Do NOT repeat it. Call valid_actions and pick a different action (prefer the recommended one)."
)
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {tool_args}", observation))
force_valid_actions_next = True
continue
if verbose:
print("\n" + "─" * 40)
print(f"Step {step_idx}/{max_steps}")
print("THOUGHT:", thought)
print("TOOL:", tool_name)
print("ARGS:", tool_args)
# Execute tool
prev_observation = observation
try:
res = await client.call_tool(tool_name, tool_args)
observation = _tool_text(res)
# immediate checkpoint on score gain (robust) ---
if tool_name == "play_action" and self._checkpoint_enabled and "checkpoint_save" in available_tool_names:
delta_pts, total_pts = _parse_points_from_obs(observation)
if delta_pts is not None and delta_pts > 0:
low_obs = (observation or "").lower()
if "game over" not in low_obs and "grue" not in low_obs:
# prefer authoritative score from state if available
cur_total = None
try:
if isinstance(state_obj, dict) and "score" in state_obj:
cur_total = int(state_obj["score"])
except Exception:
cur_total = None
# fallback to parsed total if present
if cur_total is None and total_pts is not None:
cur_total = total_pts
# monotone rule (only if we have a total); otherwise save anyway
should_save = (cur_total is None) or (self._last_score_seen is None) or (cur_total > self._last_score_seen)
if should_save:
if cur_total is not None:
self._last_score_seen = cur_total
try:
if verbose:
print(f"[DEBUG] CHECKPOINT_SAVE after +{delta_pts} (total={cur_total}) at step={step_idx}")
# 1) best
await client.call_tool("checkpoint_save", {"name": self._checkpoint_best})
# 2) loop-safe position
try:
await client.call_tool("checkpoint_save", {"name": self._checkpoint_loop})
except Exception:
pass
except Exception as e:
if verbose:
print("[DEBUG] checkpoint_save after points failed:", e)
except Exception as e:
observation = f"Error calling tool {tool_name}: {e}"
if tool_name == "play_action":
loc_key = _cur_loc_key()
blocked_actions_by_loc.setdefault(loc_key, set()).add(self._norm(tool_args.get("action", "")))
force_valid_actions_next = True
if self._looks_like_parser_failure(observation):
force_valid_actions_next = True
if tool_name == "play_action":
loc_key = _cur_loc_key()
act_norm = self._norm(tool_args.get("action", ""))
rs = _result_short(observation)
if _is_no_progress_result(observation):
blocked_actions_by_loc.setdefault(loc_key, set()).add(act_norm)
# Track location changes
loc = _extract_location(observation)
if loc:
self.locations_visited.add(loc)
loc_line = loc.strip().lower()
if self._last_room_line is None or loc_line != self._last_room_line:
self._last_room_line = loc_line
# Loop detection: same play_action 3x with same short observation
if tool_name == "play_action":
act = str(tool_args.get("action", "")).strip().lower()
self._last_action = act
last_plays = [
h for h in reversed(self.history)
if h.get("tool") == "play_action" and isinstance(h.get("args"), dict)
][:2]
if len(last_plays) == 2:
last_act1 = str(last_plays[0]["args"].get("action", "")).strip().lower()
last_act2 = str(last_plays[1]["args"].get("action", "")).strip().lower()
cur_short = (observation or "").strip()[:200]
last_obs1 = (last_plays[0].get("result") or "").strip()[:200]
last_obs2 = (last_plays[1].get("result") or "").strip()[:200]
if act and act == last_act1 == last_act2 and cur_short and cur_short == last_obs1 == last_obs2:
observation = (
"SYSTEM FEEDBACK: You repeated the same action 3 times with no new info. "
"Stop repeating. Call valid_actions and pick ONE different action."
)
force_valid_actions_next = True
# No-op movement detection: movement but observation unchanged
if tool_name == "play_action":
a = str(tool_args.get("action", "")).strip().lower()
if a in {"north", "south", "east", "west", "up", "down", "in", "out"}:
prev_short = (prev_observation or "").strip()[:200]
cur_short = (observation or "").strip()[:200]
if prev_short and cur_short == prev_short:
force_valid_actions_next = True
if verbose:
print("\nRESULT:\n", observation)
# Save traces
self.history.append({"thought": thought, "tool": tool_name, "args": tool_args, "result": observation})
run_history.append((thought, f"{tool_name} {json.dumps(tool_args) if isinstance(tool_args, dict) else tool_args}", observation))
# Stop conditions
if "game over" in (observation or "").lower() or "*** game over ***" in (observation or "").lower():
break
# 3) Final Stats
# Prefer memory tool to get authoritative score/moves if available
final_score = best_score if best_score is not None else 0
moves = best_moves if best_moves is not None else 0
if "memory" in available_tool_names:
try:
mem = _tool_text(await client.call_tool("memory", {}))
s, m = _parse_score_moves_from_memory(mem)
if s is not None:
final_score = s
if m is not None:
moves = m
except Exception:
pass
game_completed = "game over" in (observation or "").lower()
max_score = 350 if game == "zork1" else 0 # keep simple; adjust later if you want per-game max scores
return RunResult(
final_score=final_score,
max_score=max_score, # Zork1 max score, adjust if needed
moves=moves,
locations_visited=set(self.locations_visited),
game_completed=game_completed,
history=run_history,
)
def _build_prompt(
self,
observation: str,
memory_text: str | None = None,
map_text: str | None = None,
valid_actions_text: str | None = None,
hint_text: str | None = None,
) -> str:
"""
Build the prompt for the LLM.
"""
parts: list[str] = []
if memory_text:
parts.append("Game memory (authoritative):")
parts.append(memory_text.strip())
parts.append("")
if map_text:
parts.append("Explored map:")
parts.append(map_text.strip())
parts.append("")
if valid_actions_text:
parts.append("Suggested valid actions (choose EXACTLY if you use one):")
parts.append(valid_actions_text.strip())
parts.append("")
if hint_text:
parts.append("Hint (non-spoiler):")
parts.append(hint_text.strip())
parts.append("")
# Short recent history: last 2 interactions
if getattr(self, "history", None):
last = self.history[-2:]
if last:
parts.append("Recent actions (most recent last):")
for h in last:
tool = h.get("tool", "")
args = h.get("args", {})
# Keep result short to avoid prompt bloat
res = (h.get("result") or "").strip().replace("\n", " ")
if len(res) > 160:
res = res[:160] + "..."
parts.append(f"- {tool} {args} -> {res}")
parts.append("")
# Current observation always last
parts.append("Current observation:")
parts.append((observation or "").strip())
# Tiny nudges based on common patterns
low = (observation or "").lower()
if "contains:" in low:
parts.append("")
parts.append("Hint: If a container contains items, try 'take <noun>' using the exact noun shown.")
if "is closed" in low:
parts.append("")
parts.append("Hint: If something is closed, try 'open <noun>' using the exact noun shown.")
if "dark" in low:
parts.append("")
parts.append("Hint: If it is dark, prioritize finding/using a light source (take lamp, turn on lamp).")
parts.append("Synthesized memory (high signal):")
parts.append(json.dumps(self.synth_memory, ensure_ascii=False, indent=2))
parts.append("")
parts.append("Current objectives (highest priority first):")
parts.append(self.objman.render()) # short text
parts.append("")
# LLM planner suggestions (do not auto-execute)
if getattr(self, "_planner_suggested_actions", None):
parts.append("Planner suggestions (DO NOT auto-execute; pick one if sensible):")
for a in self._planner_suggested_actions[:3]:
parts.append(f"- {a}")
if getattr(self, "_planner_notes", ""):
parts.append(f"Planner notes: {self._planner_notes}")
parts.append("")
parts.append("")
parts.append("What do you do next? Remember the required output format.")
return "\n".join(parts)
def _parse_response(self, response: str) -> tuple[str, str, dict]:
"""
Parse LLM response to extract thought, tool name, and arguments.
Returns:
Tuple of (thought, tool_name, args_dict)
"""
thought = ""
tool_name = "play_action"
tool_args: dict = {"action": "look"}
if not response:
return ("", "play_action", {"action": "look"})
text = response.strip()
# Fast path: try regex extraction that works even with extra text/noise
thought_m = re.search(r"(?im)^\s*THOUGHT\s*:\s*(.+?)\s*$", text)
tool_m = re.search(r"(?im)^\s*TOOL\s*:\s*([a-zA-Z0-9_]+)\s*$", text)
args_m = re.search(r"(?im)^\s*ARGS\s*:\s*(\{.*\}|\[.*\]|.+?)\s*$", text)
if thought_m:
thought = thought_m.group(1).strip()
if tool_m:
tool_name = tool_m.group(1).strip().lower()
# Parse ARGS (best-effort)
raw_args = None
if args_m:
raw_args = args_m.group(1).strip()
# If ARGS line exists but JSON is on next lines, try to capture a JSON block
if raw_args is None:
# Try to find the first JSON object after "ARGS:"
idx = text.lower().find("args:")
if idx != -1:
tail = text[idx + 5 :].strip()
# If tail doesn't start with '{', try to find one
jstart = tail.find("{")
if jstart != -1:
tail2 = tail[jstart:]
# naive brace matching
depth = 0
end = None
for i, ch in enumerate(tail2):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end is not None:
raw_args = tail2[:end].strip()
if raw_args is not None:
try:
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
tool_args = parsed
else:
# If model gave a list/string, treat as invalid
tool_args = {"action": "look"} if tool_name == "play_action" else {}
except Exception:
tool_args = {"action": ""} if tool_name == "play_action" else {}
# Enforce schema expectations
if tool_name == "play_action":
if not isinstance(tool_args, dict):
tool_args = {"action": "look"}
action = tool_args.get("action", "")
if not isinstance(action, str) or not action.strip():
tool_args["action"] = "look"
elif tool_name in {"checkpoint_save", "checkpoint_restore"}:
if not isinstance(tool_args, dict):
tool_args = {}
# name is optional; server default = "auto"
if "name" in tool_args and not isinstance(tool_args["name"], str):
tool_args["name"] = "auto"
elif tool_name == "action_probe":
if not isinstance(tool_args, dict):
tool_args = {}
if not isinstance(tool_args.get("action", ""), str) or not tool_args["action"].strip():
tool_args["action"] = "look"
else:
tool_args = {}
return thought, tool_name, tool_args
def _call_llm(self, prompt: str, system_prompt: str, seed: int) -> str:
"""
Call the LLM with the given prompt.
This is a convenience wrapper - you can also use call_llm() directly.
"""
return call_llm(prompt, system_prompt, seed)
def _normalize_action(self, action: str) -> str:
"""Soft normalizer: only for very safe rewrites."""
a = (action or "").strip().lower()
# "look around ..." -> "look"
if a.startswith("look around"):
return "look"
# common harmless variants -> "look"
if a in {"l", "look.", "look!", "look?"}:
return "look"
# "go north" -> "north" (only for cardinal/up/down)
if a.startswith("go "):
rest = a[3:].strip()
if rest in {
"north", "south", "east", "west",
"up", "down", "in", "out",
"northeast", "northwest", "southeast", "southwest",
}:
return rest
# "look at X" -> "examine X"
m = re.match(r"look at (.+)", a)
if m:
return f"examine {m.group(1).strip()}"
# "look X" -> "examine X" ONLY when it's not a "look <preposition> ..." form
m = re.match(r"^look\s+(.+)$", a)
if m:
tail = m.group(1).strip()
# Keep Zork-ish / parser forms like: look for/in/under/behind/through...
first = tail.split(" ", 1)[0]
if first in {"for", "in", "inside", "under", "behind", "through", "around", "over", "on", "at"}:
# note: "look at X" is handled above already, so here we just keep it
return a
# Otherwise: treat "look <noun>" as "examine <noun>"
if tail:
return f"examine {tail}"
return a
def _is_canonical_action(self, action: str) -> bool:
"""True if action matches strict canonical grammar."""
a = (action or "").strip().lower()
# single-word commands
if a in {
"look", "inventory",
"north", "south", "east", "west",
"up", "down", "in", "out",
"northeast", "northwest", "southeast", "southwest",
}:
return True
# verb + noun (2-3 tokens max)
parts = a.split()
if len(parts) in (2, 3):
verb = parts[0]
if verb in {"take", "drop", "open", "examine", "read", "climb", "enter", "pull", "push", "unlock"}:
# forbid placeholders
if any(tok.startswith("<") and tok.endswith(">") for tok in parts[1:]):
return False
return True
return False
def _is_allowed_exotic(self, action: str, valid_actions_list: list[str]) -> bool:
"""Exotic commands are allowed only if they appear EXACTLY in valid_actions (spacing/case-tolerant)."""
if not action:
return False
a_norm = re.sub(r"\s+", " ", action.strip().lower())
for va in valid_actions_list:
va_norm = re.sub(r"\s+", " ", va.strip().lower())
if a_norm == va_norm:
return True
return False
def _looks_like_parser_failure(self, obs: str) -> bool:
"""Detect common parser failure messages."""
if not obs:
return False
o = obs.lower()
triggers = [
"i don't know the word",
"that sentence isn't one i recognize",
"you used the word",
"there was no verb",
"i don't understand",
"you must tell me how to do that",
"you can't see any",
]
return any(t in o for t in triggers)
def _extract_valid_actions(self, valid_actions_text: str) -> list[str]:
"""
Parse MCP valid_actions output into a list of exact commands.
Supports formats like:
'Valid actions:\n- close mailbox\n- north\n...'
"""
if not valid_actions_text:
return []
lines = [ln.strip() for ln in valid_actions_text.splitlines()]
actions: list[str] = []
for ln in lines:
if ln.startswith("- "):
actions.append(ln[2:].strip())
return actions
def _norm(self, s: str) -> str:
return re.sub(r"\s+", " ", (s or "").strip().lower())
def _is_move(self, action: str) -> bool:
return self._norm(action) in {
"north", "south", "east", "west", "up", "down", "in", "out",
"northwest", "northeast", "southwest", "southeast",
}
def _extract_tried_actions_for_current_location(self, tried_actions_text: str) -> set[str]:
"""
Parse output of tried_actions() from the server and return the set of actions
already attempted in the current location (best-effort).
"""
if not tried_actions_text:
return set()
cur = (self._last_room_line or "").strip().lower()
if not cur:
return set()
lines = tried_actions_text.splitlines()
# Look for a block:
# - <Location>:
# - action
in_block = False
tried = set()
for ln in lines:
s = ln.rstrip("\n")
# Start of a location block
if re.match(r"^\-\s+.+:\s*$", s.strip()):
loc_name = s.strip()[2:-1].strip().lower()
in_block = (loc_name == cur)
continue
# Action lines in the block (format " - xxx")
if in_block:
st = s.strip()
if st.startswith("- "):
act = st[2:].strip().lower()
if act:
tried.add(self._norm(act))
return tried
def _rank_action_candidate(self, action: str) -> int:
"""
Smaller is better. Gives a deterministic ranking for probing/choosing.
"""
a = self._norm(action)
if a.startswith("take "): return 0
if a.startswith("open "): return 1
if a.startswith("unlock "): return 2
if a.startswith("enter "): return 3
if a in {"in", "up", "down"}: return 4
if a.startswith("read "): return 5
if a.startswith("examine "): return 6
if a in {"north","east","south","west","northeast","northwest","southeast","southwest"}: return 7
if a == "look": return 8
if a == "inventory": return 9
return 50
async def _choose_with_probe(
self,
client,
candidates: list[str],
available_tool_names: set[str],
) -> str | None:
"""
Use action_probe to select the best candidate.
Best = positive score_delta, else state_hash change, else first candidate.
Probes at most 2 actions to stay cheap.
"""
if not candidates:
return None
if "action_probe" not in available_tool_names:
return candidates[0]
# Sort candidates by heuristic rank, then probe top 2
candidates_sorted = sorted(candidates, key=lambda x: self._rank_action_candidate(x))
to_probe = candidates_sorted[:2]
best = None
best_tuple = None # (score_delta, hash_changed, reward_delta)
for act in to_probe:
try:
rep_raw = await client.call_tool("action_probe", {"action": act})
rep_txt = self._tool_text_any(rep_raw)
rep = json.loads(rep_txt) if rep_txt else {}
sd = int(rep.get("score_delta", 0) or 0)
rd = int(rep.get("reward_delta", 0) or 0)
hc = bool(rep.get("hash_changed")) # not perfect
tup = (sd, hc, rd)
if best_tuple is None or tup > best_tuple:
best_tuple = tup
best = act
except Exception:
continue
return best or candidates_sorted[0]
def _tool_text_any(self, res) -> str:
if res is None:
return ""
if isinstance(res, str):
return res
if isinstance(res, dict):
return json.dumps(res)
content = getattr(res, "content", None)
if content:
try:
if isinstance(content, list) and content and hasattr(content[0], "text"):
return content[0].text or ""
except Exception:
pass
if isinstance(res, list) and res:
try:
if hasattr(res[0], "text"):
return res[0].text or ""
except Exception:
pass
return str(res)
def _planner_should_run(self, step_idx: int, observation: str, force: bool) -> bool:
if force:
return True
if (step_idx - self._planner_last_step) < self._planner_cooldown:
return False
low = (observation or "").lower()
triggers = ["locked", "dark", "can't", "i don't know the word", "that sentence isn't one i recognize"]
return any(t in low for t in triggers) or (step_idx % self._planner_cooldown == 0)
def _filter_planner_actions(self, actions: list[str], valid_actions_list: list[str]) -> list[str]:
"""
Keep only actions that are:
- canonical OR appear exactly in valid_actions_list (if provided)
- non-empty
"""
out = []
va = [self._norm(x) for x in (valid_actions_list or [])]
for a in (actions or []):
a = (a or "").strip().lower()
if not a:
continue
if self._is_canonical_action(a):
out.append(a)
continue
# exotic allowed only if in valid_actions
if va and self._is_allowed_exotic(a, valid_actions_list):
out.append(a)
# max 3
return out[:3]
def _run_planner_llm(
self,
observation: str,
state_obj: dict,
valid_actions_list: list[str],
tried_here_list: list[str],
seed: int,
step_idx: int,
) -> None:
prompt = build_planner_prompt(
observation=observation,
state_obj=state_obj or {},
synth_memory=self.synth_memory or {},
objectives_text=self.objman.render(),
valid_actions_list=valid_actions_list or [],
tried_here=tried_here_list or [],
)
try:
txt = call_llm(prompt, PLANNER_SYSTEM, seed=seed + 50_000 + step_idx, max_tokens=450)
plan = json.loads(txt)
llm_objs = plan.get("objectives", [])
if isinstance(llm_objs, list):
self.objman.replace_from_llm(llm_objs)
sugg = plan.get("suggested_actions", [])
if not isinstance(sugg, list):
sugg = []
self._planner_suggested_actions = self._filter_planner_actions(sugg, valid_actions_list)
self._planner_notes = str(plan.get("notes", "") or "")[:200]
self._planner_last_step = step_idx
except Exception:
# planner failure should be silent (don’t break run)
self._planner_suggested_actions = []
self._planner_notes = ""
# =============================================================================
# For local testing
# =============================================================================
async def test_agent():
"""Test the agent locally."""
from fastmcp import Client
# Path to your MCP server
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())