Anchor concrete write retries
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
edc01d795f8921397e2785603c9d214f27c50d8d- Parents
-
b5700b2 - Tree
e6b6f7d
edc01d7
edc01d795f8921397e2785603c9d214f27c50d8db5700b2
e6b6f7d| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/repair.py
|
100 | 0 |
| M |
tests/test_repair.py
|
156 | 0 |
src/loader/runtime/repair.pymodified@@ -429,6 +429,12 @@ class ResponseRepairer: | ||
| 429 | 429 | lines.append( |
| 430 | 430 | f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure." |
| 431 | 431 | ) |
| 432 | + reference_line = self._known_reference_structure_line( | |
| 433 | + concrete_target, | |
| 434 | + require_first_substantive_output=True, | |
| 435 | + ) | |
| 436 | + if reference_line: | |
| 437 | + lines.append(reference_line) | |
| 432 | 438 | if _should_encourage_initial_version( |
| 433 | 439 | target=concrete_target, |
| 434 | 440 | has_confirmed_output_file_progress=True, |
@@ -892,6 +898,15 @@ class ResponseRepairer: | ||
| 892 | 898 | lines.append( |
| 893 | 899 | f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure." |
| 894 | 900 | ) |
| 901 | + reference_line = self._known_reference_structure_line( | |
| 902 | + concrete_target, | |
| 903 | + require_first_substantive_output=( | |
| 904 | + has_confirmed_output_file_progress | |
| 905 | + and not has_confirmed_substantive_output_file_progress | |
| 906 | + ), | |
| 907 | + ) | |
| 908 | + if reference_line: | |
| 909 | + lines.append(reference_line) | |
| 895 | 910 | if _should_encourage_initial_version( |
| 896 | 911 | target=concrete_target, |
| 897 | 912 | has_confirmed_output_file_progress=has_confirmed_output_file_progress, |
@@ -957,6 +972,15 @@ class ResponseRepairer: | ||
| 957 | 972 | lines.append( |
| 958 | 973 | f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure." |
| 959 | 974 | ) |
| 975 | + reference_line = self._known_reference_structure_line( | |
| 976 | + inferred_pending_target, | |
| 977 | + require_first_substantive_output=( | |
| 978 | + has_confirmed_output_file_progress | |
| 979 | + and not has_confirmed_substantive_output_file_progress | |
| 980 | + ), | |
| 981 | + ) | |
| 982 | + if reference_line: | |
| 983 | + lines.append(reference_line) | |
| 960 | 984 | if todo_describes_aggregate_mutation(next_pending): |
| 961 | 985 | lines.insert( |
| 962 | 986 | 1, |
@@ -1057,6 +1081,15 @@ class ResponseRepairer: | ||
| 1057 | 1081 | lines.append( |
| 1058 | 1082 | f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure." |
| 1059 | 1083 | ) |
| 1084 | + reference_line = self._known_reference_structure_line( | |
| 1085 | + next_output_file, | |
| 1086 | + require_first_substantive_output=( | |
| 1087 | + has_confirmed_output_file_progress | |
| 1088 | + and not has_confirmed_substantive_output_file_progress | |
| 1089 | + ), | |
| 1090 | + ) | |
| 1091 | + if reference_line: | |
| 1092 | + lines.append(reference_line) | |
| 1060 | 1093 | if _should_encourage_initial_version( |
| 1061 | 1094 | target=next_output_file, |
| 1062 | 1095 | has_confirmed_output_file_progress=has_confirmed_output_file_progress, |
@@ -1338,6 +1371,68 @@ class ResponseRepairer: | ||
| 1338 | 1371 | return first_line or None |
| 1339 | 1372 | return None |
| 1340 | 1373 | |
| 1374 | + def _known_reference_structure_line( | |
| 1375 | + self, | |
| 1376 | + target: Path, | |
| 1377 | + *, | |
| 1378 | + require_first_substantive_output: bool, | |
| 1379 | + ) -> str | None: | |
| 1380 | + if not require_first_substantive_output: | |
| 1381 | + return None | |
| 1382 | + reference = self._best_known_reference_path(target) | |
| 1383 | + if reference is None: | |
| 1384 | + return None | |
| 1385 | + return ( | |
| 1386 | + f"You already read `{display_runtime_path(reference)}`; reuse its overall " | |
| 1387 | + "structure as the starting pattern for this new file, then adapt the content " | |
| 1388 | + "to the current target." | |
| 1389 | + ) | |
| 1390 | + | |
| 1391 | + def _best_known_reference_path(self, target: Path) -> Path | None: | |
| 1392 | + normalized_target = target.expanduser().resolve(strict=False) | |
| 1393 | + target_tokens = { | |
| 1394 | + token | |
| 1395 | + for token in re.split(r"[^a-z0-9]+", normalized_target.stem.lower()) | |
| 1396 | + if token | |
| 1397 | + } | |
| 1398 | + target_number = _leading_numeric_prefix(normalized_target.stem) | |
| 1399 | + messages = list(getattr(self.context.session, "messages", []) or []) | |
| 1400 | + candidates: list[tuple[int, str, Path]] = [] | |
| 1401 | + | |
| 1402 | + for message in messages: | |
| 1403 | + for tool_call in getattr(message, "tool_calls", []) or []: | |
| 1404 | + if getattr(tool_call, "name", "") != "read": | |
| 1405 | + continue | |
| 1406 | + raw_path = str(tool_call.arguments.get("file_path") or "").strip() | |
| 1407 | + if not raw_path: | |
| 1408 | + continue | |
| 1409 | + candidate = Path(raw_path).expanduser().resolve(strict=False) | |
| 1410 | + if candidate == normalized_target or not candidate.suffix: | |
| 1411 | + continue | |
| 1412 | + if candidate.suffix.lower() != normalized_target.suffix.lower(): | |
| 1413 | + continue | |
| 1414 | + score = 0 | |
| 1415 | + if candidate.name.lower() == normalized_target.name.lower(): | |
| 1416 | + score += 8 | |
| 1417 | + if candidate.parent.name.lower() == normalized_target.parent.name.lower(): | |
| 1418 | + score += 2 | |
| 1419 | + if target_number and _leading_numeric_prefix(candidate.stem) == target_number: | |
| 1420 | + score += 3 | |
| 1421 | + candidate_tokens = { | |
| 1422 | + token | |
| 1423 | + for token in re.split(r"[^a-z0-9]+", candidate.stem.lower()) | |
| 1424 | + if token | |
| 1425 | + } | |
| 1426 | + score += min(3, len(target_tokens & candidate_tokens)) | |
| 1427 | + if score <= 0: | |
| 1428 | + continue | |
| 1429 | + candidates.append((score, str(candidate), candidate)) | |
| 1430 | + | |
| 1431 | + if not candidates: | |
| 1432 | + return None | |
| 1433 | + candidates.sort(key=lambda item: (item[0], item[1]), reverse=True) | |
| 1434 | + return candidates[0][2] | |
| 1435 | + | |
| 1341 | 1436 | @staticmethod |
| 1342 | 1437 | def _mutation_tool_scaffold(path: Path, *, tool_name: str) -> str: |
| 1343 | 1438 | normalized_path = json.dumps(display_runtime_path(path)) |
@@ -1383,3 +1478,8 @@ def _should_encourage_initial_version( | ||
| 1383 | 1478 | if _is_summary_artifact_path(target): |
| 1384 | 1479 | return False |
| 1385 | 1480 | return not has_confirmed_substantive_output_file_progress |
| 1481 | + | |
| 1482 | + | |
| 1483 | +def _leading_numeric_prefix(stem: str) -> str: | |
| 1484 | + match = re.match(r"^(\d+)", stem) | |
| 1485 | + return match.group(1) if match else "" | |
tests/test_repair.pymodified@@ -1177,6 +1177,162 @@ def test_empty_response_retry_keeps_concrete_second_chapter_for_aggregate_chapte | ||
| 1177 | 1177 | assert "Follow the same full-payload one-file-at-a-time write pattern" in decision.retry_message |
| 1178 | 1178 | |
| 1179 | 1179 | |
| 1180 | +def test_empty_response_retry_reuses_known_reference_structure_for_first_substantive_file( | |
| 1181 | + temp_dir: Path, | |
| 1182 | +) -> None: | |
| 1183 | + context = build_context( | |
| 1184 | + temp_dir=temp_dir, | |
| 1185 | + use_react=False, | |
| 1186 | + ) | |
| 1187 | + repairer = ResponseRepairer(context) | |
| 1188 | + | |
| 1189 | + guide_root = temp_dir / "guides" / "nginx" | |
| 1190 | + chapters = guide_root / "chapters" | |
| 1191 | + chapters.mkdir(parents=True) | |
| 1192 | + index_path = guide_root / "index.html" | |
| 1193 | + reference_chapter = temp_dir / "guides" / "fortran" / "chapters" / "01-introduction.html" | |
| 1194 | + reference_chapter.parent.mkdir(parents=True) | |
| 1195 | + reference_chapter.write_text("<h1>Chapter 1: Introduction to Fortran</h1>\n") | |
| 1196 | + index_path.write_text( | |
| 1197 | + "\n".join( | |
| 1198 | + [ | |
| 1199 | + "<html>", | |
| 1200 | + '<a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a>', | |
| 1201 | + "</html>", | |
| 1202 | + ] | |
| 1203 | + ) | |
| 1204 | + + "\n" | |
| 1205 | + ) | |
| 1206 | + context.session.append( | |
| 1207 | + Message( | |
| 1208 | + role=Role.ASSISTANT, | |
| 1209 | + content="", | |
| 1210 | + tool_calls=[ | |
| 1211 | + ToolCall( | |
| 1212 | + id="call_ref", | |
| 1213 | + name="read", | |
| 1214 | + arguments={"file_path": str(reference_chapter)}, | |
| 1215 | + ) | |
| 1216 | + ], | |
| 1217 | + ) | |
| 1218 | + ) | |
| 1219 | + | |
| 1220 | + implementation_plan = temp_dir / "implementation.md" | |
| 1221 | + implementation_plan.write_text( | |
| 1222 | + "\n".join( | |
| 1223 | + [ | |
| 1224 | + "# Implementation Plan", | |
| 1225 | + "", | |
| 1226 | + "## File Changes", | |
| 1227 | + f"- `{guide_root}/`", | |
| 1228 | + f"- `{chapters}/`", | |
| 1229 | + f"- `{index_path}`", | |
| 1230 | + "", | |
| 1231 | + ] | |
| 1232 | + ) | |
| 1233 | + ) | |
| 1234 | + | |
| 1235 | + dod = create_definition_of_done("Create a multi-file nginx guide.") | |
| 1236 | + dod.implementation_plan = str(implementation_plan) | |
| 1237 | + dod.touched_files.append(str(index_path)) | |
| 1238 | + dod.completed_items.append("Develop the main index.html file with proper structure") | |
| 1239 | + dod.pending_items.append("Create chapter files following the established pattern") | |
| 1240 | + | |
| 1241 | + decision = repairer.handle_empty_response( | |
| 1242 | + task="Create a multi-file nginx guide.", | |
| 1243 | + original_task=None, | |
| 1244 | + empty_retry_count=1, | |
| 1245 | + max_empty_retries=2, | |
| 1246 | + dod=dod, | |
| 1247 | + ) | |
| 1248 | + | |
| 1249 | + assert decision.should_continue is True | |
| 1250 | + assert decision.retry_message is not None | |
| 1251 | + assert ( | |
| 1252 | + f"You already read `{display_runtime_path(reference_chapter)}`; reuse its overall structure " | |
| 1253 | + "as the starting pattern for this new file, then adapt the content to the current target." | |
| 1254 | + in decision.retry_message | |
| 1255 | + ) | |
| 1256 | + | |
| 1257 | + | |
| 1258 | +def test_compact_first_substantive_retry_reuses_known_reference_structure( | |
| 1259 | + temp_dir: Path, | |
| 1260 | +) -> None: | |
| 1261 | + context = build_context( | |
| 1262 | + temp_dir=temp_dir, | |
| 1263 | + use_react=False, | |
| 1264 | + ) | |
| 1265 | + repairer = ResponseRepairer(context) | |
| 1266 | + | |
| 1267 | + guide_root = temp_dir / "guides" / "nginx" | |
| 1268 | + chapters = guide_root / "chapters" | |
| 1269 | + chapters.mkdir(parents=True) | |
| 1270 | + index_path = guide_root / "index.html" | |
| 1271 | + reference_chapter = temp_dir / "guides" / "fortran" / "chapters" / "01-introduction.html" | |
| 1272 | + reference_chapter.parent.mkdir(parents=True) | |
| 1273 | + reference_chapter.write_text("<h1>Chapter 1: Introduction to Fortran</h1>\n") | |
| 1274 | + index_path.write_text( | |
| 1275 | + "\n".join( | |
| 1276 | + [ | |
| 1277 | + "<html>", | |
| 1278 | + '<a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a>', | |
| 1279 | + "</html>", | |
| 1280 | + ] | |
| 1281 | + ) | |
| 1282 | + + "\n" | |
| 1283 | + ) | |
| 1284 | + context.session.append( | |
| 1285 | + Message( | |
| 1286 | + role=Role.ASSISTANT, | |
| 1287 | + content="", | |
| 1288 | + tool_calls=[ | |
| 1289 | + ToolCall( | |
| 1290 | + id="call_ref", | |
| 1291 | + name="read", | |
| 1292 | + arguments={"file_path": str(reference_chapter)}, | |
| 1293 | + ) | |
| 1294 | + ], | |
| 1295 | + ) | |
| 1296 | + ) | |
| 1297 | + | |
| 1298 | + implementation_plan = temp_dir / "implementation.md" | |
| 1299 | + implementation_plan.write_text( | |
| 1300 | + "\n".join( | |
| 1301 | + [ | |
| 1302 | + "# Implementation Plan", | |
| 1303 | + "", | |
| 1304 | + "## File Changes", | |
| 1305 | + f"- `{guide_root}/`", | |
| 1306 | + f"- `{chapters}/`", | |
| 1307 | + f"- `{index_path}`", | |
| 1308 | + "", | |
| 1309 | + ] | |
| 1310 | + ) | |
| 1311 | + ) | |
| 1312 | + | |
| 1313 | + dod = create_definition_of_done("Create a multi-file nginx guide.") | |
| 1314 | + dod.implementation_plan = str(implementation_plan) | |
| 1315 | + dod.touched_files.append(str(index_path)) | |
| 1316 | + dod.completed_items.append("Develop the main index.html file with proper structure") | |
| 1317 | + dod.pending_items.append("Create chapter files following the established pattern") | |
| 1318 | + | |
| 1319 | + decision = repairer.handle_empty_response( | |
| 1320 | + task="Create a multi-file nginx guide.", | |
| 1321 | + original_task=None, | |
| 1322 | + empty_retry_count=3, | |
| 1323 | + max_empty_retries=4, | |
| 1324 | + dod=dod, | |
| 1325 | + ) | |
| 1326 | + | |
| 1327 | + assert decision.should_continue is True | |
| 1328 | + assert decision.retry_message is not None | |
| 1329 | + assert ( | |
| 1330 | + f"You already read `{display_runtime_path(reference_chapter)}`; reuse its overall structure " | |
| 1331 | + "as the starting pattern for this new file, then adapt the content to the current target." | |
| 1332 | + in decision.retry_message | |
| 1333 | + ) | |
| 1334 | + | |
| 1335 | + | |
| 1180 | 1336 | def test_empty_response_retry_prefers_output_index_over_reference_index_with_same_name( |
| 1181 | 1337 | temp_dir: Path, |
| 1182 | 1338 | ) -> None: |