Spaces:
Sleeping
Sleeping
soumission zork
Browse files- README.md +12 -13
- agent.py +388 -167
- mcp_server.py +445 -176
- 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 |
-
|
| 22 |
|
| 23 |
-
-
|
| 24 |
-
|
| 25 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
|
| 133 |
-
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
- memory: Get current game state and history (if implemented)
|
| 138 |
-
- inventory: Check what you're carrying (if implemented)
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
THOUGHT: <
|
| 147 |
-
TOOL: <
|
| 148 |
-
ARGS: <JSON
|
| 149 |
|
| 150 |
Example:
|
| 151 |
-
THOUGHT: I
|
| 152 |
TOOL: play_action
|
| 153 |
-
ARGS: {"action": "
|
| 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 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 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 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
return RunResult(
|
| 235 |
-
final_score=
|
| 236 |
-
max_score=
|
| 237 |
-
moves=
|
| 238 |
-
locations_visited=locations_visited,
|
| 239 |
-
game_completed=
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 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 |
-
#
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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.
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
self.
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
self.state = self.env.reset()
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def step(self, action: str) -> str:
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
self.
|
| 81 |
-
|
| 82 |
-
#
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
return self.state.observation
|
| 87 |
-
|
| 88 |
-
def
|
| 89 |
-
""
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
-
|
| 98 |
-
_game = GameManager()
|
| 99 |
|
| 100 |
|
| 101 |
-
def get_game() ->
|
| 102 |
-
"""Get or initialize the game manager."""
|
| 103 |
global _game
|
| 104 |
-
if _game
|
| 105 |
-
|
| 106 |
-
game = os.environ.get("GAME", "zork1")
|
| 107 |
-
_game.initialize(game)
|
| 108 |
return _game
|
| 109 |
|
| 110 |
|
| 111 |
# =============================================================================
|
| 112 |
-
#
|
| 113 |
# =============================================================================
|
| 114 |
|
|
|
|
| 115 |
@mcp.tool()
|
| 116 |
def play_action(action: str) -> str:
|
| 117 |
-
"""
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 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
|
|
|