Skip to content

Commit e7eb120

Browse files
fix: preflight gates so keeper Crashlanded failures are not model noise (#72)
* fix: preflight gates so keeper Crashlanded contract failures are not model noise Treat already-covered growing zones as success-by-state (check-zone / engine). Validate work_priority against WorkTypeDef names and stop sending id/priority as work types. Queue only available research. Require a living doctor/patient pair before tend. Hide and refuse stockpile_delete when the endpoint is missing. Co-authored-by: Jason <jkbennitt@users.noreply.github.com> * fix: accept skill= work_priority alias; allow re-targeting current research Felix integration fixtures emit {"skill": "growing", "priority": 1}. Treat skill as a WorkTypeDef alias. Re-queueing the current research project is allowed (it is available); only finished and locked projects are blocked. Co-authored-by: Jason <jkbennitt@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jason <jkbennitt@users.noreply.github.com>
1 parent e940ef8 commit e7eb120

17 files changed

Lines changed: 822 additions & 90 deletions

‎src/rle/harness/brief.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from pydantic import BaseModel, ConfigDict
1616

17-
from rle.rimapi.api_catalog import WRITE_CATALOG
17+
from rle.rimapi.api_catalog import visible_write_catalog
1818
from rle.rimapi.schemas import GameState
1919
from rle.rimapi.sse_client import RimAPIEvent
2020
from rle.scenarios.schema import ScenarioConfig
@@ -110,7 +110,7 @@ def action_catalog() -> list[dict[str, Any]]:
110110
out: list[dict[str, Any]] = [
111111
{"action_type": "no_action", "description": "Do nothing this tick.", "params": {}},
112112
]
113-
for name, raw in sorted(WRITE_CATALOG.items()):
113+
for name, raw in sorted(visible_write_catalog().items()):
114114
entry = cast(dict[str, Any], raw)
115115
out.append({
116116
"action_type": name,

‎src/rle/harness/felix/agents/base_role.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"- blueprint: Place a building. params: {\"def_name\": \"Wall\", \"x\": int, "
5757
"\"z\": int, \"stuff_def\": \"WoodLog\", \"rotation\": 0}.\n"
5858
"- growing_zone: Create growing zone. params: {\"plant_def\": \"Plant_Potato\", "
59-
"\"x1\": int, \"z1\": int, \"x2\": int, \"z2\": int}.\n"
59+
"\"x1\": int, \"z1\": int, \"x2\": int, \"z2\": int}. "
60+
"If a growing zone already covers the cells, do not recreate it.\n"
6061
"- stockpile_zone: Create stockpile. params: {\"x1\": int, \"z1\": int, "
6162
"\"x2\": int, \"z2\": int, \"name\": \"Supply\"}.\n"
6263
"- designate_area: Mine/harvest/deconstruct. params: {\"type\": \"Mine\", "
@@ -72,8 +73,9 @@
7273
"- time_assignment: Set schedule. params: {\"hours\": [18,19,20], "
7374
"\"assignment\": \"Joy\"}. target_colonist_id required.\n"
7475
"- bed_rest: Assign bed rest. target_colonist_id required.\n"
75-
"- tend: Have doctor tend patient. params: {\"doctor_pawn_id\": int}. "
76-
"target_colonist_id = patient.\n"
76+
"- tend: Have a living doctor tend a living patient. "
77+
"params: {\"doctor_pawn_id\": int}. target_colonist_id = patient. "
78+
"Both must be distinct living colonists.\n"
7779
"- toggle_power: Toggle building power. params: {\"building_id\": int, "
7880
"\"power_on\": true}.\n"
7981
"- no_action: Do nothing (use when colonists are already productive).\n\n"
@@ -117,7 +119,7 @@
117119
"best Construction→Construction=1, best Intellectual→Research=1\n"
118120
"- growing_zone: use FARM SITE coordinates from MAP_SUMMARY, "
119121
"plant_def=Plant_Rice (fastest food). Create this zone ONCE — if a "
120-
"growing zone already exists, do NOT create another (it will fail).\n\n"
122+
"growing zone already exists, do NOT create another.\n\n"
121123
"TICK 2 (SHELTER — most critical):\n"
122124
"- blueprint Wall: place 5x5 rectangle using SHELTER SITE from MAP_SUMMARY. "
123125
"Use stuff_def=WoodLog. Leave one gap for a Door.\n"

‎src/rle/mcp/server.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""RimAPI as an MCP tool server (requires the ``mcp`` extra).
22
3-
One tool per ``WRITE_CATALOG`` entry (executed immediately through
3+
One tool per visible ``WRITE_CATALOG`` entry (executed immediately through
44
``ActionExecutor`` and recorded in the tick ledger), a generic read tool over
55
``READ_CATALOG``, the harness-neutral brief, and ``end_turn``. Any MCP-capable
66
coding agent can attach and play; the harness packages that do so live in
@@ -15,7 +15,7 @@
1515
from mcp.server.mcpserver import MCPServer
1616

1717
from rle.mcp.session import McpSession
18-
from rle.rimapi.api_catalog import READ_CATALOG, WRITE_CATALOG
18+
from rle.rimapi.api_catalog import READ_CATALOG, visible_write_catalog
1919

2020
SERVER_NAME = "rle"
2121
INSTRUCTIONS = (
@@ -80,7 +80,7 @@ def end_turn(summary: str = "") -> str:
8080
n = len(session.ledger.actions)
8181
return f"Turn ended after {n} action(s)."
8282

83-
for name, raw in sorted(WRITE_CATALOG.items()):
83+
for name, raw in sorted(visible_write_catalog().items()):
8484
entry = cast(dict[str, Any], raw)
8585
server.add_tool(
8686
_make_action_tool(session, name),

‎src/rle/mcp/session.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,19 @@ async def act(
5151
) -> dict[str, Any]:
5252
"""Execute one write immediately, record it, and return the outcome."""
5353
self.ledger.require_active()
54+
endpoint = resolve_endpoint(action_type)
55+
if endpoint == "tend" and not target_colonist_id:
56+
target_colonist_id = str(
57+
(parameters or {}).get("patient_pawn_id")
58+
or (parameters or {}).get("patient_id")
59+
or ""
60+
) or None
5461
action = Action(
5562
action_type=action_type,
5663
target_colonist_id=target_colonist_id,
5764
parameters=dict(parameters or {}),
5865
reason=reason,
5966
)
60-
endpoint = resolve_endpoint(action_type)
6167
if endpoint == "no_action":
6268
self.ledger.record(action, None)
6369
return {"ok": True, "action_type": action_type, "note": "no-op recorded"}
@@ -74,6 +80,7 @@ async def act(
7480
"error": outcome.error}
7581
result = await self.executor.execute(
7682
ActionPlan(role=self.ledger.harness_name, tick=self.ledger.game_tick, actions=[action]),
83+
state=self.state,
7784
)
7885
if result.outcomes:
7986
outcome = result.outcomes[0]

‎src/rle/orchestration/action_executor.py‎

Lines changed: 94 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,18 @@
1313
ExecutionResult,
1414
resolve_endpoint,
1515
)
16+
from rle.orchestration.preflight import (
17+
VANILLA_WORK_TYPES,
18+
growing_zone_already_covers,
19+
is_already_covered_error,
20+
is_quarantined_write,
21+
normalize_work_priorities,
22+
research_target_status,
23+
resolve_tend_pair,
24+
)
1625
from rle.rimapi.api_catalog import WRITE_CATALOG
17-
from rle.rimapi.client import RimAPIClient, RimAPIResponseError
26+
from rle.rimapi.client import RimAPIClient, RimAPIConnectionError, RimAPIResponseError
27+
from rle.rimapi.schemas import GameState
1828

1929
__all__ = ["NEEDS_PAWN", "ActionExecutor", "ActionOutcome", "ExecutionResult"]
2030

@@ -47,31 +57,6 @@ def _extract_rimapi_error(detail: str) -> str:
4757
return detail
4858

4959

50-
def _normalize_work_priorities(params: dict[str, Any]) -> dict[str, int]:
51-
"""Accept the parameter shapes models actually emit for work_priority.
52-
53-
Documented shape is flat ``{"<WorkType>": <1-4>}``, but frontier models
54-
also emit ``{"work_type": "Research", "priority": 2}`` and
55-
``{"work_priorities": {"Growing": 1, ...}}`` (issue #27). Passing those
56-
through verbatim posted garbage like work="work_type" to RIMAPI.
57-
"""
58-
nested = params.get("work_priorities")
59-
if isinstance(nested, dict):
60-
return {str(work): int(pri) for work, pri in nested.items()}
61-
if "work_type" in params:
62-
return {str(params["work_type"]): int(params.get("priority", 1))}
63-
flat = {
64-
str(work): int(pri) for work, pri in params.items()
65-
if isinstance(pri, int) and not isinstance(pri, bool)
66-
}
67-
if not flat:
68-
raise ValueError(
69-
'work_priority requires {"<WorkType>": <1-4>} parameters '
70-
"(e.g. {\"Growing\": 1})"
71-
)
72-
return flat
73-
74-
7560
class ActionExecutor:
7661
"""Dispatches agent actions to RIMAPI write endpoints.
7762
@@ -85,15 +70,15 @@ def __init__(self, client: RimAPIClient) -> None:
8570
# Agents perseverate — they re-issue the same growing zone every tick.
8671
# Each repeat targets cells already owned by the first zone, which RIMAPI
8772
# rejects (and historically mislabelled as "Invalid plant definition").
88-
# We short-circuit overlapping repeats with an explicit error so the
89-
# agent learns the zone exists instead of burning ticks on doomed calls
90-
# (issue #33).
73+
# Overlapping repeats are success-by-state (the farm already exists).
9174
self._created_growing_zones: list[tuple[int, int, int, int]] = []
9275
# Same guard for stockpiles: RimWorld logs one "overwriting slot group
9376
# square" error PER CELL when a stockpile overlaps an existing one,
9477
# which spams the dev log (and pops it over the game when auto-open
9578
# is enabled).
9679
self._created_stockpile_zones: list[tuple[int, int, int, int]] = []
80+
self._tick_state: GameState | None = None
81+
self._work_types: frozenset[str] | None = None
9782

9883
@staticmethod
9984
def _rects_overlap(
@@ -103,8 +88,16 @@ def _rects_overlap(
10388
bx1, bz1, bx2, bz2 = b
10489
return not (ax2 < bx1 or ax1 > bx2 or az2 < bz1 or az1 > bz2)
10590

106-
async def execute(self, plan: ActionPlan) -> ExecutionResult:
107-
"""Execute all actions in a plan, return summary + per-action outcomes."""
91+
async def execute(
92+
self, plan: ActionPlan, state: GameState | None = None,
93+
) -> ExecutionResult:
94+
"""Execute all actions in a plan, return summary + per-action outcomes.
95+
96+
``state`` is the current colony snapshot used by preflight gates
97+
(research availability, living doctor/patient). Harnesses that omit
98+
it keep the write path; gates that need state are skipped.
99+
"""
100+
self._tick_state = state
108101
executed = 0
109102
failed = 0
110103
no_action_count = 0
@@ -158,6 +151,15 @@ async def _dispatch(self, action: Action, endpoint: str) -> None:
158151
cid = action.target_colonist_id or ""
159152
params = action.parameters
160153

154+
if is_quarantined_write(endpoint):
155+
raise ValueError(
156+
f"{endpoint} is quarantined — the endpoint is missing on "
157+
"this RIMAPI build. Do not advertise or call it."
158+
)
159+
160+
if endpoint == "tend" and (not cid or cid == "0"):
161+
cid = str(params.get("patient_pawn_id") or params.get("patient_id") or "")
162+
161163
# Skip pawn-targeting actions with no valid colonist ID
162164
if endpoint in _NEEDS_PAWN and (not cid or cid == "0"):
163165
logger.info("Skipping %s: no valid colonist ID", endpoint)
@@ -180,9 +182,22 @@ async def _dispatch(self, action: Action, endpoint: str) -> None:
180182

181183
# -- Specialized handlers (parameter mapping for complex DTOs) -----------
182184

185+
async def _allowed_work_types(self) -> frozenset[str]:
186+
if self._work_types is not None:
187+
return self._work_types
188+
try:
189+
names = await self._client.get_work_list()
190+
except (RimAPIResponseError, RimAPIConnectionError, TypeError, AttributeError):
191+
names = None
192+
if isinstance(names, list) and names:
193+
self._work_types = frozenset(str(item) for item in names)
194+
else:
195+
self._work_types = VANILLA_WORK_TYPES
196+
return self._work_types
197+
183198
async def _h_work_priority(self, cid: str, params: dict[str, Any]) -> None:
184199
await self._client.set_work_priorities(
185-
cid, _normalize_work_priorities(params),
200+
cid, normalize_work_priorities(params, await self._allowed_work_types()),
186201
)
187202

188203
async def _h_draft(self, cid: str, params: dict[str, Any]) -> None:
@@ -219,7 +234,8 @@ async def _h_bed_rest(self, cid: str, params: dict[str, Any]) -> None:
219234
await self._client.assign_bed_rest(cid, bed_building_id=params.get("bed_building_id"))
220235

221236
async def _h_tend(self, cid: str, params: dict[str, Any]) -> None:
222-
await self._client.administer_medicine(cid, doctor_id=params.get("doctor_id"))
237+
doctor_id, patient_id = resolve_tend_pair(cid, params, self._tick_state)
238+
await self._client.administer_medicine(patient_id, doctor_id=doctor_id)
223239

224240
async def _h_blueprint(self, cid: str, params: dict[str, Any]) -> None:
225241
if "x" not in params or "z" not in params:
@@ -240,22 +256,39 @@ async def _h_growing_zone(self, cid: str, params: dict[str, Any]) -> None:
240256
x2 = int(params.get("x2", x1 + 5))
241257
z2 = int(params.get("z2", z1 + 5))
242258
rect = (min(x1, x2), min(z1, z2), max(x1, x2), max(z1, z2))
259+
map_id = int(params.get("map_id", 0))
243260
if any(self._rects_overlap(rect, prev) for prev in self._created_growing_zones):
244-
raise ValueError(
245-
"A growing zone already covers these cells — it was created "
246-
"earlier this run. Do NOT recreate it; pick a different, "
247-
"non-overlapping rectangle or move on to another task."
261+
# Already satisfied this run — do not recreate or score as failure.
262+
return
263+
if await self._growing_zone_covered_on_map(map_id, x1, z1, x2, z2):
264+
self._created_growing_zones.append(rect)
265+
return
266+
try:
267+
await self._client.create_growing_zone(
268+
map_id=map_id,
269+
plant_def=params.get("plant_def", "Plant_Potato"),
270+
x1=x1,
271+
z1=z1,
272+
x2=x2,
273+
z2=z2,
248274
)
249-
await self._client.create_growing_zone(
250-
map_id=int(params.get("map_id", 0)),
251-
plant_def=params.get("plant_def", "Plant_Potato"),
252-
x1=x1,
253-
z1=z1,
254-
x2=x2,
255-
z2=z2,
256-
)
275+
except RimAPIResponseError as exc:
276+
if is_already_covered_error(_extract_rimapi_error(exc.detail)):
277+
self._created_growing_zones.append(rect)
278+
return
279+
raise
257280
self._created_growing_zones.append(rect)
258281

282+
async def _growing_zone_covered_on_map(
283+
self, map_id: int, x1: int, z1: int, x2: int, z2: int,
284+
) -> bool:
285+
"""Ask ``POST /builder/check-zone`` whether a zone already owns these cells."""
286+
try:
287+
payload = await self._client.check_zone(map_id, x1, z1, x2, z2)
288+
except (RimAPIResponseError, RimAPIConnectionError, AttributeError, TypeError):
289+
return False
290+
return growing_zone_already_covers(payload)
291+
259292
# RimWorld's StoragePriority is semantic; models reasonably emit the
260293
# words. Map them instead of crashing in int() (found in the 2026-06-11
261294
# spread: fable5 sent priority="important").
@@ -324,8 +357,22 @@ async def _h_toggle_power(self, cid: str, params: dict[str, Any]) -> None:
324357
)
325358

326359
async def _h_research_target(self, cid: str, params: dict[str, Any]) -> None:
327-
project = params.get("project", params.get("name", ""))
328-
await self._client.set_research_target(project, force=params.get("force", False))
360+
project = str(params.get("project", params.get("name", "")))
361+
force = bool(params.get("force", False))
362+
research = None if self._tick_state is None else self._tick_state.research
363+
if research is not None and not force:
364+
status = research_target_status(project, research)
365+
if status == "finished":
366+
raise ValueError(
367+
f"Research project '{project}' is already finished."
368+
)
369+
if status == "locked":
370+
raise ValueError(
371+
f"Research project '{project}' is not currently available "
372+
"(prerequisites or research bench missing). Only queue "
373+
"projects listed in research.available."
374+
)
375+
await self._client.set_research_target(project, force=force)
329376

330377
async def _h_research_stop(self, cid: str, params: dict[str, Any]) -> None:
331378
await self._client.stop_research()

‎src/rle/orchestration/action_resolver.py‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from pydantic import BaseModel, ConfigDict
3131

3232
from rle.agents.actions import Action, ActionPlan, resolve_endpoint
33+
from rle.orchestration.preflight import extract_work_priorities
3334
from rle.rimapi.schemas import GameState
3435

3536
logger = logging.getLogger(__name__)
@@ -89,17 +90,8 @@ def _winner_key(ta: _TaggedAction) -> tuple[int, int, float, int]:
8990

9091

9192
def _work_priorities(params: dict[str, Any]) -> dict[str, int]:
92-
"""Accepted work_priority shapes, tolerant of garbage."""
93-
nested = params.get("work_priorities")
94-
if isinstance(nested, dict):
95-
return {str(work): int(pri) for work, pri in nested.items() if isinstance(pri, int)}
96-
if "work_type" in params:
97-
pri = params.get("priority", 1)
98-
return {str(params["work_type"]): int(pri)} if isinstance(pri, int) else {}
99-
return {
100-
str(work): pri for work, pri in params.items()
101-
if isinstance(pri, int) and not isinstance(pri, bool)
102-
}
93+
"""Accepted work_priority shapes; reserved DTO keys are not work types."""
94+
return extract_work_priorities(params)
10395

10496

10597
def _merge_work_priority(candidates: list[_TaggedAction]) -> Action:

‎src/rle/orchestration/game_loop.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ async def run_tick(self) -> TickResult:
429429

430430
# 4. Execute — unless the harness already applied its writes
431431
if step.execution is None:
432-
exec_result = await self._executor.execute(step.plan)
432+
exec_result = await self._executor.execute(step.plan, state=state)
433433
for outcome in exec_result.outcomes:
434434
self._emit(
435435
EventType.ACTION_EXEC, tick_num,

0 commit comments

Comments
 (0)