1919import re
2020import subprocess
2121import sys
22+ import time
2223import urllib .request
2324from pathlib import Path
2425
2930MODEL = os .environ .get ("IMPROVE_MODEL" , "qwen2.5-coder:1.5b" )
3031OLLAMA_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.
4035MAX_FILE_BYTES = int (os .environ .get ("IMPROVE_MAX_FILE_BYTES" , "10000" ))
4742NUM_CTX = int (os .environ .get ("IMPROVE_NUM_CTX" , "8192" ))
4843REQUEST_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+
5052TEXT_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-
214189def 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
386356def 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 "
0 commit comments