@@ -275,6 +275,22 @@ class ResponseRepairer: |
| 275 | retry_number: int, | 275 | retry_number: int, |
| 276 | max_empty_retries: int, | 276 | max_empty_retries: int, |
| 277 | ) -> str: | 277 | ) -> str: |
| | 278 | + blocked_asset_lines = self._blocked_html_asset_empty_retry_lines(dod) |
| | 279 | + if blocked_asset_lines: |
| | 280 | + return "\n".join( |
| | 281 | + [ |
| | 282 | + "[EMPTY ASSISTANT RESPONSE]", |
| | 283 | + ( |
| | 284 | + "Your last response was empty " |
| | 285 | + f"(retry {retry_number}/{max_empty_retries}) after a blocked " |
| | 286 | + "HTML asset reference. Retry the blocked mutation directly." |
| | 287 | + ), |
| | 288 | + *[f"- {line}" for line in blocked_asset_lines], |
| | 289 | + "", |
| | 290 | + "Emit that corrected mutation tool call now. Do not return an empty response.", |
| | 291 | + ] |
| | 292 | + ) |
| | 293 | + |
| 278 | if dod is not None: | 294 | if dod is not None: |
| 279 | quality_repair_message = self._build_quality_repair_empty_retry_message( | 295 | quality_repair_message = self._build_quality_repair_empty_retry_message( |
| 280 | retry_number=retry_number, | 296 | retry_number=retry_number, |
@@ -392,6 +408,77 @@ class ResponseRepairer: |
| 392 | ] | 408 | ] |
| 393 | ) | 409 | ) |
| 394 | | 410 | |
| | 411 | + def _blocked_html_asset_empty_retry_lines( |
| | 412 | + self, |
| | 413 | + dod: DefinitionOfDone | None, |
| | 414 | + ) -> list[str]: |
| | 415 | + messages = list(getattr(self.context.session, "messages", []) or []) |
| | 416 | + for message in reversed(messages[-8:]): |
| | 417 | + content = str(getattr(message, "content", "") or "") |
| | 418 | + if "HTML local asset references do not exist" not in content: |
| | 419 | + continue |
| | 420 | + missing_assets = _extract_missing_local_asset_hrefs(content) |
| | 421 | + if not missing_assets: |
| | 422 | + continue |
| | 423 | + tool_call = self._tool_call_for_tool_result(message, messages) |
| | 424 | + target = self._tool_call_file_target(tool_call) |
| | 425 | + if not target: |
| | 426 | + preferred_target = self._preferred_retry_target(dod) |
| | 427 | + target = str(preferred_target) if preferred_target is not None else "" |
| | 428 | + if not target: |
| | 429 | + continue |
| | 430 | + |
| | 431 | + asset_preview = ", ".join(f"`{asset}`" for asset in missing_assets[:3]) |
| | 432 | + return [ |
| | 433 | + ( |
| | 434 | + f"Last blocked HTML mutation target: " |
| | 435 | + f"`{display_runtime_path(target)}`." |
| | 436 | + ), |
| | 437 | + f"Missing local asset href(s): {asset_preview}.", |
| | 438 | + ( |
| | 439 | + "Retry the same file, but remove the entire stylesheet/image/script " |
| | 440 | + "tag that points at those missing hrefs, or create the referenced " |
| | 441 | + "asset first before linking it." |
| | 442 | + ), |
| | 443 | + ( |
| | 444 | + "Prefer removing the asset link and inlining necessary styling/content " |
| | 445 | + "unless the user explicitly requested a shared asset file." |
| | 446 | + ), |
| | 447 | + ( |
| | 448 | + "Do not include those missing href values in the next `write`, " |
| | 449 | + "`edit`, or `patch` content." |
| | 450 | + ), |
| | 451 | + ] |
| | 452 | + return [] |
| | 453 | + |
| | 454 | + def _tool_call_for_tool_result( |
| | 455 | + self, |
| | 456 | + tool_result_message: object, |
| | 457 | + messages: list[object], |
| | 458 | + ) -> ToolCall | None: |
| | 459 | + result_ids = { |
| | 460 | + str(getattr(result, "tool_call_id", "") or "") |
| | 461 | + for result in getattr(tool_result_message, "tool_results", []) or [] |
| | 462 | + if str(getattr(result, "tool_call_id", "") or "") |
| | 463 | + } |
| | 464 | + if not result_ids: |
| | 465 | + return None |
| | 466 | + for message in reversed(messages): |
| | 467 | + for tool_call in getattr(message, "tool_calls", []) or []: |
| | 468 | + if str(getattr(tool_call, "id", "") or "") in result_ids: |
| | 469 | + return tool_call |
| | 470 | + return None |
| | 471 | + |
| | 472 | + @staticmethod |
| | 473 | + def _tool_call_file_target(tool_call: ToolCall | None) -> str: |
| | 474 | + if tool_call is None: |
| | 475 | + return "" |
| | 476 | + return str( |
| | 477 | + tool_call.arguments.get("file_path") |
| | 478 | + or tool_call.arguments.get("path") |
| | 479 | + or "" |
| | 480 | + ).strip() |
| | 481 | + |
| 395 | def _build_early_concrete_write_retry_message( | 482 | def _build_early_concrete_write_retry_message( |
| 396 | self, | 483 | self, |
| 397 | dod: DefinitionOfDone, | 484 | dod: DefinitionOfDone, |
@@ -2010,6 +2097,17 @@ def _repair_line_is_html_quality(line: str) -> bool: |
| 2010 | return repair_line_is_html_quality(line) | 2097 | return repair_line_is_html_quality(line) |
| 2011 | | 2098 | |
| 2012 | | 2099 | |
| | 2100 | +def _extract_missing_local_asset_hrefs(content: str) -> list[str]: |
| | 2101 | + marker = "Missing local asset href(s):" |
| | 2102 | + if marker not in content: |
| | 2103 | + return [] |
| | 2104 | + tail = content.split(marker, 1)[1].strip() |
| | 2105 | + target_text = tail.split(". ", 1)[0].strip() |
| | 2106 | + if not target_text: |
| | 2107 | + return [] |
| | 2108 | + return [item.strip() for item in target_text.split(",") if item.strip()] |
| | 2109 | + |
| | 2110 | + |
| 2013 | def _should_encourage_initial_version( | 2111 | def _should_encourage_initial_version( |
| 2014 | *, | 2112 | *, |
| 2015 | target: Path, | 2113 | target: Path, |