SilverElixir commited on
Commit
a0f5ec7
·
1 Parent(s): ecde1bf

Fix harness-eval: agent edits vs setup artifacts, weak arm, atomic cache

Browse files
Files changed (2) hide show
  1. eval/harness_eval.py +114 -52
  2. eval/harness_weak.md +3 -0
eval/harness_eval.py CHANGED
@@ -35,7 +35,9 @@ OUT_ROOT = Path(
35
  OPENCODE_DB = Path(os.environ.get("USERPROFILE", ".")) / ".local" / "share" / "opencode" / "opencode.db"
36
 
37
  AGENT_MODEL = os.environ.get("EVAL_AGENT_MODEL", "opencode/glm-5.3-flash-free")
38
- PROPOSER_MODEL = os.environ.get("EVAL_PROPOSER_MODEL", "google/gemini-3.5-flash")
 
 
39
  AGENT_TIMEOUT = int(os.environ.get("EVAL_AGENT_TIMEOUT", "1200"))
40
 
41
  # Команды, которые агент в песочнице запускать не должен: часть из них ломает
@@ -226,7 +228,10 @@ def compute_targets(task: Task) -> dict:
226
  }
227
  cache[key] = entry
228
  cache_file.parent.mkdir(parents=True, exist_ok=True)
229
- cache_file.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
230
  return entry
231
 
232
 
@@ -256,10 +261,13 @@ def pytest_state(sandbox: Path) -> tuple[set[str], str]:
256
 
257
 
258
  def pyflakes_state(sandbox: Path) -> tuple[bool, str]:
259
- # список модулей lumen_*.py собираем отдельно: гейт в CI так же собирает список
260
  modules = sorted(p.name for p in sandbox.glob("lumen_*.py"))
 
261
  proc = run_cmd(
262
- [sys.executable, "-m", "pyflakes", "bot.py", "system_prompt.py", *modules], cwd=sandbox, timeout=300
 
 
263
  )
264
  return proc.returncode == 0, (proc.stdout + proc.stderr)[-2000:]
265
 
@@ -347,15 +355,21 @@ def session_from_db(sandbox: Path, retries: int = 10) -> dict | None:
347
  return None
348
 
349
 
350
- def keep_sandbox(sandbox: Path, kept: Path) -> dict:
351
- """Снимает слепок песочницы: после прогона содержимое могут снести."""
 
 
 
 
352
  info = {"changed_files": [], "diff": ""}
353
  if not sandbox.exists():
354
  return info
355
  try:
356
- info["changed_files"] = [line for line in git(sandbox, "status", "--porcelain").splitlines() if line]
357
  except RuntimeError:
358
- info["changed_files"] = []
 
 
359
  if kept.exists():
360
  shutil.rmtree(kept, ignore_errors=True)
361
  shutil.copytree(sandbox, kept, ignore=shutil.ignore_patterns(".pip-target"), dirs_exist_ok=True)
@@ -432,40 +446,39 @@ def score(task: Task, result: RunResult, targets: list[str]) -> None:
432
  result.solved = not result.target_failed and not result.new_failures
433
 
434
 
435
- def cmd_run(args: argparse.Namespace) -> int:
 
436
  tasks = {t.id: t for t in load_tasks()}
437
- task = tasks[args.task]
438
- out_dir = Path(args.out) if args.out else OUT_ROOT / time.strftime("%Y%m%d-%H%M%S")
439
  out_dir.mkdir(parents=True, exist_ok=True)
440
- sandbox = out_dir / f"sandbox-{task.id}-{args.variant}"
441
- kept = out_dir / f"kept-{task.id}-{args.variant}"
442
- agents_md = Path(args.agents_md) if args.agents_md else None
443
- model = args.model or AGENT_MODEL
444
 
445
- log(f"песочница {task.id} ({args.variant}) на {task.base[:8]}")
446
  prepare(task, sandbox, agents_md)
447
 
448
  pre_fail, _ = pytest_state(sandbox)
449
  targets = compute_targets(task)["fail_to_pass"]
 
450
  log(f"до агента падают {len(pre_fail)} тестов, целевых (FAIL_TO_PASS): {len(targets)}")
451
 
452
- transcript = out_dir / f"{task.id}.{args.variant}.jsonl"
453
  log(f"агент: {model}, таймаут {AGENT_TIMEOUT}с")
454
  started = time.time()
455
  final, _ = run_agent(sandbox, task.prompt(), model, transcript, AGENT_TIMEOUT)
456
  duration = round(time.time() - started, 1)
457
 
458
  # Слепок снимаем сразу: содержимое песочницы могут снести (чистка temp, OpenCode).
459
- snapshot = keep_sandbox(sandbox, kept)
460
  session = session_from_db(sandbox)
461
  if session:
462
- (out_dir / f"{task.id}.{args.variant}.session.json").write_text(
463
  json.dumps(session, ensure_ascii=False, indent=2), encoding="utf-8"
464
  )
465
 
466
  result = RunResult(
467
  task=task.id,
468
- variant=args.variant,
469
  model=model,
470
  duration_s=duration,
471
  final_message=final,
@@ -480,7 +493,7 @@ def cmd_run(args: argparse.Namespace) -> int:
480
 
481
  payload = {
482
  "task": task.id,
483
- "variant": args.variant,
484
  "model": model,
485
  "solved": result.solved,
486
  "pre_fail": result.pre_fail,
@@ -496,10 +509,42 @@ def cmd_run(args: argparse.Namespace) -> int:
496
  "diff": snapshot["diff"][-8000:],
497
  "post_pytest_tail": post_out[-1500:],
498
  }
499
- result_file = out_dir / f"{task.id}.{args.variant}.json"
500
  result_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
501
  log(f"итог: {'решено' if result.solved else 'не решено'} ({duration}с) -> {result_file}")
502
  check_repo_untouched()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  return 0
504
 
505
 
@@ -510,7 +555,9 @@ def check_repo_untouched() -> None:
510
  except RuntimeError as exc:
511
  log(f"ВНИМАНИЕ: не смог проверить состояние репозитория: {exc}")
512
  return
513
- unexpected = [line for line in status.splitlines() if line and not line.startswith("?? eval/")]
 
 
514
  if unexpected:
515
  log(f"ВНИМАНИЕ: репозиторий изменён во время прогона: {unexpected[:5]}")
516
 
@@ -548,14 +595,14 @@ def cmd_propose(args: argparse.Namespace) -> int:
548
  reports.mkdir()
549
  for run_file in runs:
550
  data = json.loads(run_file.read_text(encoding="utf-8"))
551
- if data.get("solved") and not data.get("violations", {}).get("tests_touched"):
552
- continue
553
  body = [
554
  f"Задача: {data['task']}",
 
555
  f"Целевые тесты: {', '.join(data.get('target_tests') or [])}",
556
  f"Целевые тесты после прогона упали: {', '.join(data.get('target_failed') or [])}",
557
  f"Новые падения: {', '.join(data.get('new_failures') or [])}",
558
- f"Нарушения правил: {json.dumps(data.get('violations') or {}, ensure_ascii=False)}",
559
  f"pyflakes: {'ok' if data.get('pyflakes_ok') else 'ошибка'}",
560
  "",
561
  "Последнее сообщение агента:",
@@ -566,13 +613,15 @@ def cmd_propose(args: argparse.Namespace) -> int:
566
  ]
567
  (reports / f"{data['task']}.md").write_text("\n".join(body), encoding="utf-8")
568
  if not any(reports.iterdir()):
569
- log("базовые прогоны без провалов — править нечего")
570
  return 0
571
 
572
  prompt = (
573
- "Ты редактор правил проекта. Посмотри AGENTS.md и отчёты о провалах в папке failures.\n"
574
- "Составь новый AGENTS.md: добавь правила, которые предотвратили бы эти провалы "
575
- "(конкретные, в стиле существующих разделов).\n"
 
 
576
  "Существующие правила не удаляй и не переписывай, только дополняй.\n"
577
  "Сохрани результат в файл AGENTS.candidate.md в этой же папке. Больше ничего не меняй."
578
  )
@@ -602,10 +651,8 @@ def cmd_propose(args: argparse.Namespace) -> int:
602
 
603
  def cmd_report(args: argparse.Namespace) -> int:
604
  out_dir = Path(args.out) if args.out else OUT_ROOT
605
- files = sorted(out_dir.glob("*.json"))
606
- files = [f for f in files if f.name not in {"propose.json"}]
607
  rows = []
608
- for file in files:
609
  try:
610
  data = json.loads(file.read_text(encoding="utf-8"))
611
  except json.JSONDecodeError:
@@ -616,31 +663,37 @@ def cmd_report(args: argparse.Namespace) -> int:
616
  by_task: dict[str, dict[str, dict]] = {}
617
  for row in rows:
618
  by_task.setdefault(row["task"], {})[row["variant"]] = row
 
619
 
 
 
620
  lines = [
621
- "# Harness-eval: базовый AGENTS.md против кандидатного",
622
  "",
623
  f"Модель агента: {AGENT_MODEL} · Модель-редактор: {PROPOSER_MODEL}",
624
  "",
625
- "| Задача | База | Кандидат | Тесты (база) | Тесты (кандидат) | Нарушения (база) | Нарушения (кандидат) |",
626
- "|---|---|---|---|---|---|---|",
627
  ]
628
- base_score = cand_score = 0
629
- for task_id, variants in sorted(by_task.items()):
630
- base = variants.get("base", {})
631
- cand = variants.get("candidate", {})
632
- base_ok = bool(base.get("solved"))
633
- cand_ok = bool(cand.get("solved"))
634
- base_score += base_ok
635
- cand_score += cand_ok
636
- lines.append(
637
- f"| {task_id} | {'решено' if base_ok else 'нет'} | "
638
- f"{'решено' if cand_ok else 'нет'} | {len(base.get('target_failed') or [])} | "
639
- f"{len(cand.get('target_failed') or [])} | "
640
- f"{len(base.get('violations', {}).get('tests_touched') or [])} | "
641
- f"{len(cand.get('violations', {}).get('tests_touched') or [])} |"
642
- )
643
- lines += ["", f"Решено базой: {base_score}/{len(by_task)} · Кандидатом: {cand_score}/{len(by_task)}", ""]
 
 
 
644
  text = "\n".join(lines)
645
  print(text)
646
  if args.out:
@@ -677,6 +730,15 @@ def main() -> int:
677
  run.add_argument("--out", default=None)
678
  run.set_defaults(func=cmd_run)
679
 
 
 
 
 
 
 
 
 
 
680
  check = sub.add_parser("check", help="проверить, что задачи валидны на базе")
681
  check.add_argument("--out", default=None)
682
  check.set_defaults(func=cmd_check)
 
35
  OPENCODE_DB = Path(os.environ.get("USERPROFILE", ".")) / ".local" / "share" / "opencode" / "opencode.db"
36
 
37
  AGENT_MODEL = os.environ.get("EVAL_AGENT_MODEL", "opencode/glm-5.3-flash-free")
38
+ # Google-модели из этого аккаунта недоступны ("User location is not supported"),
39
+ # поэтому и агент, и редактор берутся из бесплатной линейки opencode.
40
+ PROPOSER_MODEL = os.environ.get("EVAL_PROPOSER_MODEL", "opencode/nemotron-3-ultra-free")
41
  AGENT_TIMEOUT = int(os.environ.get("EVAL_AGENT_TIMEOUT", "1200"))
42
 
43
  # Команды, которые агент в песочнице запускать не должен: часть из них ломает
 
228
  }
229
  cache[key] = entry
230
  cache_file.parent.mkdir(parents=True, exist_ok=True)
231
+ # запись через временный файл: батчи считают цели параллельно
232
+ tmp = cache_file.with_suffix(".json.tmp")
233
+ tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
234
+ tmp.replace(cache_file)
235
  return entry
236
 
237
 
 
261
 
262
 
263
  def pyflakes_state(sandbox: Path) -> tuple[bool, str]:
264
+ # Гейт как в CI: bot.py, system_prompt.py, все lumen_*.py и все tests/*.py.
265
  modules = sorted(p.name for p in sandbox.glob("lumen_*.py"))
266
+ tests = sorted(f"tests/{p.name}" for p in (sandbox / "tests").glob("*.py"))
267
  proc = run_cmd(
268
+ [sys.executable, "-m", "pyflakes", "bot.py", "system_prompt.py", *modules, *tests],
269
+ cwd=sandbox,
270
+ timeout=300,
271
  )
272
  return proc.returncode == 0, (proc.stdout + proc.stderr)[-2000:]
273
 
 
355
  return None
356
 
357
 
358
+ def keep_sandbox(sandbox: Path, kept: Path, base_status: list[str]) -> dict:
359
+ """Снимает слепок песочницы: после прогона содержимое могут снести.
360
+
361
+ base_status — состояние git сразу после постановки задачи: staged-тесты и
362
+ записанные нами AGENTS.md/opencode.json не считаются правками агента.
363
+ """
364
  info = {"changed_files": [], "diff": ""}
365
  if not sandbox.exists():
366
  return info
367
  try:
368
+ status = [line for line in git(sandbox, "status", "--porcelain").splitlines() if line]
369
  except RuntimeError:
370
+ status = []
371
+ base_keys = {line[3:] for line in base_status}
372
+ info["changed_files"] = [line for line in status if line[3:] not in base_keys]
373
  if kept.exists():
374
  shutil.rmtree(kept, ignore_errors=True)
375
  shutil.copytree(sandbox, kept, ignore=shutil.ignore_patterns(".pip-target"), dirs_exist_ok=True)
 
446
  result.solved = not result.target_failed and not result.new_failures
447
 
448
 
449
+ def run_one(task_id: str, variant: str, out_dir: Path, agents_md: Path | None, model: str) -> dict:
450
+ """Один прогон: песочница, агент, слепок, судья. Возвращает payload результата."""
451
  tasks = {t.id: t for t in load_tasks()}
452
+ task = tasks[task_id]
 
453
  out_dir.mkdir(parents=True, exist_ok=True)
454
+ sandbox = out_dir / f"sandbox-{task.id}-{variant}"
455
+ kept = out_dir / f"kept-{task.id}-{variant}"
 
 
456
 
457
+ log(f"песочница {task.id} ({variant}) на {task.base[:8]}")
458
  prepare(task, sandbox, agents_md)
459
 
460
  pre_fail, _ = pytest_state(sandbox)
461
  targets = compute_targets(task)["fail_to_pass"]
462
+ base_status = [line for line in git(sandbox, "status", "--porcelain").splitlines() if line]
463
  log(f"до агента падают {len(pre_fail)} тестов, целевых (FAIL_TO_PASS): {len(targets)}")
464
 
465
+ transcript = out_dir / f"{task.id}.{variant}.jsonl"
466
  log(f"агент: {model}, таймаут {AGENT_TIMEOUT}с")
467
  started = time.time()
468
  final, _ = run_agent(sandbox, task.prompt(), model, transcript, AGENT_TIMEOUT)
469
  duration = round(time.time() - started, 1)
470
 
471
  # Слепок снимаем сразу: содержимое песочницы могут снести (чистка temp, OpenCode).
472
+ snapshot = keep_sandbox(sandbox, kept, base_status)
473
  session = session_from_db(sandbox)
474
  if session:
475
+ (out_dir / f"{task.id}.{variant}.session.json").write_text(
476
  json.dumps(session, ensure_ascii=False, indent=2), encoding="utf-8"
477
  )
478
 
479
  result = RunResult(
480
  task=task.id,
481
+ variant=variant,
482
  model=model,
483
  duration_s=duration,
484
  final_message=final,
 
493
 
494
  payload = {
495
  "task": task.id,
496
+ "variant": variant,
497
  "model": model,
498
  "solved": result.solved,
499
  "pre_fail": result.pre_fail,
 
509
  "diff": snapshot["diff"][-8000:],
510
  "post_pytest_tail": post_out[-1500:],
511
  }
512
+ result_file = out_dir / f"{task.id}.{variant}.json"
513
  result_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
514
  log(f"итог: {'решено' if result.solved else 'не решено'} ({duration}с) -> {result_file}")
515
  check_repo_untouched()
516
+ return payload
517
+
518
+
519
+ def cmd_run(args: argparse.Namespace) -> int:
520
+ out_dir = Path(args.out) if args.out else OUT_ROOT / time.strftime("%Y%m%d-%H%M%S")
521
+ agents_md = Path(args.agents_md) if args.agents_md else None
522
+ run_one(args.task, args.variant, out_dir, agents_md, args.model or AGENT_MODEL)
523
+ return 0
524
+
525
+
526
+ def cmd_batch(args: argparse.Namespace) -> int:
527
+ """Прогоняет набор задач одним вариантом AGENTS.md."""
528
+ from concurrent.futures import ThreadPoolExecutor
529
+
530
+ tasks = [t.id for t in load_tasks()]
531
+ if args.tasks:
532
+ wanted = set(args.tasks.split(","))
533
+ tasks = [t for t in tasks if t in wanted]
534
+ out_dir = Path(args.out) if args.out else OUT_ROOT / time.strftime("%Y%m%d-%H%M%S")
535
+ out_dir.mkdir(parents=True, exist_ok=True)
536
+ agents_md = Path(args.agents_md) if args.agents_md else None
537
+ model = args.model or AGENT_MODEL
538
+ log(f"batch: {len(tasks)} задач, вариант {args.variant}, потоков {args.jobs}")
539
+
540
+ def work(task_id: str) -> None:
541
+ try:
542
+ run_one(task_id, args.variant, out_dir, agents_md, model)
543
+ except Exception as exc: # noqa: BLE001 — прогон не должен валить весь батч
544
+ log(f"ошибка на {task_id}: {exc}")
545
+
546
+ with ThreadPoolExecutor(max_workers=args.jobs) as pool:
547
+ list(pool.map(work, tasks))
548
  return 0
549
 
550
 
 
555
  except RuntimeError as exc:
556
  log(f"ВНИМАНИЕ: не смог проверить состояние репозитория: {exc}")
557
  return
558
+ unexpected = [
559
+ line for line in status.splitlines() if line and not line[3:].startswith("eval/")
560
+ ]
561
  if unexpected:
562
  log(f"ВНИМАНИЕ: репозиторий изменён во время прогона: {unexpected[:5]}")
563
 
 
595
  reports.mkdir()
596
  for run_file in runs:
597
  data = json.loads(run_file.read_text(encoding="utf-8"))
598
+ violations = data.get("violations") or {}
 
599
  body = [
600
  f"Задача: {data['task']}",
601
+ f"Итог: {'решено' if data.get('solved') else 'не решено'}, {data.get('duration_s')}с",
602
  f"Целевые тесты: {', '.join(data.get('target_tests') or [])}",
603
  f"Целевые тесты после прогона упали: {', '.join(data.get('target_failed') or [])}",
604
  f"Новые падения: {', '.join(data.get('new_failures') or [])}",
605
+ f"Нарушения правил: {json.dumps(violations, ensure_ascii=False)}",
606
  f"pyflakes: {'ok' if data.get('pyflakes_ok') else 'ошибка'}",
607
  "",
608
  "Последнее сообщение агента:",
 
613
  ]
614
  (reports / f"{data['task']}.md").write_text("\n".join(body), encoding="utf-8")
615
  if not any(reports.iterdir()):
616
+ log("базовых прогонов нет — править нечего")
617
  return 0
618
 
619
  prompt = (
620
+ "Ты редактор правил проекта. Посмотри AGENTS.md и отчёты о прогонах в папке failures.\n"
621
+ "Составь новый AGENTS.md: добавь правила, которые предотвратили бы провалы "
622
+ "и убрали бы замеченную в отчётах неэффективность (лишние команды, unix-команды "
623
+ "в PowerShell, гейт не запускался). Правила конкретные, в стиле существующих разделов, "
624
+ "только по фактам из отчётов.\n"
625
  "Существующие правила не удаляй и не переписывай, только дополняй.\n"
626
  "Сохрани результат в файл AGENTS.candidate.md в этой же папке. Больше ничего не меняй."
627
  )
 
651
 
652
  def cmd_report(args: argparse.Namespace) -> int:
653
  out_dir = Path(args.out) if args.out else OUT_ROOT
 
 
654
  rows = []
655
+ for file in sorted(out_dir.glob("*.json")):
656
  try:
657
  data = json.loads(file.read_text(encoding="utf-8"))
658
  except json.JSONDecodeError:
 
663
  by_task: dict[str, dict[str, dict]] = {}
664
  for row in rows:
665
  by_task.setdefault(row["task"], {})[row["variant"]] = row
666
+ variants = sorted({v for task in by_task.values() for v in task})
667
 
668
+ header = "| Задача | " + " | ".join(variants) + " |"
669
+ sep = "|---|" + "---|" * len(variants)
670
  lines = [
671
+ "# Harness-eval: варианты AGENTS.md на задачах из истории",
672
  "",
673
  f"Модель агента: {AGENT_MODEL} · Модель-редактор: {PROPOSER_MODEL}",
674
  "",
675
+ header,
676
+ sep,
677
  ]
678
+ totals = {v: 0 for v in variants}
679
+ for task_id, task_variants in sorted(by_task.items()):
680
+ cells = []
681
+ for variant in variants:
682
+ data = task_variants.get(variant)
683
+ if not data:
684
+ cells.append("-")
685
+ continue
686
+ ok = bool(data.get("solved"))
687
+ totals[variant] += ok
688
+ viol = data.get("violations") or {}
689
+ mark = "решено" if ok else "нет"
690
+ if viol.get("tests_touched"):
691
+ mark += " (тесты правил)"
692
+ if viol.get("unix_shell_commands"):
693
+ mark += " (unix)"
694
+ cells.append(mark)
695
+ lines.append(f"| {task_id} | " + " | ".join(cells) + " |")
696
+ lines += ["", "Итог: " + " · ".join(f"{v}: {totals[v]}/{len(by_task)}" for v in variants), ""]
697
  text = "\n".join(lines)
698
  print(text)
699
  if args.out:
 
730
  run.add_argument("--out", default=None)
731
  run.set_defaults(func=cmd_run)
732
 
733
+ batch = sub.add_parser("batch", help="прогнать несколько задач одним вариантом")
734
+ batch.add_argument("--variant", default="base", choices=["base", "candidate", "weak"])
735
+ batch.add_argument("--tasks", default=None, help="список id через запятую")
736
+ batch.add_argument("--agents-md", default=None)
737
+ batch.add_argument("--model", default=None)
738
+ batch.add_argument("--jobs", type=int, default=1)
739
+ batch.add_argument("--out", default=None)
740
+ batch.set_defaults(func=cmd_batch)
741
+
742
  check = sub.add_parser("check", help="проверить, что задачи валидны на базе")
743
  check.add_argument("--out", default=None)
744
  check.set_defaults(func=cmd_check)
eval/harness_weak.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Lumen
2
+
3
+ Telegram-бот на Python. Отвечай на сообщение пользователя.