← All work · 2025 – now
PlayZork
Teaching an LLM to play Zork
Research on LLM agents in long-horizon problems. A single model with memory loops and thrashes, so I built a multi-agent design in which narrow agents argue for competing goals and a separate arbiter decides.
- ½
- turn time after cutting calls from 10 + 2N to 5 + N
- 45s
- for bare Claude Opus 4.7 to clear my escape room
- MSc
- in progress at East Texas A&M, where this research continues
- What I did
- The research question, the architecture, the experiments, and the uncomfortable conclusions.
- What AI did
- The subject. And, eventually, the benchmark that humbled the scaffolding.
The question
Zork is a hard environment for an LLM. You have to track several unsolved puzzles, a map, partial progress and competing priorities, over hundreds of turns. A single model call, even with persistent memory, loops, thrashes and loses track of what it already learned.
So the question was: does explicit arbitration improve long-horizon decisions compared with a single model call?
The architecture
The design separates proposing from deciding. No single agent carries the whole decision.
- Issue agents, one for each unresolved problem, are deliberately narrow, stubborn and single-minded. Each one proposes one action and argues for it.
- An explorer agent proposes information-gathering moves and competes with them. Exploration is a first-class agent, not a fallback.
- An arbiter sees every proposal and a summary of the current state, and picks exactly one action.
Around them sit an observer that finds new issues, a map graph with pathfinding, death analysis that persists lessons learned, and a per-turn report of every proposal and decision. A single-call control condition gets the same information and the same model.
What I found
The honest version: it doesn’t solve Zork. What it did produce was useful:
- Restructuring cut model calls per turn from 10 + 2N to 5 + N and halved turn time. Running the agents concurrently didn’t help: local inference throughput stayed flat at about 0.26 requests per second.
- An audit of the scaffolding found the real bugs: a misnamed tool that hid the inventory, successful moves recorded as walls, rooms with the same name merged into one. A weak model cannot compensate for corrupted scaffolding.
- Then I gave my escape-room benchmark to Claude Opus 4.7 with none of my scaffolding. It escaped in 45 seconds. My enhanced setup escaped about half the time, after 30 to 60 minutes.
That last result is the one I think about most, as a researcher and as an engineering leader. The ground moves fast. Build for the models that are coming, not just the one you have.
185def create_spawn_agents_node( 186 memory_toolkit: MemoryToolkit, 187 mapper_toolkit: MapperToolkit, 188 inventory_toolkit, 189 decision_llm, 190 history_toolkit: HistoryToolkit, 191): 192 """ 193 Create the spawn agents node that creates IssueAgents and ExplorerAgent. 194 195 Args: 196 memory_toolkit: MemoryToolkit for accessing stored strategic issues 197 mapper_toolkit: MapperToolkit for accessing map state 198 inventory_toolkit: InventoryToolkit for accessing inventory 199 decision_llm: LLM for generating proposals 200 history_toolkit: HistoryToolkit for accessing tools 201 202 Returns: 203 Node function for the graph 204 """ 205 # issue signature -> why it could not be acted on. Lives as long as the 206 # graph, so a verdict persists across turns. 207 blocked_issues: dict = {} 208 209 async def spawn_agents_node(state: DecisionState) -> dict: 210 """ 211 Spawn phase: Create one IssueAgent for each tracked strategic issue. 212 Each agent performs its own research and generates a proposal IN PARALLEL. 213 """ 214 import asyncio 215 import logging 216 logger = logging.getLogger(__name__) 217 218 logger.info("\n" + "=" * 80) 219 logger.info("SPAWN AGENTS - Creating specialized agents for this turn") 220 logger.info("=" * 80) 221 222 memories_sorted = state["memories"] 223 224 context = state["turn_context"] 225 226 # One IssueAgent per issue, but skip those already known to be 227 # unactionable under this location + inventory. A skipped agent keeps 228 # its recorded reason, so the HTML report still explains its silence. 229 issue_agents = [] 230 skipped = 0 231 for mem in memories_sorted: 232 agent = IssueAgent(memory=mem) 233 cached = blocked_issues.get(_blocked_signature(mem, context)) 234 if cached is not None: 235 agent.proposed_action = None 236 agent.confidence = None 237 agent.reason = cached 238 skipped += 1 239 logger.info(f"SKIPPED IssueAgent ID:{mem.id} — still blocked: {cached[:70]}") 240 issue_agents.append(agent) 241 242 logger.info(f"SPAWNED {len(issue_agents) - skipped} IssueAgents " 243 f"({skipped} skipped as already blocked)") 244 245 # Extract current game state 246 game_response = state["game_response"] 247 # "Unknown" is prose for the prompts only. Anything that indexes the 248 # map, routes, or gets stored must go through is_known_location (#7). 249 current_location = game_response.LocationName or UNKNOWN_LOCATION 250 location_is_known = is_known_location(game_response.LocationName) 251 current_game_text = game_response.Response 252 current_score = game_response.Score 253 current_moves = game_response.Moves 254 255 # ========== NEW: Spawn ONE ExplorerAgent (if unexplored directions exist) ========== 256 # Get known exits from current location. With no room name there is no 257 # map node to explore *from*: querying exits for the fake room 258 # "Unknown" returned nothing, so the explorer confidently reported all 259 # ten directions unexplored and proposed a move it could not map (#7). 260 known_exits = ( 261 mapper_toolkit.state.get_exits_from(current_location) 262 if location_is_known 263 else [] 264 ) 265 # Canonicalize so a passage recorded as "N" counts as NORTH (#9), and 266 # split real passages (explored) from BLOCKED edges. A BLOCKED edge is 267 # only PROVISIONALLY closed (#11/#31), so its direction comes back as a 268 # low-priority retry rather than being treated as explored — otherwise 269 # a wall that has since cleared never gets re-tried and the map can 270 # only degrade. See explorer_direction_pools for the full rationale. 271 unexplored_directions, retry_directions = explorer_direction_pools(known_exits) 272 273 # Which unexplored directions does the room prose actually name? 274 # Whole-word matching only: substring containment scored "NE" inside 275 # CORNER and "SE" inside HOUSE, and a fabricated mention both outranks 276 # every real exit and adds +20 confidence (#8). It also matched 277 # "NORTH" inside "NORTHEAST", sending the agent north when the room 278 # said northeast. 279 mentioned_directions = find_mentioned_directions( 280 current_game_text, 281 unexplored_directions, 282 ) 283 284 # Spawn ONE ExplorerAgent if anything is left to try here — real 285 # frontier, or a previously-refused direction worth re-probing (#31). 286 has_candidates = bool(unexplored_directions or retry_directions) 287 explorer_agent = None 288 if has_candidates and not location_is_known: 289 logger.info( 290 "NO ExplorerAgent spawned - current location is unknown, so there " 291 "is no map node to explore from" 292 ) 293 elif has_candidates: 294 explorer_agent = ExplorerAgent( 295 current_location=current_location, 296 unexplored_directions=unexplored_directions, 297 mentioned_directions=mentioned_directions, 298 game_exits=context.game_exits, 299 retry_directions=retry_directions, 300 turn_number=0 # Will be set properly when turn_number added to state 301 ) 302 logger.info(f"SPAWNED 1 ExplorerAgent - {len(unexplored_directions)} unexplored, " 303 f"{len(retry_directions)} retry: unexplored={unexplored_directions} retry={retry_directions}") 304 logger.info(f" Mentioned in description: {mentioned_directions if mentioned_directions else 'None'}") 305 logger.info(f" Best direction chosen: {explorer_agent.best_direction}" 306 f"{' (RETRY of a refused direction)' if explorer_agent.is_retry else ''}") 307 else: 308 logger.info("NO ExplorerAgent spawned - all directions explored from this location") 309 310 # ========== DISABLED: LoopDetectionAgent ========== 311 # loop_detection_agent = LoopDetectionAgent() 312 # logger.info("SPAWNED 1 LoopDetectionAgent - monitors for stuck/oscillating patterns") 313 loop_detection_agent = None # DISABLED - not useful in practice 314 logger.info("LoopDetectionAgent DISABLED") 315 316 # ========== NEW: Spawn ONE InteractionAgent (ALWAYS) ========== 317 interaction_agent = InteractionAgent() 318 logger.info("SPAWNED 1 InteractionAgent - identifies local object interactions") 319 320 # ========== PARALLEL RESEARCH: IssueAgents + ExplorerAgent + InteractionAgent ========== 321 num_special_agents = (1 if explorer_agent else 0) + 1 # +1 for Interaction (Loop disabled) 322 logger.info(f"Starting PARALLEL research for {len(issue_agents)} IssueAgents + {num_special_agents} special agents...") 323 324 # Build a coroutine for each agent's research+propose pass. Agents are 325 # async-native (chain.ainvoke), so no thread offload is needed. 326 def agent_coroutine(agent): 327 # One LLM call per agent now, not two. 328 return agent.propose(decision_llm=decision_llm, context=context) 329 330 # Filter out None agents (e.g., loop_detection_agent is disabled) 331 runnable_issues = [a for a in issue_agents if a.reason is None] 332 all_agents = [a for a in runnable_issues 333 + [explorer_agent, loop_detection_agent, interaction_agent] 334 if a is not None] 335 336 # Run all agents in parallel — pure async, no threads. 337 # return_exceptions=True isolates failures: one agent blowing up must 338 # not cancel its siblings or end the turn (see #1). A failed agent is 339 # neutralized so it cannot contribute a proposal, but is kept in state 340 # so the HTML report still shows what it attempted and why it failed. 341 failed_agents = 0 342 if all_agents: 343 results = await asyncio.gather( 344 *(agent_coroutine(a) for a in all_agents), 345 return_exceptions=True, 346 ) 347 for agent, result in zip(all_agents, results): 348 if not isinstance(result, BaseException): 349 continue 350 if isinstance(result, asyncio.CancelledError): 351 # Turn budget expired / task cancelled — must propagate. 352 raise result 353 failed_agents += 1 354 agent_label = type(agent).__name__ 355 logger.error( 356 f"{agent_label} failed during research/proposal: {result}", 357 exc_info=result, 358 ) 359 _neutralize_failed_agent(agent, result) 360 361 # Remember a fresh "cannot act" verdict so the next turn does not pay 362 # ~3000 tokens to hear it again. 363 for agent, mem in zip(issue_agents, memories_sorted): 364 action = (agent.proposed_action or "").strip().lower() 365 if agent in all_agents and action in ("nothing", "none"): 366 blocked_issues[_blocked_signature(mem, context)] = agent.reason or "no action available" 367 368 logger.info( 369 f"All {len(all_agents)} agents completed research in PARALLEL " 370 f"({failed_agents} failed and were excluded from proposals)" 371 ) 372 logger.info("=" * 80) 373 logger.info("SPAWN AGENTS COMPLETE") 374 logger.info("=" * 80) 375 376 return { 377 "issue_agents": issue_agents, 378 "explorer_agent": explorer_agent, # single agent, can be None 379 "loop_detection_agent": loop_detection_agent, 380 "interaction_agent": interaction_agent, 381 } 382 383 384 return spawn_agents_node 385 386 387def create_decision_node(decision_chain: Runnable): 388 """ 389 Create the decision node that generates structured output from agent 390 proposals and previously gathered research context. 391 392 Args: 393 decision_chain: The LangChain decision chain with structured output 394 395 Returns: 396 Node function for the graph 397 """ 398 async def decision_node(state: DecisionState) -> DecisionState: 399 """ 400 Decision phase: Generate AdventurerResponse from agent proposals plus 401 research_context already gathered by research_node and per-agent research. 402 """ 403 import logging 404 logger = logging.getLogger(__name__) 405 406 zork_response = state["game_response"] 407 # Assembled in code by the spawn node (#25); the research node that 408 # used to produce this via an LLM round-trip is gone. 409 turn_context = state.get("turn_context") 410 research_context = ( 411 turn_context.research_context_for() if turn_context is not None 412 else state.get("research_context", "") 413 ) 414 issue_agents = state["issue_agents"] 415 explorer_agent = state["explorer_agent"] 416 loop_detection_agent = state["loop_detection_agent"] 417 interaction_agent = state["interaction_agent"] 418 419 logger.info("\n" + "=" * 80) 420 logger.info("[DecisionAgent] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 421 logger.info("[DecisionAgent] AGENT: DecisionAgent") 422 logger.info("[DecisionAgent] PURPOSE: Choose best action from all agent proposals") 423 logger.info(f"[DecisionAgent] LOCATION: {zork_response.LocationName}") 424 logger.info(f"[DecisionAgent] SCORE: {zork_response.Score}, MOVES: {zork_response.Moves}") 425 logger.info("[DecisionAgent] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 426 427 logger.info("=" * 80) 428 logger.info("DECISION - Choosing best action from agent proposals") 429 logger.info("=" * 80) 430 logger.info(f"Location: {zork_response.LocationName}") 431 logger.info(f"Score: {zork_response.Score}, Moves: {zork_response.Moves}") 432 logger.info(f"Game Response (first 100): {zork_response.Response[:100]}...") 433 434 # Format agent proposals for Decision Agent 435 agent_proposals_text = _format_agent_proposals( 436 issue_agents, explorer_agent, loop_detection_agent, interaction_agent, 437 context=turn_context, 438 ) 439 logger.info(f"Agent Proposals:\n{agent_proposals_text}") 440 logger.info("=" * 80) 441 442 # No additional tool-calling pass here: research_node + per-agent research 443 # already gathered sufficient context. Keep tool_calls_history empty for the 444 # report writer's compatibility. 445 tool_calls_history: list = [] 446 full_research_context = research_context 447 448 decision_input = { 449 "score_trajectory": (turn_context.score_trajectory if turn_context 450 else "unknown"), 451 "frontier": (turn_context.frontier_summary if turn_context 452 else "unknown"), 453 "score": zork_response.Score, 454 "locationName": zork_response.LocationName, 455 "moves": zork_response.Moves, 456 "game_response": zork_response.Response, 457 "research_context": full_research_context, 458 "agent_proposals": agent_proposals_text 459 } 460 461 # Format the full prompt for reporting (from prompt_library.py) 462 from adventurer.prompt_library import PromptLibrary 463 system_prompt = PromptLibrary.get_decision_agent_evaluation_prompt() 464 human_prompt = PromptLibrary.get_decision_agent_human_prompt()
185def create_spawn_agents_node( 186 memory_toolkit: MemoryToolkit, 187 mapper_toolkit: MapperToolkit, 188 inventory_toolkit, 189 decision_llm, 190 history_toolkit: HistoryToolkit, 191): 192 """ 193 Create the spawn agents node that creates IssueAgents and ExplorerAgent. 194 195 Args: 196 memory_toolkit: MemoryToolkit for accessing stored strategic issues 197 mapper_toolkit: MapperToolkit for accessing map state 198 inventory_toolkit: InventoryToolkit for accessing inventory 199 decision_llm: LLM for generating proposals 200 history_toolkit: HistoryToolkit for accessing tools 201 202 Returns: 203 Node function for the graph 204 """ 205 # issue signature -> why it could not be acted on. Lives as long as the 206 # graph, so a verdict persists across turns. 207 blocked_issues: dict = {} 208 209 async def spawn_agents_node(state: DecisionState) -> dict: 210 """ 211 Spawn phase: Create one IssueAgent for each tracked strategic issue. 212 Each agent performs its own research and generates a proposal IN PARALLEL. 213 """ 214 import asyncio 215 import logging 216 logger = logging.getLogger(__name__) 217 218 logger.info("\n" + "=" * 80) 219 logger.info("SPAWN AGENTS - Creating specialized agents for this turn") 220 logger.info("=" * 80) 221 222 memories_sorted = state["memories"] 223 224 context = state["turn_context"] 225 226 # One IssueAgent per issue, but skip those already known to be 227 # unactionable under this location + inventory. A skipped agent keeps 228 # its recorded reason, so the HTML report still explains its silence. 229 issue_agents = [] 230 skipped = 0 231 for mem in memories_sorted: 232 agent = IssueAgent(memory=mem) 233 cached = blocked_issues.get(_blocked_signature(mem, context)) 234 if cached is not None: 235 agent.proposed_action = None 236 agent.confidence = None 237 agent.reason = cached 238 skipped += 1 239 logger.info(f"SKIPPED IssueAgent ID:{mem.id} — still blocked: {cached[:70]}") 240 issue_agents.append(agent) 241 242 logger.info(f"SPAWNED {len(issue_agents) - skipped} IssueAgents " 243 f"({skipped} skipped as already blocked)") 244 245 # Extract current game state 246 game_response = state["game_response"] 247 # "Unknown" is prose for the prompts only. Anything that indexes the 248 # map, routes, or gets stored must go through is_known_location (#7). 249 current_location = game_response.LocationName or UNKNOWN_LOCATION 250 location_is_known = is_known_location(game_response.LocationName) 251 current_game_text = game_response.Response 252 current_score = game_response.Score 253 current_moves = game_response.Moves 254 255 # ========== NEW: Spawn ONE ExplorerAgent (if unexplored directions exist) ========== 256 # Get known exits from current location. With no room name there is no 257 # map node to explore *from*: querying exits for the fake room 258 # "Unknown" returned nothing, so the explorer confidently reported all 259 # ten directions unexplored and proposed a move it could not map (#7). 260 known_exits = ( 261 mapper_toolkit.state.get_exits_from(current_location) 262 if location_is_known 263 else [] 264 ) 265 # Canonicalize so a passage recorded as "N" counts as NORTH (#9), and 266 # split real passages (explored) from BLOCKED edges. A BLOCKED edge is 267 # only PROVISIONALLY closed (#11/#31), so its direction comes back as a 268 # low-priority retry rather than being treated as explored — otherwise 269 # a wall that has since cleared never gets re-tried and the map can 270 # only degrade. See explorer_direction_pools for the full rationale. 271 unexplored_directions, retry_directions = explorer_direction_pools(known_exits) 272 273 # Which unexplored directions does the room prose actually name? 274 # Whole-word matching only: substring containment scored "NE" inside 275 # CORNER and "SE" inside HOUSE, and a fabricated mention both outranks 276 # every real exit and adds +20 confidence (#8). It also matched 277 # "NORTH" inside "NORTHEAST", sending the agent north when the room 278 # said northeast. 279 mentioned_directions = find_mentioned_directions( 280 current_game_text, 281 unexplored_directions, 282 ) 283 284 # Spawn ONE ExplorerAgent if anything is left to try here — real 285 # frontier, or a previously-refused direction worth re-probing (#31). 286 has_candidates = bool(unexplored_directions or retry_directions) 287 explorer_agent = None 288 if has_candidates and not location_is_known: 289 logger.info( 290 "NO ExplorerAgent spawned - current location is unknown, so there " 291 "is no map node to explore from" 292 ) 293 elif has_candidates: 294 explorer_agent = ExplorerAgent( 295 current_location=current_location, 296 unexplored_directions=unexplored_directions, 297 mentioned_directions=mentioned_directions, 298 game_exits=context.game_exits, 299 retry_directions=retry_directions, 300 turn_number=0 # Will be set properly when turn_number added to state 301 ) 302 logger.info(f"SPAWNED 1 ExplorerAgent - {len(unexplored_directions)} unexplored, " 303 f"{len(retry_directions)} retry: unexplored={unexplored_directions} retry={retry_directions}") 304 logger.info(f" Mentioned in description: {mentioned_directions if mentioned_directions else 'None'}") 305 logger.info(f" Best direction chosen: {explorer_agent.best_direction}" 306 f"{' (RETRY of a refused direction)' if explorer_agent.is_retry else ''}") 307 else: 308 logger.info("NO ExplorerAgent spawned - all directions explored from this location") 309 310 # ========== DISABLED: LoopDetectionAgent ========== 311 # loop_detection_agent = LoopDetectionAgent() 312 # logger.info("SPAWNED 1 LoopDetectionAgent - monitors for stuck/oscillating patterns") 313 loop_detection_agent = None # DISABLED - not useful in practice 314 logger.info("LoopDetectionAgent DISABLED") 315 316 # ========== NEW: Spawn ONE InteractionAgent (ALWAYS) ========== 317 interaction_agent = InteractionAgent() 318 logger.info("SPAWNED 1 InteractionAgent - identifies local object interactions") 319 320 # ========== PARALLEL RESEARCH: IssueAgents + ExplorerAgent + InteractionAgent ========== 321 num_special_agents = (1 if explorer_agent else 0) + 1 # +1 for Interaction (Loop disabled) 322 logger.info(f"Starting PARALLEL research for {len(issue_agents)} IssueAgents + {num_special_agents} special agents...") 323 324 # Build a coroutine for each agent's research+propose pass. Agents are 325 # async-native (chain.ainvoke), so no thread offload is needed. 326 def agent_coroutine(agent): 327 # One LLM call per agent now, not two. 328 return agent.propose(decision_llm=decision_llm, context=context) 329 330 # Filter out None agents (e.g., loop_detection_agent is disabled) 331 runnable_issues = [a for a in issue_agents if a.reason is None] 332 all_agents = [a for a in runnable_issues 333 + [explorer_agent, loop_detection_agent, interaction_agent] 334 if a is not None] 335 336 # Run all agents in parallel — pure async, no threads. 337 # return_exceptions=True isolates failures: one agent blowing up must 338 # not cancel its siblings or end the turn (see #1). A failed agent is 339 # neutralized so it cannot contribute a proposal, but is kept in state 340 # so the HTML report still shows what it attempted and why it failed. 341 failed_agents = 0 342 if all_agents: 343 results = await asyncio.gather( 344 *(agent_coroutine(a) for a in all_agents), 345 return_exceptions=True, 346 ) 347 for agent, result in zip(all_agents, results): 348 if not isinstance(result, BaseException): 349 continue 350 if isinstance(result, asyncio.CancelledError): 351 # Turn budget expired / task cancelled — must propagate. 352 raise result 353 failed_agents += 1 354 agent_label = type(agent).__name__ 355 logger.error( 356 f"{agent_label} failed during research/proposal: {result}", 357 exc_info=result, 358 ) 359 _neutralize_failed_agent(agent, result) 360 361 # Remember a fresh "cannot act" verdict so the next turn does not pay 362 # ~3000 tokens to hear it again. 363 for agent, mem in zip(issue_agents, memories_sorted): 364 action = (agent.proposed_action or "").strip().lower() 365 if agent in all_agents and action in ("nothing", "none"): 366 blocked_issues[_blocked_signature(mem, context)] = agent.reason or "no action available" 367 368 logger.info( 369 f"All {len(all_agents)} agents completed research in PARALLEL " 370 f"({failed_agents} failed and were excluded from proposals)" 371 ) 372 logger.info("=" * 80) 373 logger.info("SPAWN AGENTS COMPLETE") 374 logger.info("=" * 80) 375 376 return { 377 "issue_agents": issue_agents, 378 "explorer_agent": explorer_agent, # single agent, can be None 379 "loop_detection_agent": loop_detection_agent, 380 "interaction_agent": interaction_agent, 381 } 382 383 384 return spawn_agents_node 385 386 387def create_decision_node(decision_chain: Runnable): 388 """ 389 Create the decision node that generates structured output from agent 390 proposals and previously gathered research context. 391 392 Args: 393 decision_chain: The LangChain decision chain with structured output 394 395 Returns: 396 Node function for the graph 397 """ 398 async def decision_node(state: DecisionState) -> DecisionState: 399 """ 400 Decision phase: Generate AdventurerResponse from agent proposals plus 401 research_context already gathered by research_node and per-agent research. 402 """ 403 import logging 404 logger = logging.getLogger(__name__) 405 406 zork_response = state["game_response"] 407 # Assembled in code by the spawn node (#25); the research node that 408 # used to produce this via an LLM round-trip is gone. 409 turn_context = state.get("turn_context") 410 research_context = ( 411 turn_context.research_context_for() if turn_context is not None 412 else state.get("research_context", "") 413 ) 414 issue_agents = state["issue_agents"] 415 explorer_agent = state["explorer_agent"] 416 loop_detection_agent = state["loop_detection_agent"] 417 interaction_agent = state["interaction_agent"] 418 419 logger.info("\n" + "=" * 80) 420 logger.info("[DecisionAgent] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 421 logger.info("[DecisionAgent] AGENT: DecisionAgent") 422 logger.info("[DecisionAgent] PURPOSE: Choose best action from all agent proposals") 423 logger.info(f"[DecisionAgent] LOCATION: {zork_response.LocationName}") 424 logger.info(f"[DecisionAgent] SCORE: {zork_response.Score}, MOVES: {zork_response.Moves}") 425 logger.info("[DecisionAgent] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 426 427 logger.info("=" * 80) 428 logger.info("DECISION - Choosing best action from agent proposals") 429 logger.info("=" * 80) 430 logger.info(f"Location: {zork_response.LocationName}") 431 logger.info(f"Score: {zork_response.Score}, Moves: {zork_response.Moves}") 432 logger.info(f"Game Response (first 100): {zork_response.Response[:100]}...") 433 434 # Format agent proposals for Decision Agent 435 agent_proposals_text = _format_agent_proposals( 436 issue_agents, explorer_agent, loop_detection_agent, interaction_agent, 437 context=turn_context, 438 ) 439 logger.info(f"Agent Proposals:\n{agent_proposals_text}") 440 logger.info("=" * 80) 441 442 # No additional tool-calling pass here: research_node + per-agent research 443 # already gathered sufficient context. Keep tool_calls_history empty for the 444 # report writer's compatibility. 445 tool_calls_history: list = [] 446 full_research_context = research_context 447 448 decision_input = { 449 "score_trajectory": (turn_context.score_trajectory if turn_context 450 else "unknown"), 451 "frontier": (turn_context.frontier_summary if turn_context 452 else "unknown"), 453 "score": zork_response.Score, 454 "locationName": zork_response.LocationName, 455 "moves": zork_response.Moves, 456 "game_response": zork_response.Response, 457 "research_context": full_research_context, 458 "agent_proposals": agent_proposals_text 459 } 460 461 # Format the full prompt for reporting (from prompt_library.py) 462 from adventurer.prompt_library import PromptLibrary 463 system_prompt = PromptLibrary.get_decision_agent_evaluation_prompt() 464 human_prompt = PromptLibrary.get_decision_agent_human_prompt()