File size: 36,837 Bytes
ddb2889 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 | from __future__ import annotations
import argparse
import ast
import json
import socket
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from life_game.code_mutation import ( # noqa: E402
CodeMutationSpecError,
apply_code_mutation_spec,
apply_method_replacement_spec,
apply_unified_diff_patch,
code_mutation_diff_stats,
code_mutation_spec_to_dict,
extract_unified_diff_patch,
method_replacement_spec_to_dict,
parse_code_mutation_spec,
parse_method_replacement_spec,
parse_unified_diff_patch_spec,
render_method_replacement_diff,
render_code_mutation_diff,
)
from life_game.code_extensions import format_extension_briefs, select_extension_brief # noqa: E402
from life_game.game import GAME_REGISTRY # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description="Ask the LLM for a constrained game-code mutation proposal.")
parser.add_argument("--mode", choices=sorted(GAME_REGISTRY), help="Game mode whose code should be modified.")
parser.add_argument("--intent", help="Developer intent for the code change.")
parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1")
parser.add_argument("--model", default="signal-garden-qwen35-code-mutation")
parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument("--max-tokens", type=int, default=2048)
parser.add_argument("--common-chars", type=int, default=1200)
parser.add_argument("--output", default="annotations/code_mutation_proposals.jsonl")
parser.add_argument("--from-record", help="Replay a JSONL proposal record instead of querying the LLM.")
parser.add_argument("--record-index", type=int, default=-1)
parser.add_argument("--list-extensions", action="store_true", help="Print detailed extension briefs for --mode.")
parser.add_argument("--extension-id", help="Use one detailed extension brief as the mutation target.")
parser.add_argument("--ambitious", action="store_true", help="Bias the prompt toward 30+ changed-line patches.")
parser.add_argument("--min-changed-lines", type=int, default=0, help="Reject proposals below this diff size.")
parser.add_argument("--repair-attempts", type=int, default=0, help="Ask the LLM to repair invalid patch output.")
parser.add_argument(
"--proposal-format",
choices=("auto", "line-ranges", "method-replacements", "json-patch", "unified-diff"),
default="auto",
help="Use JSON line ranges for small edits or whole-method replacement JSON for larger patches.",
)
parser.add_argument("--print-diff", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--apply", action="store_true", help="Write the proposed exact edits into this worktree.")
args = parser.parse_args()
if args.list_extensions:
if not args.mode:
print("Choose --mode to list detailed extension briefs for a game.", file=sys.stderr)
return 1
try:
print(format_extension_briefs(args.mode, args.extension_id))
except KeyError as exc:
print(f"code mutation failed: {exc}", file=sys.stderr)
return 1
return 0
min_changed_lines = max(0, int(args.min_changed_lines))
if args.ambitious:
args.max_tokens = max(args.max_tokens, 2560)
args.common_chars = max(args.common_chars, 1200)
proposal_format = args.proposal_format
if proposal_format == "auto":
proposal_format = "method-replacements" if args.ambitious or args.extension_id or min_changed_lines >= 30 else "line-ranges"
repair_attempts = max(0, int(args.repair_attempts))
if args.ambitious and proposal_format in {"json-patch", "method-replacements"}:
repair_attempts = max(repair_attempts, 1)
raw = ""
prompt_messages: Any = []
mode = args.mode or ""
intent = args.intent or ""
model = args.model
base_url = args.base_url
patch_spec: dict[str, Any] | None = None
method_spec = None
try:
if args.from_record:
source_record = _load_jsonl_record(Path(args.from_record), args.record_index)
raw = str(source_record.get("raw_response") or "")
prompt_messages = source_record.get("prompt_messages") or []
proposal_format = str(source_record.get("proposal_format") or source_record.get("format") or proposal_format)
mode = str(source_record.get("mode") or "")
intent = str(source_record.get("intent") or "")
model = str(source_record.get("model") or args.model)
base_url = str(source_record.get("base_url") or args.base_url)
if proposal_format == "method-replacements":
method_spec_payload = source_record.get("method_spec")
if method_spec_payload:
method_spec = parse_method_replacement_spec(json.dumps(method_spec_payload))
else:
method_spec = parse_method_replacement_spec(raw)
_validate_required_method_replacements(method_spec, mode, args.extension_id)
preview_diff = render_method_replacement_diff(method_spec, ROOT)
targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=True)
elif proposal_format in {"json-patch", "unified-diff"}:
patch_spec = source_record.get("patch_spec") if isinstance(source_record.get("patch_spec"), dict) else None
preview_diff = str(source_record.get("patch") or source_record.get("diff") or raw)
targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
else:
spec_payload = source_record.get("parsed_spec")
spec = parse_code_mutation_spec(json.dumps(spec_payload))
preview_diff = render_code_mutation_diff(spec, ROOT)
targets = apply_code_mutation_spec(spec, ROOT, dry_run=True)
else:
if not args.mode or not args.intent:
raise RuntimeError("--mode and --intent are required unless --from-record is used.")
mode = args.mode
intent = args.intent
model = args.model
base_url = args.base_url
prompt_messages = _prompt_messages(
mode,
intent,
args.common_chars,
args.extension_id,
args.ambitious,
min_changed_lines,
proposal_format,
)
last_error = ""
for attempt in range(repair_attempts + 1):
if attempt > 0:
prompt_messages = _repair_prompt_messages(proposal_format, raw, last_error)
raw = _post_chat_completion(
args.base_url,
args.model,
prompt_messages,
args.timeout_seconds,
args.max_tokens,
json_response=proposal_format in {"line-ranges", "method-replacements", "json-patch"},
)
try:
if proposal_format == "method-replacements":
method_spec = parse_method_replacement_spec(raw)
_validate_required_method_replacements(method_spec, mode, args.extension_id)
preview_diff = render_method_replacement_diff(method_spec, ROOT)
targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=True)
elif proposal_format == "json-patch":
patch_spec = parse_unified_diff_patch_spec(raw)
preview_diff = str(patch_spec["patch"])
targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
elif proposal_format == "unified-diff":
preview_diff = extract_unified_diff_patch(raw)
targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
else:
spec = parse_code_mutation_spec(raw)
preview_diff = render_code_mutation_diff(spec, ROOT)
targets = apply_code_mutation_spec(spec, ROOT, dry_run=True)
except (CodeMutationSpecError, RuntimeError) as exc:
last_error = str(exc)
if attempt >= repair_attempts:
raise
continue
break
diff_stats = code_mutation_diff_stats(preview_diff)
if diff_stats.changed_lines < min_changed_lines:
raise RuntimeError(
f"proposal changed {diff_stats.changed_lines} lines, below requested minimum {min_changed_lines}"
)
if args.apply:
if proposal_format == "method-replacements":
targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=False)
elif proposal_format in {"json-patch", "unified-diff"}:
targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=False)
else:
targets = apply_code_mutation_spec(spec, ROOT, dry_run=False)
except (CodeMutationSpecError, RuntimeError, KeyError) as exc:
if raw or prompt_messages:
_record_failure(args.output, mode, intent, model, base_url, proposal_format, prompt_messages, raw, str(exc))
print(f"code mutation failed: {exc}", file=sys.stderr)
return 1
record = {
"schema": "signal-garden-code-mutation-proposal/v1",
"mode": mode,
"intent": intent,
"model": model,
"base_url": base_url,
"proposal_format": proposal_format,
"prompt_messages": prompt_messages,
"raw_response": raw,
"parsed_spec": code_mutation_spec_to_dict(spec) if proposal_format == "line-ranges" else None,
"method_spec": method_replacement_spec_to_dict(method_spec) if proposal_format == "method-replacements" else None,
"patch_spec": patch_spec if proposal_format == "json-patch" else None,
"patch": preview_diff if proposal_format in {"json-patch", "unified-diff"} else None,
"diff": preview_diff,
"diff_stats": {
"added_lines": diff_stats.added_lines,
"removed_lines": diff_stats.removed_lines,
"changed_lines": diff_stats.changed_lines,
},
"applied": bool(args.apply),
"targets": [str(path.relative_to(ROOT)) for path in targets],
}
output_path = ROOT / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
action = "Applied" if args.apply else "Validated"
if proposal_format == "line-ranges":
summary = spec.summary
elif proposal_format == "method-replacements":
summary = method_spec.summary
else:
summary = "unified diff patch"
print(f"{action} proposal: {summary}")
print(
f"Diff size: +{diff_stats.added_lines}/-{diff_stats.removed_lines} "
f"({diff_stats.changed_lines} changed lines)"
)
for target in targets:
print(f"- {target.relative_to(ROOT)}")
if args.print_diff and preview_diff:
print()
print(preview_diff.rstrip())
print(f"Recorded proposal in {_display_path(output_path)}")
if not args.apply:
print("Rerun with --apply in this worktree to write the exact edits.")
return 0
def _prompt_messages(
mode: str,
intent: str,
common_chars: int,
extension_id: str | None,
ambitious: bool,
min_changed_lines: int,
proposal_format: str,
) -> list[dict[str, str]]:
game = GAME_REGISTRY[mode]
game_path = _module_path(game.__class__.__module__)
game_source = game_path.read_text(encoding="utf-8")
common_source = (ROOT / "life_game/games/common.py").read_text(encoding="utf-8")
regions = _python_regions(game_source)
selected_extension = select_extension_brief(mode, extension_id) if extension_id else None
extension_briefs = _prompt_extension_briefs(mode, selected_extension)
source_context = _source_context(game_path.relative_to(ROOT), game_source, selected_extension, proposal_format)
helper_context = _helper_context(game_source, common_source, common_chars)
method_scope_text = _method_scope_text(selected_extension, proposal_format)
signatures_text = _method_signature_text(game_source, selected_extension.required_methods if selected_extension else ())
required_method_text = (
f"- Required replacement methods for this extension: {', '.join(selected_extension.required_methods)}.\n"
if selected_extension and selected_extension.required_methods
else ""
)
semantic_requirements = _semantic_requirement_text(mode, extension_id)
target_lines = max(
min_changed_lines,
selected_extension.target_change_lines if selected_extension and ambitious else 30 if ambitious else 0,
)
ambition_rules = (
f"- This is a material extension request: target at least {target_lines} changed diff lines.\n"
"- Prefer 2-4 coordinated complete method replacements over a one-line tweak.\n"
"- Do not answer with a tiny constant tweak; proposals below the target changed-line count are rejected.\n"
"- If you cannot safely reach the target size, make the biggest coherent patch rather than padding with noise."
if target_lines
else "- Keep the patch compact unless the requested mechanic needs a larger change."
)
response_contract = _response_contract(proposal_format)
user = f"""
Mode: {mode}
Objective: {game.objective}
Developer intent: {intent}
You may modify game code in this experimental git worktree.
{response_contract}
Rules:
- For material changes, replace complete methods from the replaceable regions list.
{required_method_text.rstrip()}
{semantic_requirements.rstrip()}
{method_scope_text.rstrip()}
- Only modify life_game/games/*.py, tests/test_*.py, or USAGE_hackathon-clean.md.
- Prefer changing only {game_path.relative_to(ROOT)}; include a test edit only when it is essential.
- Keep the game deterministic enough for tests and compatible with GameMode/GameModel.
- Do not add new third-party dependencies.
{ambition_rules}
Extension brief:
{extension_briefs}
{source_context}
Replaceable regions in {game_path.relative_to(ROOT)}:
{regions}
{signatures_text}
Imported helper excerpts:
```python
{helper_context}
```
"""
return [
{
"role": "system",
"content": _system_prompt(proposal_format),
},
{"role": "user", "content": user.strip()},
]
def _module_path(module_name: str) -> Path:
return ROOT / Path(*module_name.split(".")).with_suffix(".py")
def _numbered_source(source: str) -> str:
return "\n".join(f"{line_number:04d}: {line}" for line_number, line in enumerate(source.splitlines(), start=1))
def _source_context(
path: Path,
source: str,
selected_extension: Any,
proposal_format: str,
) -> str:
required_methods = tuple(selected_extension.required_methods) if selected_extension else ()
if proposal_format == "method-replacements" and required_methods:
excerpt = _numbered_method_excerpt(source, required_methods)
return f"Relevant numbered excerpts from {path}:\n```python\n{excerpt}\n```"
return f"Numbered {path}:\n```python\n{_numbered_source(source)}\n```"
def _numbered_method_excerpt(source: str, method_names: tuple[str, ...]) -> str:
lines = source.splitlines()
try:
tree = ast.parse(source)
except SyntaxError:
return _numbered_source(source)
ranges: list[tuple[int, int]] = []
first_method = len(lines) + 1
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
method_nodes = [item for item in node.body if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))]
if not method_nodes:
continue
first_method = min(first_method, min(item.lineno for item in method_nodes))
for item in method_nodes:
if item.name in method_names and item.end_lineno is not None:
ranges.append((item.lineno, item.end_lineno))
header_end = max(1, first_method - 1)
merged = _merge_ranges([(1, header_end), *ranges])
chunks: list[str] = []
previous_end = 0
for start, end in merged:
if chunks and start > previous_end + 1:
chunks.append(".... omitted unchanged methods ....")
for line_number in range(start, end + 1):
if 1 <= line_number <= len(lines):
chunks.append(f"{line_number:04d}: {lines[line_number - 1]}")
previous_end = end
return "\n".join(chunks)
def _merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
cleaned = sorted((start, end) for start, end in ranges if start <= end)
if not cleaned:
return []
merged = [cleaned[0]]
for start, end in cleaned[1:]:
previous_start, previous_end = merged[-1]
if start <= previous_end + 1:
merged[-1] = (previous_start, max(previous_end, end))
else:
merged.append((start, end))
return merged
def _prompt_extension_briefs(mode: str, selected_extension: Any) -> str:
if selected_extension is None:
return format_extension_briefs(mode, None)
lines = [
f"- {selected_extension.extension_id}: {selected_extension.title}",
f" Goal: {selected_extension.player_facing_goal}",
" Implement:",
*(f" - {note}" for note in selected_extension.implementation_notes),
" Guardrails:",
*(f" - {risk}" for risk in selected_extension.risk_notes),
f" Required methods: {', '.join(selected_extension.required_methods or ('none',))}",
]
return "\n".join(lines)
def _method_scope_text(selected_extension: Any, proposal_format: str) -> str:
if proposal_format != "method-replacements" or selected_extension is None or not selected_extension.required_methods:
return ""
methods = ", ".join(selected_extension.required_methods)
return (
f"- Replace only these required methods unless the brief explicitly requires another method: {methods}.\n"
"- Do not replace private helper methods such as names starting with `_`; call them with their existing signature.\n"
)
def _method_signature_text(source: str, method_names: tuple[str, ...]) -> str:
if not method_names:
return ""
signatures = _method_signatures(source, method_names)
if not signatures:
return ""
return "Required method signatures:\n" + "\n".join(f"- {signature}" for signature in signatures)
def _method_signatures(source: str, method_names: tuple[str, ...]) -> list[str]:
lines = source.splitlines()
try:
tree = ast.parse(source)
except SyntaxError:
return []
wanted = set(method_names)
signatures: list[str] = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted:
line = lines[node.lineno - 1].strip()
current = node.lineno
while not line.rstrip().endswith(":") and len(line) < 240 and current < len(lines):
line += " " + lines[current].strip()
current += 1
signatures.append(line)
return signatures
def _helper_context(game_source: str, common_source: str, common_chars: int) -> str:
names = _common_import_names(game_source)
excerpt = _common_symbol_excerpt(common_source, names, max(0, int(common_chars)))
if excerpt:
return excerpt
return common_source[: max(0, int(common_chars))]
def _common_import_names(source: str) -> tuple[str, ...]:
try:
tree = ast.parse(source)
except SyntaxError:
return ()
names: list[str] = []
for node in tree.body:
if not isinstance(node, ast.ImportFrom) or node.module != "common":
continue
for alias in node.names:
if alias.name not in names:
names.append(alias.name)
return tuple(names)
def _common_symbol_excerpt(source: str, names: tuple[str, ...], limit: int) -> str:
if limit <= 0 or not names:
return ""
lines = source.splitlines()
try:
tree = ast.parse(source)
except SyntaxError:
return source[:limit]
wanted = set(names)
chunks: list[str] = []
for node in tree.body:
node_names: tuple[str, ...] = ()
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
node_names = (node.name,)
elif isinstance(node, ast.Assign):
node_names = tuple(target.id for target in node.targets if isinstance(target, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
node_names = (node.target.id,)
if not wanted.intersection(node_names) or node.end_lineno is None:
continue
chunk = "\n".join(lines[line_number - 1] for line_number in range(node.lineno, node.end_lineno + 1))
candidate = "\n\n".join((*chunks, chunk))
if len(candidate) <= limit:
chunks.append(chunk)
continue
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
signature = _function_signature_excerpt(lines, node)
candidate = "\n\n".join((*chunks, signature))
if len(candidate) <= limit:
chunks.append(signature)
if len("\n\n".join(chunks)) >= limit:
break
return "\n\n".join(chunks).strip()
def _function_signature_excerpt(lines: list[str], node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
signature = lines[node.lineno - 1]
current = node.lineno
while not signature.rstrip().endswith(":") and current < len(lines):
signature += "\n" + lines[current]
current += 1
return signature + "\n ..."
def _response_contract(proposal_format: str) -> str:
if proposal_format == "unified-diff":
return """Return only a unified diff patch. Do not wrap it in JSON. Do not include prose.
The patch must:
- use `diff --git a/path b/path` file headers,
- include enough context for `git apply --check`,
- preserve Python indentation,
- include only allowed paths."""
if proposal_format == "method-replacements":
return """Return JSON only with this schema:
{
"summary": "short imperative summary",
"rationale": "why the code change helps the game",
"replacements": [
{
"path": "life_game/games/example.py",
"class_name": "ExampleGame",
"method_name": "advance",
"content": " def advance(self, model, game, rule):\n ..."
}
],
"tests": ["uv run pytest tests/test_game.py"]
}
For method replacements:
- content is the complete replacement method, including the `def method_name(...)` line.
- Preserve the exact existing signature for every replaced method.
- Unindented method content is accepted and will be indented into the class.
- Replace required methods from the prompt; avoid private helpers unless explicitly required.
- Use existing imports/helpers only; do not invent `self.some_new_method(...)` or new imports.
- Store new state in `game.data` and existing GameModel fields such as bullets, enemies, towers, boss, claimed, or pickups."""
if proposal_format == "json-patch":
return """Return JSON only with this schema:
{
"summary": "short imperative summary",
"rationale": "why the code change helps the game",
"patch": "unified diff patch text with escaped newline characters",
"tests": ["uv run pytest tests/test_game.py"]
}
For the patch string:
- use `diff --git a/path b/path` file headers,
- include enough context for `git apply --check`,
- preserve Python indentation,
- include only allowed paths,
- make the patch material when requested, usually by replacing complete methods."""
return """Return JSON only with this schema:
{
"summary": "short imperative summary",
"rationale": "why the code change helps the game",
"edits": [
{
"path": "life_game/games/example.py",
"purpose": "why this exact edit changes behavior",
"start_line": 10,
"end_line": 12,
"new_lines": ["replacement line 1", "replacement line 2"]
}
],
"tests": ["uv run pytest tests/test_game.py"]
}
For line-range JSON:
- start_line and end_line are 1-based inclusive line numbers from the numbered source.
- Use JSON string arrays for new_lines.
- Preserve indentation inside each line string.
- The apply tool joins new_lines with newline characters and a final newline."""
def _system_prompt(proposal_format: str) -> str:
addendum = (
" Build material game mechanics as deterministic local changes: store new state in game.data or existing "
"GameModel masks, update player-visible messages/progress, prefer complete new()/command()/advance()/progress() "
"method replacements, and avoid app UI, renderer, replay, dependency, or multi-file architecture work unless "
"the prompt explicitly requests it."
)
if proposal_format == "unified-diff":
return "You are an expert Python game engineer. Return only a valid unified diff patch." + addendum
if proposal_format == "method-replacements":
return "You are an expert Python game engineer. Return only JSON with complete Python method replacements." + addendum
if proposal_format == "json-patch":
return "You are an expert Python game engineer. Return only JSON with a valid unified diff patch string." + addendum
return "You are an expert Python game engineer. Return only validated JSON for exact code edits." + addendum
def _semantic_requirement_text(mode: str, extension_id: str | None) -> str:
if mode == "Firewall Defender" and extension_id == "spread_charge":
return (
"- Semantic requirements: new() must initialize charge/max_charge/charge_cooldown; "
"command() must create a real multi-bullet spread shot when charged by directly assigning "
"a new bullets tuple on the returned GameModel, not by calling an invented helper; "
"advance() must increment charge over time and maintain cooldown state.\n"
)
if mode == "Drone Swarm" and extension_id in {"anchor_modes", "overclock_pulse"}:
return (
"- Semantic requirements: command() must intentionally switch formation state; "
"advance() must preserve or update towers according to that explicit state.\n"
)
if mode == "Gatekeeper" and extension_id == "typed_gates":
return (
"- Semantic requirements: new() must initialize typed gate state; command() must create gates with "
"a gate_type such as blocker, zapper, or slow; advance() must branch on gate_type and implement "
"distinct blocker, zapper, and slow behavior.\n"
)
if mode == "Data Vampire" and extension_id == "safe_feeding_zones":
return (
"- Semantic requirements: new() must initialize safe_zone or poison node state; command() or advance() "
"must create or spend safe feeding zone state; advance() must prevent enemies from occupying active safe "
"zones and must not use model.grid as food.\n"
)
if mode == "Gravity Well" and extension_id == "polarity_charge_heavies":
return (
"- Semantic requirements: new() must initialize polarity_cooldown, charge_meter, heavy_enemy, or "
"orbit_shield state; command() must gate polarity toggles through cooldown or charge; advance() must "
"make heavy enemies or orbit shield behavior mechanically different from normal enemies/core damage.\n"
)
return ""
def _repair_prompt_messages(proposal_format: str, raw_response: str, error: str) -> list[dict[str, str]]:
if proposal_format == "json-patch":
contract = _response_contract("json-patch")
elif proposal_format == "unified-diff":
contract = _response_contract("unified-diff")
elif proposal_format == "method-replacements":
contract = _response_contract("method-replacements")
else:
contract = _response_contract("line-ranges")
return [
{
"role": "system",
"content": _system_prompt(proposal_format),
},
{
"role": "user",
"content": f"""
Your previous code mutation output failed validation.
Validation error:
{error}
Previous output:
{raw_response[:12000]}
Return a corrected response that satisfies this contract:
{contract}
Important correction rules:
- Return the full corrected proposal, not only the method or hunk related to the validation error.
- Preserve every required replacement method from the original task; if the error names missing methods, include them.
- The patch must be accepted by `git apply --check`.
- In unified diff hunks, added Python code lines must start with exactly one `+` diff marker.
- The Python code after that marker must not contain an accidental extra leading `+`.
- Do not call invented helper methods. Method-replacement responses must be complete using existing helpers and GameModel fields.
- Preserve the exact existing method signature for every replaced method.
- Preserve indentation and enough context lines.
- Do not include prose outside the requested response format.
""".strip(),
},
]
def _validate_required_method_replacements(method_spec: Any, mode: str, extension_id: str | None) -> None:
if not extension_id:
return
brief = select_extension_brief(mode, extension_id)
if brief is None or not brief.required_methods:
return
replaced = {replacement.method_name for replacement in method_spec.replacements}
missing = [method for method in brief.required_methods if method not in replaced]
if missing:
raise RuntimeError(f"method replacement proposal is missing required method(s): {', '.join(missing)}")
_validate_extension_method_content(method_spec, mode, extension_id)
def _validate_extension_method_content(method_spec: Any, mode: str, extension_id: str) -> None:
replacements = {replacement.method_name: replacement.content for replacement in method_spec.replacements}
if mode == "Firewall Defender" and extension_id == "spread_charge":
_require_content("new", replacements.get("new", ""), ("charge", "max_charge", "charge_cooldown"))
_require_content("command", replacements.get("command", ""), ("charge", "max_charge", "bullets"))
_require_content("advance", replacements.get("advance", ""), ("charge", "max_charge", "charge_cooldown", "min("))
if mode == "Drone Swarm" and extension_id in {"anchor_modes", "overclock_pulse"}:
_require_content("command", replacements.get("command", ""), ("drone", "rotation"))
_require_content("advance", replacements.get("advance", ""), ("towers", "events"))
if mode == "Gatekeeper" and extension_id == "typed_gates":
_require_content("new", replacements.get("new", ""), ("gate_type",))
_require_content("command", replacements.get("command", ""), ("gate_type", "blocker", "zapper", "slow"))
_require_content("advance", replacements.get("advance", ""), ("gate_type", "blocker", "zapper", "slow"))
if mode == "Data Vampire" and extension_id == "safe_feeding_zones":
_require_content("new", replacements.get("new", ""), ("safe", "zone"))
_require_content("advance", replacements.get("advance", ""), ("safe", "zone", "poison"))
if mode == "Gravity Well" and extension_id == "polarity_charge_heavies":
_require_content("new", replacements.get("new", ""), ("polarity_cooldown", "charge_meter"))
_require_content("command", replacements.get("command", ""), ("polarity_cooldown", "charge_meter"))
_require_content("advance", replacements.get("advance", ""), ("heavy", "orbit_shield"))
def _require_content(method_name: str, content: str, required_terms: tuple[str, ...]) -> None:
missing = [term for term in required_terms if term not in content]
if missing:
raise RuntimeError(f"{method_name} replacement is missing required content: {', '.join(missing)}")
def _python_regions(source: str) -> str:
try:
tree = ast.parse(source)
except SyntaxError:
return "- unavailable: source did not parse"
regions: list[str] = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
regions.append(
f"- {node.name}.{item.name}: start_line {item.lineno}, end_line {item.end_lineno}"
)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
regions.append(f"- {node.name}: start_line {node.lineno}, end_line {node.end_lineno}")
return "\n".join(regions) if regions else "- no functions detected"
def _post_chat_completion(
base_url: str,
model: str,
messages: list[dict[str, str]],
timeout_seconds: float,
max_tokens: int,
json_response: bool,
) -> str:
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": 0.2,
"top_p": 0.9,
"max_tokens": max(256, int(max_tokens)),
}
if json_response:
payload["response_format"] = {"type": "json_object"}
endpoint = f"{base_url.rstrip('/')}/chat/completions"
request = urllib.request.Request(
endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=max(1.0, float(timeout_seconds))) as response:
response_body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} from {endpoint}: {detail[:240]}") from exc
except (socket.timeout, TimeoutError) as exc:
raise RuntimeError(f"timeout after {timeout_seconds:g}s contacting {endpoint}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"could not connect to {endpoint}: {exc.reason}") from exc
try:
parsed = json.loads(response_body)
return str(parsed["choices"][0]["message"].get("content") or "")
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
raise RuntimeError("LLM server returned an invalid chat completion response.") from exc
def _display_path(path: Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return str(path)
def _load_jsonl_record(path: Path, record_index: int) -> dict[str, Any]:
records: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line in handle:
stripped = line.strip()
if not stripped:
continue
parsed = json.loads(stripped)
if not isinstance(parsed, dict):
raise RuntimeError(f"Record in {path} is not a JSON object.")
records.append(parsed)
if not records:
raise RuntimeError(f"No records found in {path}.")
try:
return records[record_index]
except IndexError as exc:
raise RuntimeError(f"Record index {record_index} is outside {path}.") from exc
def _record_failure(
output: str,
mode: str,
intent: str,
model: str,
base_url: str,
proposal_format: str,
prompt_messages: Any,
raw: str,
error: str,
) -> None:
output_path = ROOT / output
output_path.parent.mkdir(parents=True, exist_ok=True)
record = {
"schema": "signal-garden-code-mutation-proposal/v1",
"status": "failed",
"mode": mode,
"intent": intent,
"model": model,
"base_url": base_url,
"proposal_format": proposal_format,
"prompt_messages": prompt_messages,
"raw_response": raw,
"error": error,
}
with output_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
if __name__ == "__main__":
raise SystemExit(main())
|