blindpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ from blindpath.Constants.tiles import (
2
+ AGENT,
3
+ EMPTY,
4
+ OBSTACLE,
5
+ GOAL,
6
+ BOUNDARY,
7
+ TILE_WORDS,
8
+ )
9
+ from blindpath.Constants.env import (
10
+ DEFAULT_SEED,
11
+ DEFAULT_ENV_SIZE,
12
+ DEFAULT_NUM_GOALS,
13
+ DEFAULT_OBSTACLE_COUNT,
14
+ DEFAULT_VISION_SIZE,
15
+ MIN_ENV_DIMENSION,
16
+ MIN_VISION_SIZE,
17
+ MIN_NUM_GOALS,
18
+ MIN_OBSTACLE_COUNT,
19
+ )
20
+
21
+ __all__ = [
22
+ "DEFAULT_SEED",
23
+ "DEFAULT_ENV_SIZE",
24
+ "DEFAULT_NUM_GOALS",
25
+ "DEFAULT_OBSTACLE_COUNT",
26
+ "DEFAULT_VISION_SIZE",
27
+ "MIN_ENV_DIMENSION",
28
+ "MIN_VISION_SIZE",
29
+ "MIN_NUM_GOALS",
30
+ "MIN_OBSTACLE_COUNT",
31
+ "AGENT",
32
+ "EMPTY",
33
+ "OBSTACLE",
34
+ "GOAL",
35
+ "BOUNDARY",
36
+ "TILE_WORDS",
37
+ ]
@@ -0,0 +1,24 @@
1
+ """
2
+ Constants for BlindPath environment configuration.
3
+ """
4
+
5
+ from typing import Tuple
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Default values
9
+ # ---------------------------------------------------------------------------
10
+
11
+ DEFAULT_SEED: None = None
12
+ DEFAULT_ENV_SIZE: Tuple[int, int] = (40, 40)
13
+ DEFAULT_NUM_GOALS: int = 1
14
+ DEFAULT_OBSTACLE_COUNT: int = 0
15
+ DEFAULT_VISION_SIZE: int = 11
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Minimum allowed values
19
+ # ---------------------------------------------------------------------------
20
+
21
+ MIN_ENV_DIMENSION: int = 5
22
+ MIN_VISION_SIZE: int = 3
23
+ MIN_NUM_GOALS: int = 1
24
+ MIN_OBSTACLE_COUNT: int = 0
@@ -0,0 +1,19 @@
1
+ """
2
+ blindpath/Constants/tiles.py
3
+
4
+ Single-character tile labels used throughout the grid system.
5
+ """
6
+
7
+ AGENT = "A"
8
+ EMPTY = "E"
9
+ OBSTACLE = "O"
10
+ GOAL = "G"
11
+ BOUNDARY = "B"
12
+
13
+ TILE_WORDS: dict[str, str] = {
14
+ AGENT: "Agent",
15
+ EMPTY: "Empty",
16
+ OBSTACLE: "Obstacle",
17
+ GOAL: "Goal",
18
+ BOUNDARY: "Boundary",
19
+ }
@@ -0,0 +1,7 @@
1
+ from blindpath.DataClasses.vector2 import Vector2
2
+ from blindpath.core.grid import Grid
3
+ from blindpath.DataClasses.step_result import StepResult
4
+ from blindpath.DataClasses.env_config import EnvConfig
5
+ from blindpath.DataClasses.generated_map import GeneratedMap
6
+
7
+ __all__ = ["Vector2", "Grid", "StepResult", "EnvConfig", "GeneratedMap"]
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, Tuple
3
+
4
+ from blindpath.Constants.env import (
5
+ DEFAULT_SEED,
6
+ DEFAULT_ENV_SIZE,
7
+ DEFAULT_NUM_GOALS,
8
+ DEFAULT_OBSTACLE_COUNT,
9
+ DEFAULT_VISION_SIZE,
10
+ MIN_ENV_DIMENSION,
11
+ MIN_VISION_SIZE,
12
+ MIN_NUM_GOALS,
13
+ MIN_OBSTACLE_COUNT,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class EnvConfig:
19
+ """All hyperparameters for one BlindPath environment instance."""
20
+
21
+ seed: Optional[int] = DEFAULT_SEED
22
+ env_size: Tuple[int, int] = DEFAULT_ENV_SIZE
23
+ num_goals: int = DEFAULT_NUM_GOALS
24
+ obstacle_count: int = DEFAULT_OBSTACLE_COUNT
25
+ vision_size: int = DEFAULT_VISION_SIZE
26
+
27
+ def __post_init__(self) -> None:
28
+ rows, cols = self.env_size
29
+ if rows < MIN_ENV_DIMENSION or cols < MIN_ENV_DIMENSION:
30
+ raise ValueError(f"env_size must be at least {MIN_ENV_DIMENSION}x{MIN_ENV_DIMENSION}")
31
+ if self.vision_size < MIN_VISION_SIZE:
32
+ raise ValueError(f"vision_size must be at least {MIN_VISION_SIZE}")
33
+ if self.vision_size % 2 == 0:
34
+ raise ValueError("vision_size must be odd")
35
+ if self.vision_size > min(rows, cols):
36
+ raise ValueError("vision_size cannot exceed the environment size")
37
+ if self.num_goals < MIN_NUM_GOALS:
38
+ raise ValueError(f"num_goals must be >= {MIN_NUM_GOALS}")
39
+ if self.obstacle_count < MIN_OBSTACLE_COUNT:
40
+ raise ValueError(f"obstacle_count must be >= {MIN_OBSTACLE_COUNT}")
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+ from typing import List
3
+
4
+ from blindpath.DataClasses.vector2 import Vector2
5
+ from blindpath.core.grid import Grid
6
+
7
+
8
+ @dataclass
9
+ class GeneratedMap:
10
+ grid: Grid
11
+ agent_start: Vector2
12
+ goals: List[Vector2]
13
+ optimal_path_len: int
14
+ accessible_tiles: int
15
+ accessible_vision_tiles: int
@@ -0,0 +1,18 @@
1
+ from dataclasses import dataclass
2
+
3
+ from blindpath.DataClasses.vector2 import Vector2
4
+ from blindpath.core.grid import Grid
5
+
6
+
7
+ @dataclass
8
+ class StepResult:
9
+ """Returned by BlindPathEnv.step()."""
10
+ vision_grid: Grid # NxN vision window (LLM input)
11
+ full_grid: Grid # Full grid (debug only)
12
+ agent_position: Vector2
13
+ goals_remaining: int
14
+ done: bool # episode finished (success OR timeout)
15
+ success: bool # all goals reached
16
+ timed_out: bool
17
+ legal: bool # was the attempted action legal?
18
+ iteration: int
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Vector2:
8
+ x: int # column
9
+ y: int # row (0 = top)
10
+
11
+ def __add__(self, other: Vector2) -> Vector2:
12
+ return Vector2(self.x + other.x, self.y + other.y)
13
+
14
+ def __eq__(self, other: object) -> bool:
15
+ if not isinstance(other, Vector2):
16
+ return NotImplemented
17
+ return self.x == other.x and self.y == other.y
18
+
19
+ def __hash__(self) -> int:
20
+ return hash((self.x, self.y))
21
+
22
+ def __repr__(self) -> str:
23
+ return f"Vector2({self.x}, {self.y})"
24
+
25
+ def manhattan(self, other: Vector2) -> int:
26
+ return abs(self.x - other.x) + abs(self.y - other.y)
@@ -0,0 +1,9 @@
1
+ """
2
+ blindpath/Util/__init__.py
3
+ """
4
+
5
+ from blindpath.Util.astar import astar
6
+ from blindpath.Util.grid_formatting import grid_to_ascii, grid_to_json, grid_to_chess, grid_to_word, grid_to_row_narrative, grid_to_col_narrative
7
+ from blindpath.Util.action_parsing import parse_action
8
+
9
+ __all__ = ["astar", "grid_to_ascii", "grid_to_json", "grid_to_chess", "grid_to_word", "grid_to_row_narrative", "grid_to_col_narrative", "parse_action"]
@@ -0,0 +1,36 @@
1
+ """
2
+ blindpath/Util/action_parsing.py
3
+
4
+ Extracts a valid BlindPath action from raw LLM output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Optional
12
+
13
+ from blindpath.core.env import ACTIONS
14
+
15
+
16
+ def parse_action(raw: str) -> Optional[str]:
17
+ """
18
+ Try to extract a valid action string from raw LLM output.
19
+
20
+ Extracts the first JSON object/array from the response,
21
+ then checks for a valid "action" field.
22
+ Returns None if no valid action could be extracted.
23
+ """
24
+ match = re.search(r'(\{.*?\}|\[.*?\])', raw, re.DOTALL)
25
+ if not match:
26
+ return None
27
+
28
+ try:
29
+ data = json.loads(match.group(0))
30
+ action = data.get("action", "")
31
+ if action in ACTIONS:
32
+ return action
33
+ except (json.JSONDecodeError, AttributeError):
34
+ pass
35
+
36
+ return None
@@ -0,0 +1,102 @@
1
+ """
2
+ blindpath/Util/astar.py
3
+
4
+ A* pathfinding on a BlindPath grid.
5
+ """
6
+
7
+ import heapq
8
+ import math
9
+ from typing import List, Optional
10
+
11
+ import numpy as np
12
+
13
+ from blindpath.DataClasses.vector2 import Vector2
14
+ from blindpath.Constants.tiles import OBSTACLE
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Directional offsets (shared with other grid algorithms via import)
18
+ # ---------------------------------------------------------------------------
19
+
20
+ NEIGHBOURS: List[Vector2] = [
21
+ Vector2(0, -1), # Up
22
+ Vector2(0, 1), # Down
23
+ Vector2(-1, 0), # Left
24
+ Vector2( 1, 0), # Right
25
+ ]
26
+
27
+
28
+ def astar(
29
+ grid: List[List[str]],
30
+ start: Vector2,
31
+ goal: Vector2,
32
+ *,
33
+ cost_grid: Optional[np.ndarray] = None,
34
+ ) -> Optional[List[Vector2]]:
35
+ """
36
+ A* from *start* to *goal*.
37
+
38
+ Parameters
39
+ ----------
40
+ grid : Grid
41
+ The map. 'O' cells are impassable.
42
+ start, goal : Vector2
43
+ Source and target positions (column=x, row=y).
44
+ cost_grid : np.ndarray | None
45
+ If given, adds per-cell movement cost on top of the base cost-1.
46
+ Used during generation to produce naturalistically winding paths.
47
+
48
+ Returns
49
+ -------
50
+ List[Vector2] | None
51
+ Ordered list of positions (excluding *start*, including *goal*),
52
+ or None if no path exists.
53
+ """
54
+ rows = len(grid)
55
+ cols = len(grid[0]) if rows else 0
56
+
57
+ def h(pos: Vector2) -> float:
58
+ return pos.manhattan(goal)
59
+
60
+ def cell_cost(pos: Vector2) -> float:
61
+ base = 1.0
62
+ if cost_grid is not None:
63
+ base += float(cost_grid[pos.y, pos.x])
64
+ return base
65
+
66
+ counter = 0 # tie-breaker so Vector2 is never compared by heapq
67
+ open_heap: list = []
68
+ heapq.heappush(open_heap, (h(start), 0.0, counter, start))
69
+ came_from: dict[Vector2, Optional[Vector2]] = {start: None}
70
+ g_score: dict[Vector2, float] = {start: 0.0}
71
+
72
+ while open_heap:
73
+ _, g, _, current = heapq.heappop(open_heap)
74
+
75
+ if g > g_score.get(current, math.inf):
76
+ continue # stale entry
77
+
78
+ if current == goal:
79
+ # Reconstruct
80
+ path: List[Vector2] = []
81
+ node: Optional[Vector2] = current
82
+ while node is not None and node != start:
83
+ path.append(node)
84
+ node = came_from[node]
85
+ path.reverse()
86
+ return path
87
+
88
+ for delta in NEIGHBOURS:
89
+ nb = current + delta
90
+ if not (0 <= nb.x < cols and 0 <= nb.y < rows):
91
+ continue
92
+ if grid[nb.y][nb.x] == OBSTACLE and nb != goal:
93
+ continue
94
+ tentative_g = g_score[current] + cell_cost(nb)
95
+ if tentative_g < g_score.get(nb, math.inf):
96
+ g_score[nb] = tentative_g
97
+ came_from[nb] = current
98
+ f = tentative_g + h(nb)
99
+ counter += 1
100
+ heapq.heappush(open_heap, (f, tentative_g, counter, nb))
101
+
102
+ return None # no path
@@ -0,0 +1,138 @@
1
+ """
2
+ blindpath/Util/grid_formatting.py
3
+
4
+ Display utilities for BlindPath grids.
5
+
6
+ Cartesian formats — each tile as an individual data point with coordinates.
7
+ Topography formats — full grid rendered as a 2D map.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import string
14
+ from typing import List
15
+
16
+ from blindpath.Constants.tiles import TILE_WORDS
17
+ from blindpath.core.grid import Grid
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Cartesian
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def grid_to_json(grid: Grid) -> str:
25
+ """
26
+ Each grid point as a JSON object with x, y coordinates and label,
27
+ wrapped in a root object with grid dimensions.
28
+
29
+ Example: {"rows": 3, "cols": 3, "tiles": [{"x": 0, "y": 0, "label": "E"}, ...]}
30
+ """
31
+ tiles: List[dict] = []
32
+ for y, row in enumerate(grid):
33
+ for x, label in enumerate(row):
34
+ tiles.append({"x": x, "y": y, "label": label})
35
+ return json.dumps({"matrix_y_size": len(grid), "matrix_x_size": len(grid[0]), "tiles": tiles})
36
+
37
+
38
+ def grid_to_chess(grid: Grid) -> str:
39
+ """
40
+ Each grid point as a JSON object using chess notation for coordinates,
41
+ wrapped in a root object with grid dimensions.
42
+
43
+ Columns map to letters (a-z), rows count from the bottom (1-based).
44
+ Example: {"rows": 3, "cols": 3, "tiles": [{"pos": "a3", "label": "E"}, ...]}
45
+ """
46
+ rows = len(grid)
47
+ tiles: List[dict] = []
48
+ for y, row in enumerate(grid):
49
+ rank = rows - y # chess ranks count from bottom
50
+ for x, label in enumerate(row):
51
+ file = string.ascii_lowercase[x] if x < 26 else f"c{x}"
52
+ tiles.append({"pos": f"{file}{rank}", "label": label})
53
+ return json.dumps({"matrix_y_size": rows, "matrix_x_size": len(grid[0]), "tiles": tiles})
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Topography
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def grid_to_ascii(grid: Grid) -> str:
61
+ """Convert a 2D grid to a compact ASCII string, one row per line."""
62
+ return "\n".join(" ".join(row) for row in grid)
63
+
64
+
65
+ def grid_to_word(grid: Grid) -> str:
66
+ """
67
+ Like ASCII but each tile is a full word instead of a single letter.
68
+
69
+ Uses TILE_WORDS mapping from Constants. Unknown labels pass through as-is.
70
+ Words are space-separated, one row per line.
71
+ """
72
+ lines: List[str] = []
73
+ for row in grid:
74
+ words = [TILE_WORDS.get(tile, tile) for tile in row]
75
+ lines.append(" ".join(words))
76
+ return "\n".join(lines)
77
+
78
+
79
+ _UNIQUE_TILES = {"A", "G"}
80
+
81
+
82
+ def _ordinal(n: int) -> str:
83
+ """Return the ordinal string for a 1-based number: 1st, 2nd, 3rd, 4th..."""
84
+ if 11 <= n % 100 <= 13:
85
+ return f"{n}th"
86
+ return f"{n}{('th','st','nd','rd')[min(n % 10, 4)] if n % 10 < 4 else 'th'}"
87
+
88
+
89
+ def _describe_group(label: str, count: int) -> str:
90
+ """Describe a run of identical tiles in natural language."""
91
+ word = TILE_WORDS.get(label, label).lower()
92
+ if label in _UNIQUE_TILES:
93
+ return f"the {word}"
94
+ cell = "cell" if count == 1 else "cells"
95
+ return f"{count} {word} {cell}"
96
+
97
+
98
+ def _run_length_groups(tiles: List[str]) -> List[tuple[str, int]]:
99
+ """Group consecutive identical tiles into (label, count) pairs."""
100
+ groups: List[tuple[str, int]] = []
101
+ for tile in tiles:
102
+ if groups and groups[-1][0] == tile:
103
+ groups[-1] = (tile, groups[-1][1] + 1)
104
+ else:
105
+ groups.append((tile, 1))
106
+ return groups
107
+
108
+
109
+ def grid_to_row_narrative(grid: Grid) -> str:
110
+ """
111
+ Natural language description of the grid, row by row.
112
+
113
+ Groups consecutive identical tiles and describes each run.
114
+ Example: "The 1st row has 3 empty cells, then the goal, then 1 empty cell."
115
+ """
116
+ sentences: List[str] = []
117
+ for i, row in enumerate(grid):
118
+ groups = _run_length_groups(list(row))
119
+ parts = [_describe_group(label, count) for label, count in groups]
120
+ sentences.append(f"The {_ordinal(i + 1)} row has {', then '.join(parts)}.")
121
+ return "\n".join(sentences)
122
+
123
+
124
+ def grid_to_col_narrative(grid: Grid) -> str:
125
+ """
126
+ Natural language description of the grid, column by column.
127
+
128
+ Groups consecutive identical tiles and describes each run.
129
+ Example: "The 1st column has 3 empty cells, then the agent, then 1 empty cell."
130
+ """
131
+ sentences: List[str] = []
132
+ cols = grid.cols if hasattr(grid, 'cols') else len(grid[0])
133
+ for x in range(cols):
134
+ column = [grid[y][x] for y in range(len(grid))]
135
+ groups = _run_length_groups(column)
136
+ parts = [_describe_group(label, count) for label, count in groups]
137
+ sentences.append(f"The {_ordinal(x + 1)} column has {', then '.join(parts)}.")
138
+ return "\n".join(sentences)
blindpath/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ blindpath — Public API
3
+ """
4
+
5
+ from blindpath.core.env import BlindPathEnv, ACTIONS
6
+ from blindpath.DataClasses.env_config import EnvConfig
7
+ from blindpath.DataClasses.step_result import StepResult
8
+ from blindpath.DataClasses.vector2 import Vector2
9
+ from blindpath.DataClasses.generated_map import GeneratedMap
10
+ from blindpath.core.grid import Grid
11
+ from blindpath.Constants.tiles import AGENT, EMPTY, OBSTACLE, GOAL, BOUNDARY
12
+ from blindpath.Util.grid_formatting import grid_to_ascii, grid_to_json, grid_to_chess, grid_to_word, grid_to_row_narrative, grid_to_col_narrative
13
+ from blindpath.Util.action_parsing import parse_action
14
+ from blindpath.core.map_generation import generate_map
15
+ from blindpath.agents.base import BaseAgent
16
+ from blindpath.agents.random_agent import RandomAgent
17
+ from blindpath.agents.pomcp_agent import POMCPAgent
18
+ from blindpath.agents.llm_agent import LLMAgent
19
+ from blindpath.eval.episode_metrics import EpisodeMetrics
20
+ from blindpath.eval.benchmark_results import BenchmarkResults
21
+ from blindpath.session import Session
22
+ from importlib.metadata import version as _version
23
+
24
+ __version__ = _version("blindpath")
25
+
26
+ __all__ = [
27
+ # Environment
28
+ "BlindPathEnv",
29
+ "EnvConfig",
30
+ "StepResult",
31
+ "ACTIONS",
32
+ # Grid
33
+ "Vector2",
34
+ "Grid",
35
+ "generate_map",
36
+ "grid_to_ascii",
37
+ "grid_to_json",
38
+ "grid_to_chess",
39
+ "grid_to_word",
40
+ "grid_to_row_narrative",
41
+ "grid_to_col_narrative",
42
+ "parse_action",
43
+ # Agents
44
+ "BaseAgent",
45
+ "RandomAgent",
46
+ "POMCPAgent",
47
+ "LLMAgent",
48
+ # Metrics
49
+ "EpisodeMetrics",
50
+ "BenchmarkResults",
51
+ # Session
52
+ "Session",
53
+ ]
@@ -0,0 +1,11 @@
1
+ from blindpath.agents.base import BaseAgent
2
+ from blindpath.agents.random_agent import RandomAgent
3
+ from blindpath.agents.pomcp_agent import POMCPAgent
4
+
5
+ __all__ = ["BaseAgent", "RandomAgent", "POMCPAgent"]
6
+
7
+ try:
8
+ from blindpath.agents.llm_agent import LLMAgent
9
+ __all__.append("LLMAgent")
10
+ except ImportError:
11
+ pass
@@ -0,0 +1,39 @@
1
+ """
2
+ blindpath/agents/base.py
3
+
4
+ Abstract base class for all BlindPath agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+
11
+ from blindpath.core.env import BlindPathEnv
12
+ from blindpath.DataClasses.step_result import StepResult
13
+
14
+
15
+ class BaseAgent(ABC):
16
+ """
17
+ Every agent must implement `step()`.
18
+
19
+ Parameters passed to `step()`:
20
+ - env : the live environment (agents may read public properties
21
+ but must NOT modify internal state directly).
22
+ - result : the StepResult from the previous step (or initial reset).
23
+ """
24
+
25
+ @abstractmethod
26
+ def step(
27
+ self,
28
+ env: BlindPathEnv,
29
+ result: StepResult,
30
+ ) -> str:
31
+ """
32
+ Return one of: "Up", "Down", "Left", "Right".
33
+ """
34
+
35
+ def reset(self) -> None:
36
+ """Called at the beginning of every new episode. Override if needed."""
37
+
38
+ def observe(self, action: str, result: StepResult) -> None:
39
+ """Called after env.step() with the action taken and result. Override if needed."""
@@ -0,0 +1,69 @@
1
+ """
2
+ blindpath/agents/llm_agent.py
3
+
4
+ Abstract base class for LLM-powered BlindPath agents.
5
+
6
+ Subclasses implement `_prompt()` to call their LLM of choice.
7
+ The base class handles prompt assembly and action parsing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from abc import abstractmethod
14
+ from typing import Callable, Optional
15
+
16
+ from blindpath.agents.base import BaseAgent
17
+ from blindpath.core.env import ACTIONS, BlindPathEnv
18
+ from blindpath.DataClasses.step_result import StepResult
19
+ from blindpath.Util.action_parsing import parse_action
20
+
21
+
22
+ PromptBuilder = Callable[[BlindPathEnv, StepResult], Optional[str]]
23
+
24
+
25
+ class LLMAgent(BaseAgent):
26
+ """
27
+ Abstract base for LLM-powered agents.
28
+
29
+ Subclasses must implement `_prompt()` to call their LLM and return
30
+ the raw response string. The base class assembles the full prompt
31
+ (custom + static + vision) and parses the action from the response.
32
+
33
+ Parameters
34
+ ----------
35
+ prompt_builder : PromptBuilder | None
36
+ Optional callback that receives (env, result) and returns a custom
37
+ prompt string to prepend before the static prompt.
38
+ """
39
+
40
+ def __init__(self, *, prompt_builder: Optional[PromptBuilder] = None) -> None:
41
+ self._prompt_builder = prompt_builder
42
+ self.compliance_failures: int = 0
43
+
44
+ @abstractmethod
45
+ def _prompt(self, prompt: str) -> str:
46
+ """Send the assembled prompt to the LLM and return the raw response."""
47
+
48
+ def reset(self) -> None:
49
+ self.compliance_failures = 0
50
+
51
+ def step(self, env: BlindPathEnv, result: StepResult) -> str:
52
+ parts: list[str] = []
53
+
54
+ if self._prompt_builder is not None:
55
+ custom = self._prompt_builder(env, result)
56
+ if custom:
57
+ parts.append(custom.strip())
58
+
59
+ parts.append(env.get_static_prompt())
60
+ parts.append(f"\nCURRENT VISION GRID:\n{env.get_vision_string()}")
61
+
62
+ raw = self._prompt("\n\n".join(parts))
63
+ action = parse_action(raw)
64
+
65
+ if action is None:
66
+ self.compliance_failures += 1
67
+ action = random.choice(list(ACTIONS.keys()))
68
+
69
+ return action