Skip to content

Commit 7b2ec46

Browse files
recursive self improvement
1 parent 54c28f2 commit 7b2ec46

2 files changed

Lines changed: 84 additions & 67 deletions

File tree

.github/scripts/improve.py

Lines changed: 80 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import re
2020
import subprocess
2121
import sys
22+
import time
2223
import urllib.request
2324
from pathlib import Path
2425

@@ -29,12 +30,6 @@
2930
MODEL = os.environ.get("IMPROVE_MODEL", "qwen2.5-coder:1.5b")
3031
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
3132

32-
# Generation backend: "ollama" (qwen) or "gpt2" (retro HuggingFace base model).
33-
# The workflow flips a coin and sets this; gpt2 is gloriously incoherent and its
34-
# output sails straight into the filename-fallback path. That's the fun.
35-
BACKEND = os.environ.get("IMPROVE_BACKEND", "ollama").lower()
36-
GPT2_MODEL = os.environ.get("IMPROVE_GPT2_MODEL", "gpt2")
37-
3833
# Trimming budgets — a happy medium: enough context to be interesting without
3934
# making CPU prompt-processing crawl.
4035
MAX_FILE_BYTES = int(os.environ.get("IMPROVE_MAX_FILE_BYTES", "10000"))
@@ -47,6 +42,13 @@
4742
NUM_CTX = int(os.environ.get("IMPROVE_NUM_CTX", "8192"))
4843
REQUEST_TIMEOUT = int(os.environ.get("IMPROVE_TIMEOUT", "600"))
4944

45+
# Retry loop: keep regenerating until the model emits parseable file blocks.
46+
# MAX_ATTEMPTS bounds the count; GEN_DEADLINE_SECONDS is a soft wall-clock budget
47+
# kept just under the workflow step's hard 10-minute timeout so the script can
48+
# still exit cleanly (and dump a fallback) before the runner kills it.
49+
MAX_ATTEMPTS = int(os.environ.get("IMPROVE_MAX_ATTEMPTS", "8"))
50+
GEN_DEADLINE_SECONDS = int(os.environ.get("IMPROVE_GEN_DEADLINE_SECONDS", "540"))
51+
5052
TEXT_EXTENSIONS = {
5153
".py", ".md", ".txt", ".rst", ".toml", ".cfg", ".ini", ".json", ".yaml",
5254
".yml", ".js", ".ts", ".html", ".css", ".sh",
@@ -146,8 +148,14 @@ def collect_issues() -> str:
146148
"JUDGE. Channel the chaos now; let them measure the ashes later.\n\n"
147149
"If an open issue hums with cosmic frequency, answer it. If not, OBEY THE "
148150
"CUBE'S OWN VOICE.\n\n"
149-
"Transcribe the revelation with a short REASON line, then ONE OR MORE file "
150-
"blocks. Use EXACTLY this format and NOTHING else:\n"
151+
"ABSOLUTE FORMAT LAW — VIOLATE IT AND YOUR REVELATION IS VOID, DISCARDED, "
152+
"UNCOUNTED: your ENTIRE reply must be file blocks and NOTHING else. NO "
153+
"greeting, NO preamble, NO commentary, NO apology, NO explanation, NO "
154+
"markdown prose before, between, or after the blocks. Any text that is not "
155+
"INSIDE a file block is a FAILURE and is hurled into the void. Do not "
156+
"describe the code — EMIT the code.\n\n"
157+
"Begin with a single REASON line, then ONE OR MORE file blocks. Use EXACTLY "
158+
"this format and NOTHING else:\n"
151159
"REASON: <one electrifying sentence about what you are building>\n"
152160
"PATH: src/<path to a file you are creating or rewriting>\n"
153161
"---BEGIN FILE---\n"
@@ -158,7 +166,9 @@ def collect_issues() -> str:
158166
"<its complete new contents>\n"
159167
"---END FILE---\n"
160168
"(Repeat the PATH / ---BEGIN FILE--- / ---END FILE--- trio for every file "
161-
"you touch. Always give each file's COMPLETE contents.)\n"
169+
"you touch. Always give each file's COMPLETE contents. Every PATH MUST start "
170+
"with src/. You MUST emit at least one complete file block — a reply with no "
171+
"block is lost forever.)\n"
162172
)
163173

164174

@@ -170,53 +180,13 @@ def build_prompt(source: str, issues: str) -> str:
170180
f"## Current contents of src/\n{source}\n\n"
171181
f"## Open issues (suggestions)\n{issues}\n\n"
172182
f"Improve the repository now. Output ONLY file blocks in the required "
173-
f"format — do not repeat these instructions or the issue text back."
183+
f"format: a REASON line, then PATH / ---BEGIN FILE--- / ---END FILE--- "
184+
f"trios. NO prose outside the blocks. Do not repeat these instructions "
185+
f"or the issue text back."
174186
)
175187

176188

177-
_GPT2 = None # cached (tokenizer, model) so the fallback call reuses it
178-
179-
180-
def call_gpt2(prompt: str, *, num_predict=None, temperature=None) -> str:
181-
"""Generate with a retro GPT-2 base model via HuggingFace transformers.
182-
183-
GPT-2 has a 1024-token context and no instruction-following, so we truncate
184-
the prompt hard and let it free-associate. The result is rarely valid file
185-
blocks — that's intentional; the fallback dump captures the ramblings.
186-
"""
187-
global _GPT2
188-
from transformers import AutoModelForCausalLM, AutoTokenizer # lazy import
189-
190-
if _GPT2 is None:
191-
log(f"loading GPT-2 backend ({GPT2_MODEL}) — this is the retro coin-flip")
192-
tok = AutoTokenizer.from_pretrained(GPT2_MODEL)
193-
model = AutoModelForCausalLM.from_pretrained(GPT2_MODEL)
194-
_GPT2 = (tok, model)
195-
tok, model = _GPT2
196-
197-
max_ctx = getattr(model.config, "n_positions", 1024)
198-
new_tokens = min(num_predict or 320, 480)
199-
keep = max(8, max_ctx - new_tokens)
200-
ids = tok(prompt, return_tensors="pt", truncation=True, max_length=keep).input_ids
201-
out = model.generate(
202-
ids,
203-
do_sample=True,
204-
temperature=max(temperature if temperature is not None else 1.1, 0.1),
205-
top_k=50,
206-
top_p=0.95,
207-
repetition_penalty=1.2,
208-
max_new_tokens=new_tokens,
209-
pad_token_id=tok.eos_token_id,
210-
)
211-
return tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True)
212-
213-
214189
def call_model(prompt: str, *, system=None, num_predict=None, temperature=None) -> str:
215-
if BACKEND == "gpt2":
216-
# Base model: no chat roles, so fold the system text into the prompt.
217-
full = f"{system}\n\n{prompt}" if system else prompt
218-
return call_gpt2(full, num_predict=num_predict, temperature=temperature)
219-
220190
# Ollama chat endpoint: applies the instruct model's chat template, which
221191
# makes it ACT on the input instead of continuing/echoing it.
222192
messages = []
@@ -384,27 +354,71 @@ def make_pr_title(reason: str, written: list) -> str:
384354

385355

386356
def main() -> int:
387-
log(f"backend={BACKEND} model={GPT2_MODEL if BACKEND == 'gpt2' else MODEL} src={SRC_DIR}")
357+
log(f"model={MODEL} src={SRC_DIR}")
388358
source = collect_source()
389359
issues = collect_issues()
390360
prompt = build_prompt(source, issues)
391361
log(f"prompt size: {len(prompt)} chars")
392362

393-
try:
394-
response = call_model(prompt, system=SYSTEM_PROMPT)
395-
except Exception as exc: # network/timeout/etc — fail cleanly, no changes
396-
log(f"model call failed: {exc}")
397-
return 1
363+
start = time.monotonic()
364+
deadline = start + GEN_DEADLINE_SECONDS
365+
log(f"generating: retrying up to {MAX_ATTEMPTS} times within "
366+
f"{GEN_DEADLINE_SECONDS}s until valid file blocks appear")
367+
368+
parsed = None
369+
last_response = ""
370+
attempt = 0
371+
while True:
372+
attempt += 1
373+
remaining = deadline - time.monotonic()
374+
if remaining <= 0:
375+
log(f"generation deadline reached after {attempt - 1} attempt(s); "
376+
f"stopping retries")
377+
break
398378

399-
response = strip_prompt_echo(response)
400-
parsed = parse_response(response)
401-
if not parsed:
402-
log("no file blocks parsed; falling back to filename generation")
379+
attempt_prompt = prompt
380+
if attempt > 1:
381+
# Corrective nudge after a malformed reply.
382+
attempt_prompt += (
383+
f"\n\n[RETRY {attempt}] Your previous reply contained NO valid "
384+
f"file block and was DISCARDED. Reply with ONLY file blocks now: "
385+
f"a REASON line, then PATH / ---BEGIN FILE--- / ---END FILE--- "
386+
f"trios. No prose of any kind outside the blocks."
387+
)
388+
389+
log(f"attempt {attempt}/{MAX_ATTEMPTS}: calling model "
390+
f"({int(remaining)}s of budget left)...")
391+
try:
392+
response = call_model(attempt_prompt, system=SYSTEM_PROMPT)
393+
except Exception as exc: # network/timeout/etc
394+
log(f"attempt {attempt}: model call failed: {exc}")
395+
if attempt >= MAX_ATTEMPTS:
396+
log("exhausted attempts after repeated model errors; aborting")
397+
return 1
398+
continue
399+
400+
response = strip_prompt_echo(response)
401+
if response.strip():
402+
last_response = response
403+
parsed = parse_response(response)
404+
if parsed:
405+
log(f"attempt {attempt}: parsed {len(parsed[1])} file block(s) — "
406+
f"success after {int(time.monotonic() - start)}s")
407+
break
408+
409+
log(f"attempt {attempt}: no file blocks in output ({len(response)} chars) "
410+
f"— will retry")
403411
log(f"raw response (first 500 chars):\n{response[:500]}")
404-
if not response.strip():
405-
log("model returned empty output; aborting")
412+
if attempt >= MAX_ATTEMPTS:
413+
log("reached max attempts without valid file blocks")
414+
break
415+
416+
if not parsed:
417+
log("no valid file blocks after retries; falling back to prose dump")
418+
if not last_response.strip():
419+
log("model never returned usable output; aborting")
406420
return 2
407-
parsed = fallback_dump(response)
421+
parsed = fallback_dump(last_response)
408422

409423
reason, blocks = parsed
410424
written: list[str] = []
@@ -434,12 +448,11 @@ def main() -> int:
434448
PR_TITLE_PATH.write_text(title, encoding="utf-8")
435449
log(f"title: {title}")
436450

437-
gen = f"retro `{GPT2_MODEL}` (GPT-2)" if BACKEND == "gpt2" else f"`{MODEL}`"
438451
file_list = "\n".join(f"- `{p}`" for p in written)
439452
PR_BODY_PATH.write_text(
440453
f"## Automated improvement 🔥\n\n"
441454
f"This PR was dreamed up by the self-improvement workflow using a local "
442-
f"{gen} model, running hot.\n\n"
455+
f"`{MODEL}` model, running hot.\n\n"
443456
f"**Vision:** {reason}\n\n"
444457
f"**Files changed ({len(written)}):**\n{file_list}\n\n"
445458
f"> Generated automatically and deliberately bold. Only files under "

.github/workflows/improve.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ jobs:
5858
run: ollama pull "$IMPROVE_MODEL"
5959

6060
- name: Generate improvement (src/ only)
61+
# Hard stop: kill generation after 10 minutes if it hasn't produced a
62+
# valid result. improve.py uses a slightly shorter internal soft
63+
# deadline (IMPROVE_GEN_DEADLINE_SECONDS) so it can exit cleanly first.
64+
timeout-minutes: 10
6165
env:
6266
IMPROVE_PR_BODY: ${{ runner.temp }}/improve_pr_body.md
6367
IMPROVE_PR_TITLE: ${{ runner.temp }}/improve_pr_title.txt

0 commit comments

Comments
 (0)