flams commited on
Commit
4f5b6ec
·
1 Parent(s): 615a63b

soumission zork

Browse files
Files changed (4) hide show
  1. README.md +12 -13
  2. agent.py +388 -167
  3. mcp_server.py +445 -176
  4. requirements.txt +1 -2
README.md CHANGED
@@ -18,11 +18,19 @@ This is my submission for the Text Adventure Agent assignment. My agent uses the
18
 
19
  ## Approach
20
 
21
- <!-- Describe your approach here -->
22
 
23
- - What strategy does your agent use?
24
- - What tools did you implement in your MCP server?
25
- - Any interesting techniques or optimizations?
 
 
 
 
 
 
 
 
26
 
27
  ## Files
28
 
@@ -33,15 +41,6 @@ This is my submission for the Text Adventure Agent assignment. My agent uses the
33
  | `app.py` | Gradio interface for HF Space |
34
  | `requirements.txt` | Additional dependencies |
35
 
36
- ## How to Submit
37
-
38
- 1. Fork the template Space: `https://huggingface.co/spaces/LLM-course/text-adventure-template`
39
- 2. Clone your fork locally
40
- 3. Implement your agent in `agent.py` and `mcp_server.py`
41
- 4. Test locally (see below)
42
- 5. Push your changes to your Space
43
- 6. Submit your Space URL on the course platform
44
-
45
  ## Local Testing
46
 
47
  ```bash
 
18
 
19
  ## Approach
20
 
21
+ The agent follows a ReAct loop (Thought → Tool → Observation) with a clean separation between LLM reasoning and game state management via MCP. At each step the LLM produces structured text containing THOUGHT, TOOL, and ARGS fields; the agent parses this output and dispatches the appropriate MCP tool call. This decoupling means the LLM never interacts with the game directly — it reasons about what to do, and the tool layer executes it, returning observations that feed the next reasoning step.
22
 
23
+ A central design choice is the graph-based world map (the WorldMap class). Every room the agent visits becomes a node; movement directions become directed edges. When the agent moves to a new room, a reverse edge is automatically recorded so backtracking is always possible. The map separately tracks unexplored exits — directions mentioned in room descriptions that the agent has not yet traversed. Two BFS routines operate on this graph: `find_path` computes the shortest route between any two explored rooms, and `suggest_exploration` finds the nearest unexplored exit from the current location, giving the agent a concrete next target when it runs out of local options.
24
+
25
+ To compensate for the LLM's limited context window, the agent maintains a structured notebook with typed categories: Clue, Puzzle, Item, Danger, NPC, Code, Goal, and Map. Each entry is deduplicated on write to prevent bloat. The `memory` tool returns a full status dump — current location, inventory, all notebook entries, and the map — so the LLM can re-orient itself at any point without relying on conversation history alone.
26
+
27
+ Exit detection is automated: a regex scan runs on every game response, extracting direction words (north, south, up, etc.) and automatically calling `register_exits`. This removes a common failure mode where the LLM forgets to register exits, wasting action steps.
28
+
29
+ Stuck detection operates at multiple layers. On the server side, an action history counter flags when the same action appears three or more times in the last six actions. On the agent side, a `failed_actions` set records location-action pairs so the prompt can explicitly list what not to retry. A score stagnation detector triggers after eight consecutive turns with no score change, injecting a hint into the prompt that encourages the agent to try a different area or strategy.
30
+
31
+ The LLM output parser is deliberately forgiving: it performs fuzzy matching on tool names to handle typos and extra spaces, collects multi-line JSON across split outputs, falls back to regex extraction when JSON is malformed, and merges synonymous argument keys (target, object, item, direction) into a canonical form. A common normalization converts phrases like "go north" into the bare direction "north".
32
+
33
+ Finally, the system prompt encodes a ten-point strategy distilled from expert text adventure play: examine every object, take everything not nailed down, save clues to the notebook, try synonyms when a command fails, and routinely listen and search in each room.
34
 
35
  ## Files
36
 
 
41
  | `app.py` | Gradio interface for HF Space |
42
  | `requirements.txt` | Additional dependencies |
43
 
 
 
 
 
 
 
 
 
 
44
  ## Local Testing
45
 
46
  ```bash
agent.py CHANGED
@@ -1,26 +1,13 @@
1
  """
2
- Student Agent for Text Adventure Games
3
-
4
- This is your submission file. Implement the StudentAgent class to play
5
- text adventure games using the MCP server you also implement.
6
-
7
- Your agent should:
8
- 1. Connect to the MCP server via the provided client
9
- 2. Use the ReAct pattern (Thought -> Action -> Observation)
10
- 3. Call MCP tools to interact with the game
11
- 4. Maximize the game score within the step limit
12
-
13
- Required method:
14
- async def run(self, client, game, max_steps, seed, verbose) -> RunResult
15
-
16
- The 'client' is a FastMCP Client already connected to your MCP server.
17
- Use it to call tools like: await client.call_tool("play_action", {"action": "look"})
18
-
19
- Tips:
20
- - Start by looking around and understanding your environment
21
- - Keep track of visited locations to avoid loops
22
- - Pick up useful items (lamp, sword, etc.)
23
- - The seed parameter should be used to set your LLM's seed for reproducibility
24
  """
25
 
26
  import json
@@ -70,22 +57,15 @@ else:
70
  def call_llm(prompt: str, system_prompt: str, seed: int, max_tokens: int = 300) -> str:
71
  """
72
  Call the LLM with the given prompt. Use this function in your agent.
73
-
74
  Args:
75
  prompt: The user prompt (current game state, history, etc.)
76
  system_prompt: The system prompt (instructions for the agent)
77
  seed: Random seed for reproducibility
78
  max_tokens: Maximum tokens in response (default: 300)
79
-
80
  Returns:
81
  The LLM's response text
82
-
83
- Example:
84
- response = call_llm(
85
- prompt="You are in a forest. What do you do?",
86
- system_prompt=SYSTEM_PROMPT,
87
- seed=42,
88
- )
89
  """
90
  messages = [
91
  {"role": "system", "content": system_prompt},
@@ -124,153 +104,396 @@ class RunResult:
124
  history: list[tuple[str, str, str]] = field(default_factory=list)
125
 
126
 
127
- # =============================================================================
128
- # System Prompt - Customize this for your agent
129
- # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- SYSTEM_PROMPT = """You are playing a classic text adventure game.
132
 
133
- GOAL: Explore the world, solve puzzles, and maximize your score.
134
 
135
- AVAILABLE TOOLS (use via MCP):
136
- - play_action: Execute a game command (north, take lamp, open mailbox, etc.)
137
- - memory: Get current game state and history (if implemented)
138
- - inventory: Check what you're carrying (if implemented)
139
 
140
- VALID GAME COMMANDS for play_action:
141
- - Movement: north, south, east, west, up, down, enter, exit
142
- - Objects: take <item>, drop <item>, open <thing>, close <thing>, examine <thing>
143
- - Other: look, inventory, read <thing>, turn on lamp
 
 
 
 
 
 
 
144
 
145
- RESPOND IN THIS EXACT FORMAT (no markdown):
146
- THOUGHT: <your reasoning about what to do next>
147
- TOOL: <tool_name>
148
- ARGS: <JSON arguments, e.g., {"action": "look"}>
149
 
150
  Example:
151
- THOUGHT: I should look around to see where I am.
152
  TOOL: play_action
153
- ARGS: {"action": "look"}
154
- """
155
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
- # =============================================================================
158
- # Student Agent - IMPLEMENT THIS CLASS
159
- # =============================================================================
160
 
161
  class StudentAgent:
162
- """
163
- Your ReAct agent implementation.
164
-
165
- TODO:
166
- 1. Implement the run() method with the ReAct loop
167
- 2. Parse LLM responses to extract tool calls
168
- 3. Track state and avoid loops
169
-
170
- Use the provided call_llm() function to interact with the LLM.
171
- """
172
-
173
  def __init__(self):
174
- """Initialize your agent here."""
175
- # TODO: Initialize any state tracking you need
176
- # self.history = []
177
- # self.visited_locations = set()
178
- pass
179
-
 
 
 
180
  async def run(
181
- self,
182
- client, # FastMCP Client connected to your MCP server
183
- game: str,
184
- max_steps: int,
185
- seed: int,
186
- verbose: bool = False,
187
  ) -> RunResult:
188
- """
189
- Run the agent for a game session.
190
-
191
- Args:
192
- client: FastMCP Client connected to your MCP server
193
- game: Name of the game being played (e.g., "zork1")
194
- max_steps: Maximum number of steps to take
195
- seed: Random seed for reproducibility (use for LLM calls)
196
- verbose: Whether to print detailed output
197
-
198
- Returns:
199
- RunResult with final score and statistics
200
- """
201
- # TODO: Implement your ReAct loop here
202
- #
203
- # Basic structure:
204
- # 1. Get initial observation (call play_action with "look")
205
- # 2. Loop for max_steps:
206
- # a. Build prompt with current observation and history
207
- # b. Call LLM to get thought and action
208
- # c. Parse the response to extract tool and args
209
- # d. Call the tool via client.call_tool(tool_name, args)
210
- # e. Update history and state
211
- # f. Check for game over
212
- # 3. Return RunResult with final statistics
213
-
214
- # Example of calling a tool:
215
- # result = await client.call_tool("play_action", {"action": "look"})
216
- # observation = result[0].text if result else "No response"
217
-
218
- # Example of calling the LLM:
219
- # response = call_llm(
220
- # prompt="Current observation: " + observation,
221
- # system_prompt=SYSTEM_PROMPT,
222
- # seed=seed,
223
- # )
224
-
225
- # Placeholder implementation - replace with your code
226
- locations_visited = set()
227
- history = []
228
- final_score = 0
229
- moves = 0
230
-
231
- # TODO: Your implementation here
232
- # ...
233
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  return RunResult(
235
- final_score=final_score,
236
- max_score=350, # Zork1 max score, adjust if needed
237
- moves=moves,
238
- locations_visited=locations_visited,
239
- game_completed=False,
240
- history=history,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  )
242
-
243
- def _build_prompt(self, observation: str, history: list) -> str:
244
- """
245
- Build the prompt for the LLM.
246
-
247
- TODO: Implement this to create effective prompts
248
- """
249
- # TODO: Combine system prompt, history, and current observation
250
- pass
251
-
252
- def _parse_response(self, response: str) -> tuple[str, str, dict]:
253
- """
254
- Parse LLM response to extract thought, tool name, and arguments.
255
-
256
- TODO: Implement robust parsing
257
-
258
- Returns:
259
- Tuple of (thought, tool_name, args_dict)
260
- """
261
- # TODO: Parse the response format:
262
- # THOUGHT: ...
263
- # TOOL: ...
264
- # ARGS: {...}
265
- pass
266
-
267
- def _call_llm(self, prompt: str, system_prompt: str, seed: int) -> str:
268
- """
269
- Call the LLM with the given prompt.
270
-
271
- This is a convenience wrapper - you can also use call_llm() directly.
272
- """
273
- return call_llm(prompt, system_prompt, seed)
274
 
275
 
276
  # =============================================================================
@@ -280,12 +503,10 @@ class StudentAgent:
280
  async def test_agent():
281
  """Test the agent locally."""
282
  from fastmcp import Client
283
-
284
- # Path to your MCP server
285
  server_path = "mcp_server.py"
286
-
287
  agent = StudentAgent()
288
-
289
  async with Client(server_path) as client:
290
  result = await agent.run(
291
  client=client,
@@ -294,7 +515,7 @@ async def test_agent():
294
  seed=42,
295
  verbose=True,
296
  )
297
-
298
  print(f"\nFinal Score: {result.final_score}")
299
  print(f"Moves: {result.moves}")
300
  print(f"Locations: {result.locations_visited}")
 
1
  """
2
+ MCP ReAct Agent - Enhanced Generalist
3
+
4
+ Key improvements over v6:
5
+ - Richer system prompt with strategy patterns for different game types
6
+ - Stuck detection + automatic recovery (suggest_exploration, try new verbs)
7
+ - Smarter history: shows failed actions to avoid repetition
8
+ - Exit registration from game text (auto-detects mentioned directions)
9
+ - Multi-phase play: explore collect solve backtrack
10
+ - Robust parsing with multiple fallback strategies
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
  import json
 
57
  def call_llm(prompt: str, system_prompt: str, seed: int, max_tokens: int = 300) -> str:
58
  """
59
  Call the LLM with the given prompt. Use this function in your agent.
60
+
61
  Args:
62
  prompt: The user prompt (current game state, history, etc.)
63
  system_prompt: The system prompt (instructions for the agent)
64
  seed: Random seed for reproducibility
65
  max_tokens: Maximum tokens in response (default: 300)
66
+
67
  Returns:
68
  The LLM's response text
 
 
 
 
 
 
 
69
  """
70
  messages = [
71
  {"role": "system", "content": system_prompt},
 
104
  history: list[tuple[str, str, str]] = field(default_factory=list)
105
 
106
 
107
+ # ─── System Prompt ─────────────────────────────────────────────────────────────
108
+ SYSTEM_PROMPT = """You are an expert text adventure game player. You are methodical, curious, and never give up.
109
+
110
+ AVAILABLE TOOLS:
111
+ - play_action: Send a command to the game.
112
+ ARGS: {"action": "your command"}
113
+ For movement use direction words: north, south, east, west, up, down, in, out, ne, nw, se, sw
114
+ For interactions: examine <thing>, take <item>, drop <item>, open <thing>, close <thing>,
115
+ read <thing>, push <thing>, pull <thing>, turn <thing>, light <thing>, put <item> in <container>,
116
+ unlock <door> with <key>, give <item> to <npc>, attack <enemy> with <weapon>, tie <item> to <thing>,
117
+ climb <thing>, enter <thing>, search <thing>, listen, smell, wave <item>, eat <item>, drink <item>
118
+
119
+ - think: Plan your strategy. ARGS: {"goal": "...", "thought": "..."}
120
+
121
+ - notebook_write: Save clues, codes, puzzle info permanently.
122
+ ARGS: {"text": "...", "category": "Clue|Puzzle|Item|Danger|NPC|Code|Goal|Map"}
123
+
124
+ - notebook_read: Read your saved notes. ARGS: {"keyword": "optional filter"}
125
+
126
+ - memory: Full status dump (location, inventory, notes, map). ARGS: {}
127
+
128
+ - get_map: View explored map and unexplored exits. ARGS: {}
129
 
130
+ - find_path: Get directions to a known room. ARGS: {"target_room": "room name"}
131
 
132
+ - suggest_exploration: Get suggestion for nearest unexplored area. ARGS: {}
133
 
134
+ - register_exits: Record exits visible in current room.
135
+ ARGS: {"directions": "north, south, up"}
 
 
136
 
137
+ STRATEGY How to play well:
138
+ 1. EXPLORE SYSTEMATICALLY: When you enter a new room, ALWAYS do "look" first, then register visible exits with register_exits. Explore every exit.
139
+ 2. EXAMINE EVERYTHING: If the game describes objects, furniture, or features — examine them. Things hide under rugs, inside containers, behind paintings.
140
+ 3. TAKE EVERYTHING: Collect all portable items. You'll need them later for puzzles.
141
+ 4. READ CAREFULLY: The game text contains ALL clues. Unusual descriptions often hint at puzzles.
142
+ 5. SAVE CLUES: If you notice a code, inscription, locked door, NPC request, or puzzle — write it in notebook_write immediately.
143
+ 6. DON'T REPEAT FAILURES: Check your recent history. If a command didn't work, try a DIFFERENT approach. Use synonyms: get/take, look/examine, push/move.
144
+ 7. BACKTRACK SMARTLY: If stuck, call suggest_exploration to find unexplored exits, or find_path to return to a room with unsolved puzzles.
145
+ 8. USE ITEMS: When you have items and encounter obstacles, think about which item might help. Try "use X", "put X in Y", "unlock Y with X".
146
+ 9. LISTEN AND SEARCH: "listen", "search", "look under X", "look behind X" often reveal hidden things.
147
+ 10. CHECK SCORE: If your score increases, you're making progress. If not for a while, try a new area.
148
 
149
+ RESPONSE FORMAT (strict):
150
+ THOUGHT: <brief reasoning about what you observe and your plan>
151
+ TOOL: <exactly one tool name>
152
+ ARGS: <valid JSON for that tool>
153
 
154
  Example:
155
+ THOUGHT: I see a rusty door to the north and a brass lamp on the ground. I should take the lamp first.
156
  TOOL: play_action
157
+ ARGS: {"action": "take lamp"}"""
158
+
159
+
160
+ # ─── Directions mentioned in text ──────────────────────────────────────────────
161
+ EXIT_PATTERN = re.compile(
162
+ r"\b(north|south|east|west|up|down|northeast|northwest|southeast|southwest)\b",
163
+ re.IGNORECASE,
164
+ )
165
+
166
+ DIRECTION_SET = {
167
+ "n",
168
+ "s",
169
+ "e",
170
+ "w",
171
+ "u",
172
+ "d",
173
+ "ne",
174
+ "nw",
175
+ "se",
176
+ "sw",
177
+ "north",
178
+ "south",
179
+ "east",
180
+ "west",
181
+ "up",
182
+ "down",
183
+ "northeast",
184
+ "northwest",
185
+ "southeast",
186
+ "southwest",
187
+ "in",
188
+ "out",
189
+ "enter",
190
+ "exit",
191
+ }
192
 
 
 
 
193
 
194
  class StudentAgent:
 
 
 
 
 
 
 
 
 
 
 
195
  def __init__(self):
196
+ self.history: list[dict] = []
197
+ self.score: int = 0
198
+ self.max_score: int = 0
199
+ self.location: str = "Unknown"
200
+ self.locations_visited: set[str] = set()
201
+ self.failed_actions: set[str] = set() # track "location:action" that failed
202
+ self.consecutive_no_score: int = 0
203
+ self.last_score: int = 0
204
+
205
  async def run(
206
+ self, client, game: str, max_steps: int, seed: int, verbose: bool = False
 
 
 
 
 
207
  ) -> RunResult:
208
+ tools = await client.list_tools()
209
+ tool_names = [t.name for t in tools]
210
+
211
+ # Initial look
212
+ result = await client.call_tool("play_action", {"action": "look"})
213
+ observation = self._extract_result(result)
214
+ self._update_state(observation)
215
+
216
+ # Register initial exits
217
+ exits = self._detect_exits(observation)
218
+ if exits:
219
+ try:
220
+ await client.call_tool(
221
+ "register_exits", {"directions": ", ".join(exits)}
222
+ )
223
+ except Exception:
224
+ pass
225
+
226
+ if verbose:
227
+ print(f"\n{'=' * 60}\nINITIAL OBSERVATION:\n{observation}\n{'=' * 60}")
228
+
229
+ step = 0
230
+ for step in range(1, max_steps + 1):
231
+ prompt = self._build_prompt(observation, step)
232
+ response = call_llm(prompt, SYSTEM_PROMPT, seed + step, max_tokens=400)
233
+ thought, tool_name, tool_args = self._parse_response(response, tool_names)
234
+
235
+ if verbose:
236
+ print(f"\n--- Step {step} ---")
237
+ print(f" THOUGHT: {thought}")
238
+ print(f" TOOL: {tool_name}({json.dumps(tool_args)})")
239
+
240
+ try:
241
+ result = await client.call_tool(tool_name, tool_args)
242
+ observation = self._extract_result(result)
243
+ except Exception as e:
244
+ observation = f"Error: {e}"
245
+
246
+ if verbose:
247
+ obs_preview = observation[:400].replace("\n", "\n ")
248
+ print(f" RESULT: {obs_preview}")
249
+
250
+ self._update_state(observation)
251
+
252
+ # Auto-register exits when we get a play_action result
253
+ if tool_name == "play_action":
254
+ exits = self._detect_exits(observation)
255
+ if exits:
256
+ try:
257
+ await client.call_tool(
258
+ "register_exits", {"directions": ", ".join(exits)}
259
+ )
260
+ except Exception:
261
+ pass
262
+
263
+ # Track failed movement
264
+ action = tool_args.get("action", "").lower()
265
+ if self._is_failure(observation):
266
+ self.failed_actions.add(f"{self.location}:{action}")
267
+
268
+ # Track score progress
269
+ if self.score > self.last_score:
270
+ self.consecutive_no_score = 0
271
+ self.last_score = self.score
272
+ else:
273
+ self.consecutive_no_score += 1
274
+
275
+ self.history.append(
276
+ {
277
+ "step": step,
278
+ "thought": thought,
279
+ "tool": tool_name,
280
+ "args": tool_args,
281
+ "result": observation[:200],
282
+ "location": self.location,
283
+ "score": self.score,
284
+ }
285
+ )
286
+
287
+ if self._is_game_over(observation):
288
+ break
289
+
290
  return RunResult(
291
+ final_score=self.score,
292
+ max_score=self.max_score,
293
+ moves=step,
294
+ locations_visited=self.locations_visited,
295
+ game_completed=self._is_game_over(observation),
296
+ error=None,
297
+ history=[
298
+ (h["tool"], json.dumps(h["args"]), h["result"]) for h in self.history
299
+ ],
300
+ )
301
+
302
+ def _build_prompt(self, observation: str, step: int) -> str:
303
+ parts = []
304
+
305
+ # Status line
306
+ parts.append(
307
+ f"[Step {step} | Score: {self.score}/{self.max_score} | "
308
+ f"Location: {self.location} | Rooms visited: {len(self.locations_visited)}]"
309
+ )
310
+
311
+ # Recent history (last 7 for better context)
312
+ if self.history:
313
+ parts.append("\nRecent history:")
314
+ for h in self.history[-7:]:
315
+ action_str = json.dumps(h["args"])
316
+ loc = h.get("location", "?")
317
+ result_short = h["result"].replace("\n", " ")[:80]
318
+ parts.append(f" [{loc}] {h['tool']}({action_str}) -> {result_short}")
319
+
320
+ # Failed actions at current location (helps avoid repetition)
321
+ loc_failures = [
322
+ a.split(":", 1)[1]
323
+ for a in self.failed_actions
324
+ if a.startswith(f"{self.location}:")
325
+ ]
326
+ if loc_failures:
327
+ parts.append(f"\nActions that FAILED here: {', '.join(loc_failures)}")
328
+
329
+ # Stuck hint
330
+ if self.consecutive_no_score > 8:
331
+ parts.append(
332
+ "\n[HINT: Score hasn't changed in a while. Consider: "
333
+ "call suggest_exploration, check memory, examine objects more carefully, "
334
+ "or try using inventory items on things you've seen.]"
335
+ )
336
+
337
+ # Current game output
338
+ parts.append(f"\nGame output:\n{observation}")
339
+ parts.append("\nWhat do you do next?")
340
+
341
+ return "\n".join(parts)
342
+
343
+ def _parse_response(
344
+ self, response: str, valid_tools: list[str]
345
+ ) -> tuple[str, str, dict]:
346
+ thought = "..."
347
+ tool_name = "play_action"
348
+ tool_args = {"action": "look"}
349
+
350
+ lines = response.split("\n")
351
+ args_lines = []
352
+ collecting_args = False
353
+
354
+ for line in lines:
355
+ clean = line.strip()
356
+ up = clean.upper()
357
+
358
+ if up.startswith("THOUGHT:"):
359
+ thought = clean.split(":", 1)[1].strip()
360
+ collecting_args = False
361
+ elif up.startswith("TOOL:"):
362
+ raw_tool = clean.split(":", 1)[1].strip().lower().strip("`").strip()
363
+ # Handle common LLM mistakes
364
+ raw_tool = raw_tool.replace(" ", "_")
365
+ if raw_tool in valid_tools:
366
+ tool_name = raw_tool
367
+ elif "play" in raw_tool or "action" in raw_tool:
368
+ tool_name = "play_action"
369
+ elif "note" in raw_tool and "write" in raw_tool:
370
+ tool_name = "notebook_write"
371
+ elif "note" in raw_tool and "read" in raw_tool:
372
+ tool_name = "notebook_read"
373
+ elif "note" in raw_tool:
374
+ tool_name = "notebook_write"
375
+ elif "map" in raw_tool:
376
+ tool_name = "get_map"
377
+ elif "path" in raw_tool:
378
+ tool_name = "find_path"
379
+ elif "suggest" in raw_tool or "explor" in raw_tool:
380
+ tool_name = "suggest_exploration"
381
+ elif "register" in raw_tool or "exit" in raw_tool:
382
+ tool_name = "register_exits"
383
+ collecting_args = False
384
+ elif up.startswith("ARGS:"):
385
+ raw = clean.split(":", 1)[1].strip()
386
+ args_lines = [raw]
387
+ collecting_args = True
388
+ elif collecting_args and clean:
389
+ args_lines.append(clean)
390
+
391
+ # Parse ARGS
392
+ if args_lines:
393
+ raw_args = " ".join(args_lines)
394
+ # Try direct JSON parse
395
+ try:
396
+ tool_args = json.loads(raw_args)
397
+ except json.JSONDecodeError:
398
+ # Try extracting JSON object
399
+ m = re.search(r"\{[^{}]+\}", raw_args)
400
+ if m:
401
+ try:
402
+ tool_args = json.loads(m.group())
403
+ except json.JSONDecodeError:
404
+ pass
405
+ # Fallback: try extracting action string
406
+ if tool_name == "play_action":
407
+ m = re.search(r'"action"\s*:\s*"([^"]+)"', raw_args)
408
+ if m:
409
+ tool_args = {"action": m.group(1)}
410
+
411
+ # ─── Fix play_action args ───
412
+ if tool_name == "play_action":
413
+ action = str(tool_args.get("action", "")).strip()
414
+ # Merge split args (action + target/object)
415
+ for extra_key in ("target", "object", "item", "direction"):
416
+ extra = str(tool_args.get(extra_key, "")).strip()
417
+ if extra and extra.lower() not in action.lower():
418
+ action = f"{action} {extra}".strip()
419
+
420
+ # Strip "go " prefix for bare directions
421
+ if action.lower().startswith("go "):
422
+ rest = action[3:].strip().lower()
423
+ if rest in DIRECTION_SET:
424
+ action = rest
425
+
426
+ tool_args = {"action": action or "look"}
427
+
428
+ # ─── Fix find_path args ───
429
+ if tool_name == "find_path":
430
+ # Normalize: the tool expects "target_room" not "to" or "room"
431
+ for key in ("to", "room", "destination", "target"):
432
+ if key in tool_args and "target_room" not in tool_args:
433
+ tool_args["target_room"] = tool_args.pop(key)
434
+
435
+ # Final validation
436
+ if tool_name not in valid_tools:
437
+ tool_name = "play_action"
438
+ if "action" not in tool_args:
439
+ tool_args = {"action": "look"}
440
+
441
+ return thought, tool_name, tool_args
442
+
443
+ def _extract_result(self, result) -> str:
444
+ if hasattr(result, "content") and result.content:
445
+ return result.content[0].text
446
+ return str(result)
447
+
448
+ def _update_state(self, text: str):
449
+ m = re.search(r"Score:\s*(\d+)/(\d+)", text, re.IGNORECASE)
450
+ if m:
451
+ self.score = int(m.group(1))
452
+ self.max_score = int(m.group(2))
453
+ m_loc = re.search(r"\[Location:\s*([^|\]]+)", text)
454
+ if m_loc:
455
+ loc = m_loc.group(1).strip()
456
+ if loc and loc != "Unknown":
457
+ self.location = loc
458
+ self.locations_visited.add(loc)
459
+
460
+ def _detect_exits(self, text: str) -> list[str]:
461
+ """Extract direction words mentioned in game text."""
462
+ return list(set(EXIT_PATTERN.findall(text.lower())))
463
+
464
+ def _is_failure(self, text: str) -> bool:
465
+ """Detect if the game rejected our action."""
466
+ fail_phrases = [
467
+ "you can't go",
468
+ "you can't do",
469
+ "i don't understand",
470
+ "that's not a verb",
471
+ "you don't see",
472
+ "you can't see",
473
+ "there's no",
474
+ "you can't",
475
+ "nothing happens",
476
+ "is locked",
477
+ "is closed",
478
+ "won't budge",
479
+ "doesn't seem to",
480
+ "you aren't",
481
+ ]
482
+ lower = text.lower()
483
+ return any(f in lower for f in fail_phrases)
484
+
485
+ def _is_game_over(self, text: str) -> bool:
486
+ return any(
487
+ x in text.lower()
488
+ for x in [
489
+ "*** you have died ***",
490
+ "*** you have won ***",
491
+ "game over",
492
+ "you have won",
493
+ "you have died",
494
+ "would you like to restart",
495
+ ]
496
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
 
498
 
499
  # =============================================================================
 
503
  async def test_agent():
504
  """Test the agent locally."""
505
  from fastmcp import Client
506
+
 
507
  server_path = "mcp_server.py"
 
508
  agent = StudentAgent()
509
+
510
  async with Client(server_path) as client:
511
  result = await agent.run(
512
  client=client,
 
515
  seed=42,
516
  verbose=True,
517
  )
518
+
519
  print(f"\nFinal Score: {result.final_score}")
520
  print(f"Moves: {result.moves}")
521
  print(f"Locations: {result.locations_visited}")
mcp_server.py CHANGED
@@ -1,209 +1,478 @@
1
  """
2
- Student MCP Server for Text Adventure Games
3
-
4
- This is your MCP server submission. Implement the tools that your agent
5
- will use to play text adventure games.
6
-
7
- Required tool:
8
- play_action(action: str) -> str
9
- Execute a game command and return the result.
10
-
11
- Recommended tools:
12
- memory() -> str
13
- Return current game state, score, and recent history.
14
-
15
- inventory() -> str
16
- Return the player's current inventory.
17
-
18
- get_map() -> str
19
- Return a map of explored locations.
20
-
21
- Test your server with:
22
- fastmcp dev submission_template/mcp_server.py
23
-
24
- Then open the MCP Inspector in your browser to test the tools interactively.
25
  """
26
 
 
27
  import sys
28
  import os
 
 
29
 
30
- # Add parent directory to path to import games module
31
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
32
-
33
- from fastmcp import FastMCP
34
  from games.zork_env import TextAdventureEnv
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- # =============================================================================
38
- # Create the MCP Server
39
- # =============================================================================
40
-
41
- mcp = FastMCP("Student Text Adventure Server")
42
-
43
-
44
- # =============================================================================
45
- # Game State Management
46
- # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- class GameManager:
49
- """
50
- Manages the text adventure game state.
51
-
52
- TODO: Extend this class to track:
53
- - Action history (for memory tool)
54
- - Explored locations (for mapping)
55
- - Current score and moves
56
- """
57
-
58
  def __init__(self):
59
- self.env: TextAdventureEnv = None
60
- self.state = None
61
- self.game_name: str = ""
62
- # TODO: Add more state tracking
63
- # self.history: list[tuple[str, str]] = []
64
- # self.explored_locations: dict[str, set[str]] = {}
65
- # self.current_location: str = ""
66
-
67
- def initialize(self, game: str = "zork1"):
68
- """Initialize or reset the game."""
69
- self.game_name = game
70
- self.env = TextAdventureEnv(game)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  self.state = self.env.reset()
72
- # TODO: Reset your state tracking here
73
- return self.state.observation
74
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def step(self, action: str) -> str:
76
- """Execute an action and return the result."""
77
- if self.env is None:
78
- self.initialize()
79
-
80
- self.state = self.env.step(action)
81
-
82
- # TODO: Update your state tracking here
83
- # self.history.append((action, self.state.observation))
84
- # Update location tracking, etc.
85
-
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  return self.state.observation
87
-
88
- def get_score(self) -> int:
89
- """Get current score."""
90
- return self.state.score if self.state else 0
91
-
92
- def get_moves(self) -> int:
93
- """Get number of moves taken."""
94
- return self.state.moves if self.state else 0
 
 
 
 
 
 
 
 
 
 
95
 
96
 
97
- # Global game manager
98
- _game = GameManager()
99
 
100
 
101
- def get_game() -> GameManager:
102
- """Get or initialize the game manager."""
103
  global _game
104
- if _game.env is None:
105
- # Get game from environment variable (set by evaluator)
106
- game = os.environ.get("GAME", "zork1")
107
- _game.initialize(game)
108
  return _game
109
 
110
 
111
  # =============================================================================
112
- # MCP Tools - IMPLEMENT THESE
113
  # =============================================================================
114
 
 
115
  @mcp.tool()
116
  def play_action(action: str) -> str:
117
- """
118
- Execute a game command and return the result.
119
-
120
- This is the main tool for interacting with the game.
121
-
122
- Args:
123
- action: The command to execute (e.g., "north", "take lamp", "open mailbox")
124
-
125
- Returns:
126
- The game's response to the action
127
-
128
- Valid commands include:
129
- - Movement: north, south, east, west, up, down, enter, exit
130
- - Objects: take <item>, drop <item>, open <thing>, examine <thing>
131
- - Other: look, inventory, read <thing>, turn on lamp
132
- """
133
- game = get_game()
134
-
135
- # TODO: You might want to add action validation here
136
- # TODO: You might want to include score changes in the response
137
-
138
- result = game.step(action)
139
-
140
- # Optional: Append score info
141
- # result += f"\n[Score: {game.get_score()} | Moves: {game.get_moves()}]"
142
-
143
- return result
144
-
145
-
146
- # TODO: Implement additional tools to help your agent
147
-
148
- # @mcp.tool()
149
- # def memory() -> str:
150
- # """
151
- # Get the current game state summary.
152
- #
153
- # Returns:
154
- # A summary including current location, score, moves, and recent history
155
- # """
156
- # game = get_game()
157
- # # TODO: Return useful state information
158
- # pass
159
-
160
-
161
- # @mcp.tool()
162
- # def inventory() -> str:
163
- # """
164
- # Check what the player is carrying.
165
- #
166
- # Returns:
167
- # List of items in the player's inventory
168
- # """
169
- # game = get_game()
170
- # result = game.step("inventory")
171
- # return result
172
-
173
-
174
- # @mcp.tool()
175
- # def get_map() -> str:
176
- # """
177
- # Get a map of explored locations.
178
- #
179
- # Returns:
180
- # A text representation of explored locations and connections
181
- # """
182
- # game = get_game()
183
- # # TODO: Return map of explored locations
184
- # pass
185
-
186
-
187
- # @mcp.tool()
188
- # def get_valid_actions() -> str:
189
- # """
190
- # Get a list of likely valid actions from the current location.
191
- #
192
- # Returns:
193
- # List of actions that might work here
194
- # """
195
- # # This is a hint: Jericho provides get_valid_actions()
196
- # game = get_game()
197
- # if game.env and game.env.env:
198
- # valid = game.env.env.get_valid_actions()
199
- # return "Valid actions: " + ", ".join(valid[:20])
200
- # return "Could not determine valid actions"
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- # =============================================================================
204
- # Run the server
205
- # =============================================================================
206
 
207
  if __name__ == "__main__":
208
- # This runs the server with stdio transport (for MCP clients)
209
  mcp.run()
 
1
  """
2
+ MCP Server - Enhanced Generalist Agent
3
+ Features:
4
+ - Graph-based mapping with BFS pathfinding
5
+ - Structured notebook (clues, puzzles, NPCs, dangers)
6
+ - Explicit inventory tracking
7
+ - Unexplored exit tracking for systematic exploration
8
+ - Stuck detection helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
+ import re
12
  import sys
13
  import os
14
+ from collections import deque
15
+ from fastmcp import FastMCP
16
 
 
17
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
 
18
  from games.zork_env import TextAdventureEnv
19
 
20
+ INITIAL_GAME = os.environ.get("GAME", "zork1")
21
+ mcp = FastMCP("Text Adventure Server")
22
+
23
+ # All recognized movement directions
24
+ DIRECTIONS = {
25
+ "n",
26
+ "s",
27
+ "e",
28
+ "w",
29
+ "u",
30
+ "d",
31
+ "ne",
32
+ "nw",
33
+ "se",
34
+ "sw",
35
+ "north",
36
+ "south",
37
+ "east",
38
+ "west",
39
+ "up",
40
+ "down",
41
+ "northeast",
42
+ "northwest",
43
+ "southeast",
44
+ "southwest",
45
+ "in",
46
+ "out",
47
+ "enter",
48
+ "exit",
49
+ }
50
+
51
+ # Canonical direction mapping for consistency in the graph
52
+ DIR_CANONICAL = {
53
+ "n": "north",
54
+ "s": "south",
55
+ "e": "east",
56
+ "w": "west",
57
+ "u": "up",
58
+ "d": "down",
59
+ "ne": "northeast",
60
+ "nw": "northwest",
61
+ "se": "southeast",
62
+ "sw": "southwest",
63
+ }
64
+
65
+ OPPOSITE_DIR = {
66
+ "north": "south",
67
+ "south": "north",
68
+ "east": "west",
69
+ "west": "east",
70
+ "up": "down",
71
+ "down": "up",
72
+ "northeast": "southwest",
73
+ "southwest": "northeast",
74
+ "northwest": "southeast",
75
+ "southeast": "northwest",
76
+ "in": "out",
77
+ "out": "in",
78
+ "enter": "exit",
79
+ "exit": "enter",
80
+ }
81
+
82
+
83
+ def canonicalize(direction: str) -> str:
84
+ d = direction.lower().strip()
85
+ return DIR_CANONICAL.get(d, d)
86
+
87
+
88
+ class WorldMap:
89
+ """Graph-based world map with BFS pathfinding."""
90
 
91
+ def __init__(self):
92
+ # room_name -> {canonical_direction: destination_room_name}
93
+ self.graph: dict[str, dict[str, str]] = {}
94
+ # room_name -> brief description
95
+ self.room_info: dict[str, str] = {}
96
+ # room_name -> set of canonical directions mentioned but not yet taken
97
+ self.known_exits: dict[str, set[str]] = {}
98
+
99
+ def ensure_room(self, name: str):
100
+ if name and name != "Unknown":
101
+ if name not in self.graph:
102
+ self.graph[name] = {}
103
+ if name not in self.known_exits:
104
+ self.known_exits[name] = set()
105
+
106
+ def record_move(self, from_room: str, direction: str, to_room: str):
107
+ """Record a successful movement between rooms."""
108
+ d = canonicalize(direction)
109
+ self.ensure_room(from_room)
110
+ self.ensure_room(to_room)
111
+
112
+ if from_room != to_room and from_room != "Unknown" and to_room != "Unknown":
113
+ self.graph[from_room][d] = to_room
114
+ # Record reverse edge
115
+ opp = OPPOSITE_DIR.get(d)
116
+ if opp:
117
+ self.graph[to_room][opp] = from_room
118
+ # Remove from unexplored
119
+ self.known_exits.get(from_room, set()).discard(d)
120
+
121
+ def record_blocked(self, room: str, direction: str):
122
+ """Record that a direction is blocked/doesn't work from a room."""
123
+ d = canonicalize(direction)
124
+ self.ensure_room(room)
125
+ self.graph[room][d] = "[BLOCKED]"
126
+ self.known_exits.get(room, set()).discard(d)
127
+
128
+ def register_exits(self, room: str, directions: list[str]):
129
+ """Register exits mentioned in room description that we haven't explored yet."""
130
+ self.ensure_room(room)
131
+ for d in directions:
132
+ cd = canonicalize(d)
133
+ if cd not in self.graph.get(room, {}):
134
+ self.known_exits[room].add(cd)
135
+
136
+ def set_room_info(self, room: str, info: str):
137
+ self.room_info[room] = info[:200]
138
+
139
+ def find_path(self, start: str, end: str) -> list[str] | None:
140
+ """BFS shortest path. Returns list of directions, or None if no path."""
141
+ if start == end:
142
+ return []
143
+ if start not in self.graph or end not in self.graph:
144
+ return None
145
+
146
+ queue = deque([(start, [])])
147
+ visited = {start}
148
+
149
+ while queue:
150
+ current, path = queue.popleft()
151
+ for direction, neighbor in self.graph.get(current, {}).items():
152
+ if neighbor == "[BLOCKED]" or neighbor in visited:
153
+ continue
154
+ if neighbor == end:
155
+ return path + [direction]
156
+ visited.add(neighbor)
157
+ queue.append((neighbor, path + [direction]))
158
+ return None
159
+
160
+ def get_unexplored(self) -> list[tuple[str, str]]:
161
+ """Get all (room, direction) pairs that are known but unexplored."""
162
+ result = []
163
+ for room, dirs in self.known_exits.items():
164
+ for d in dirs:
165
+ result.append((room, d))
166
+ return result
167
+
168
+ def get_nearest_unexplored(self, current: str) -> tuple[list[str], str] | None:
169
+ """Find the nearest unexplored exit from current position.
170
+ Returns (path_to_room, unexplored_direction) or None."""
171
+ unexplored = self.get_unexplored()
172
+ if not unexplored:
173
+ return None
174
+
175
+ best = None
176
+ best_len = float("inf")
177
+ for room, direction in unexplored:
178
+ if room == current:
179
+ return ([], direction)
180
+ path = self.find_path(current, room)
181
+ if path is not None and len(path) < best_len:
182
+ best = (path, direction)
183
+ best_len = len(path)
184
+ return best
185
+
186
+ def to_text(self, current: str = "") -> str:
187
+ if not self.graph:
188
+ return "Map is empty — no paths recorded yet."
189
+ lines = []
190
+ for room in sorted(self.graph.keys()):
191
+ exits = self.graph[room]
192
+ marker = " << YOU ARE HERE" if room == current else ""
193
+ exit_parts = []
194
+ for d, dest in sorted(exits.items()):
195
+ if dest == "[BLOCKED]":
196
+ exit_parts.append(f"{d}:BLOCKED")
197
+ else:
198
+ exit_parts.append(f"{d}->{dest}")
199
+ unexplored = self.known_exits.get(room, set())
200
+ for d in sorted(unexplored):
201
+ exit_parts.append(f"{d}:???")
202
+ exits_str = ", ".join(exit_parts) if exit_parts else "no known exits"
203
+ lines.append(f" [{room}]{marker}: {exits_str}")
204
+
205
+ return "Known Map:\n" + "\n".join(lines)
206
+
207
+
208
+ class Notebook:
209
+ """Structured notebook for clues, items, puzzles, etc."""
210
 
 
 
 
 
 
 
 
 
 
 
211
  def __init__(self):
212
+ self.entries: list[dict] = []
213
+
214
+ def add(self, text: str, category: str = "General") -> str:
215
+ cat = category.upper().strip()
216
+ entry = {"category": cat, "text": text}
217
+ # Avoid exact duplicates
218
+ for e in self.entries:
219
+ if e["text"] == text and e["category"] == cat:
220
+ return f"(Already noted: {text})"
221
+ self.entries.append(entry)
222
+ return f"Noted [{cat}]: {text} (Total: {len(self.entries)} entries)"
223
+
224
+ def to_text(self) -> str:
225
+ if not self.entries:
226
+ return "Notebook is empty."
227
+ lines = []
228
+ for e in self.entries:
229
+ lines.append(f" [{e['category']}] {e['text']}")
230
+ return "\n".join(lines)
231
+
232
+ def search(self, keyword: str) -> str:
233
+ kw = keyword.lower()
234
+ matches = [
235
+ e
236
+ for e in self.entries
237
+ if kw in e["text"].lower() or kw in e["category"].lower()
238
+ ]
239
+ if not matches:
240
+ return f"No notes matching '{keyword}'."
241
+ return "\n".join(f" [{e['category']}] {e['text']}" for e in matches)
242
+
243
+
244
+ class GameState:
245
+ def __init__(self, game_name: str):
246
+ self.env = TextAdventureEnv(game_name)
247
  self.state = self.env.reset()
248
+
249
+ self.world_map = WorldMap()
250
+ self.notebook = Notebook()
251
+ self.current_location = "Unknown"
252
+ self._update_location(self.state.location)
253
+ self.world_map.ensure_room(self.current_location)
254
+
255
+ self.current_goal = (
256
+ "Explore the environment, collect useful items, and increase score."
257
+ )
258
+ self.plan = ""
259
+ self.action_history: list[str] = [] # last N actions for stuck detection
260
+
261
+ def _clean_location_name(self, raw: str) -> str:
262
+ if not raw:
263
+ return "Unknown"
264
+ if ":" in raw:
265
+ raw = raw.split(":", 1)[1].strip()
266
+ raw = re.sub(
267
+ r"\s*(Parent\d+|Sibling\d+|Child\d+|Attributes\s*\[.*?\]|Properties\s*\[.*?\]).*",
268
+ "",
269
+ raw,
270
+ )
271
+ return raw.strip() or "Unknown"
272
+
273
+ def _update_location(self, raw_loc: str):
274
+ self.current_location = self._clean_location_name(raw_loc)
275
+
276
  def step(self, action: str) -> str:
277
+ prev_loc = self.current_location
278
+ action_clean = action.strip()
279
+
280
+ self.state = self.env.step(action_clean)
281
+ self._update_location(self.state.location)
282
+
283
+ # Track action history for stuck detection
284
+ self.action_history.append(action_clean.lower())
285
+ if len(self.action_history) > 20:
286
+ self.action_history = self.action_history[-20:]
287
+
288
+ # Determine if this was a movement command
289
+ action_lower = action_clean.lower()
290
+ is_move = action_lower in DIRECTIONS
291
+
292
+ if is_move:
293
+ if prev_loc != self.current_location and prev_loc != "Unknown":
294
+ self.world_map.record_move(
295
+ prev_loc, action_lower, self.current_location
296
+ )
297
+ elif prev_loc == self.current_location and prev_loc != "Unknown":
298
+ self.world_map.record_blocked(prev_loc, action_lower)
299
+
300
  return self.state.observation
301
+
302
+ def get_inventory_text(self) -> str:
303
+ if hasattr(self.state, "inventory") and self.state.inventory:
304
+ items = self.state.inventory
305
+ if isinstance(items, list):
306
+ return ", ".join(items) if items else "empty-handed"
307
+ return str(items)
308
+ return "(unknown try 'inventory' command)"
309
+
310
+ def is_stuck(self) -> bool:
311
+ """Detect if agent is looping: same action repeated 3+ times in last 6."""
312
+ if len(self.action_history) < 6:
313
+ return False
314
+ recent = self.action_history[-6:]
315
+ from collections import Counter
316
+
317
+ counts = Counter(recent)
318
+ return counts.most_common(1)[0][1] >= 3
319
 
320
 
321
+ _game: GameState | None = None
 
322
 
323
 
324
+ def get_game() -> GameState:
 
325
  global _game
326
+ if _game is None:
327
+ _game = GameState(INITIAL_GAME)
 
 
328
  return _game
329
 
330
 
331
  # =============================================================================
332
+ # TOOLS
333
  # =============================================================================
334
 
335
+
336
  @mcp.tool()
337
  def play_action(action: str) -> str:
338
+ """Execute a game command. Examples: 'north', 'take lamp', 'examine rug', 'open door', 'inventory'.
339
+ For directions, just use: north/south/east/west/up/down/in/out/ne/nw/se/sw.
340
+ Returns the game's response plus your current location and score."""
341
+ g = get_game()
342
+ obs = g.step(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
+ # Build response with structured metadata
345
+ parts = [obs]
346
+ parts.append(
347
+ f"\n[Location: {g.current_location} | Score: {g.state.score}/{g.state.max_score}]"
348
+ )
349
+
350
+ # If stuck, add a gentle nudge
351
+ if g.is_stuck():
352
+ parts.append(
353
+ "[WARNING: You seem to be repeating actions. Try something different!]"
354
+ )
355
+
356
+ return "\n".join(parts)
357
+
358
+
359
+ @mcp.tool()
360
+ def think(goal: str, thought: str) -> str:
361
+ """Plan your strategy. Update your current goal and reasoning.
362
+ Use this to organize what you want to accomplish and why."""
363
+ g = get_game()
364
+ g.current_goal = goal
365
+ g.plan = thought
366
+ return f"Goal updated: {goal}\nPlan: {thought}\nLocation: {g.current_location}"
367
+
368
+
369
+ @mcp.tool()
370
+ def notebook_write(text: str, category: str = "Clue") -> str:
371
+ """Save important information to your permanent notebook.
372
+ Categories: Clue, Puzzle, Item, Danger, NPC, Code, Goal, Map.
373
+ Use this to remember puzzle hints, codes, locked doors, NPC dialogue, etc."""
374
+ g = get_game()
375
+ return g.notebook.add(text, category)
376
+
377
+
378
+ @mcp.tool()
379
+ def notebook_read(keyword: str = "") -> str:
380
+ """Read your notebook. Optionally filter by keyword or category.
381
+ Call with no keyword to see everything."""
382
+ g = get_game()
383
+ if keyword:
384
+ return g.notebook.search(keyword)
385
+ return g.notebook.to_text()
386
+
387
+
388
+ @mcp.tool()
389
+ def memory() -> str:
390
+ """Get a full status dump: location, goal, inventory, notebook, and map."""
391
+ g = get_game()
392
+ return f"""=== STATUS ===
393
+ Location: {g.current_location}
394
+ Score: {g.state.score}/{g.state.max_score}
395
+ Goal: {g.current_goal}
396
+ Plan: {g.plan}
397
+
398
+ === INVENTORY ===
399
+ {g.get_inventory_text()}
400
+
401
+ === NOTEBOOK ({len(g.notebook.entries)} entries) ===
402
+ {g.notebook.to_text()}
403
+
404
+ === MAP ===
405
+ {g.world_map.to_text(g.current_location)}
406
+ """
407
+
408
+
409
+ @mcp.tool()
410
+ def get_map() -> str:
411
+ """View your explored map with all known connections and unexplored exits."""
412
+ g = get_game()
413
+ txt = g.world_map.to_text(g.current_location)
414
+
415
+ unexplored = g.world_map.get_unexplored()
416
+ if unexplored:
417
+ txt += f"\n\nUnexplored exits ({len(unexplored)}):"
418
+ for room, d in unexplored:
419
+ txt += f"\n {room} -> {d} (not yet visited)"
420
+
421
+ return txt
422
+
423
+
424
+ @mcp.tool()
425
+ def find_path(target_room: str) -> str:
426
+ """Find the shortest path from your current location to a target room.
427
+ Returns step-by-step directions, or says if no path is known."""
428
+ g = get_game()
429
+ path = g.world_map.find_path(g.current_location, target_room)
430
+ if path is None:
431
+ # Try fuzzy match
432
+ for room in g.world_map.graph:
433
+ if target_room.lower() in room.lower():
434
+ path = g.world_map.find_path(g.current_location, room)
435
+ if path is not None:
436
+ target_room = room
437
+ break
438
+ if path is None:
439
+ return f"No known path from '{g.current_location}' to '{target_room}'. You may need to explore more."
440
+ if not path:
441
+ return f"You are already at '{target_room}'!"
442
+ return f"Path to '{target_room}': {' -> '.join(path)} ({len(path)} steps)"
443
+
444
+
445
+ @mcp.tool()
446
+ def suggest_exploration() -> str:
447
+ """Get a suggestion for where to explore next, based on unexplored exits.
448
+ Finds the nearest unexplored direction and tells you how to get there."""
449
+ g = get_game()
450
+ result = g.world_map.get_nearest_unexplored(g.current_location)
451
+ if result is None:
452
+ return "No unexplored exits recorded. Try: look (to spot exits), or try directions manually."
453
+
454
+ path, unexplored_dir = result
455
+ if not path:
456
+ return f"There's an unexplored exit right here: go '{unexplored_dir}'!"
457
+ path_str = " -> ".join(path)
458
+ return (
459
+ f"Nearest unexplored exit: go to via [{path_str}], then try '{unexplored_dir}'."
460
+ )
461
+
462
+
463
+ @mcp.tool()
464
+ def register_exits(directions: str) -> str:
465
+ """Tell the map about exits you see in the current room description.
466
+ Pass a comma-separated list of directions, e.g. 'north, south, up'.
467
+ This helps track what you haven't explored yet."""
468
+ g = get_game()
469
+ dirs = [d.strip().lower() for d in directions.split(",") if d.strip()]
470
+ valid = [d for d in dirs if canonicalize(d) in DIRECTIONS or d in DIRECTIONS]
471
+ if valid:
472
+ g.world_map.register_exits(g.current_location, valid)
473
+ return f"Registered exits at {g.current_location}: {', '.join(valid)}"
474
+ return "No valid directions recognized. Use: north, south, east, west, up, down, in, out, etc."
475
 
 
 
 
476
 
477
  if __name__ == "__main__":
 
478
  mcp.run()
requirements.txt CHANGED
@@ -5,5 +5,4 @@
5
  # Do not add jericho, fastmcp here - they are installed during evaluation
6
 
7
  # Add any additional packages your agent needs below:
8
- # numpy
9
- # requests
 
5
  # Do not add jericho, fastmcp here - they are installed during evaluation
6
 
7
  # Add any additional packages your agent needs below:
8
+ python-dotenv