claude-evolve 1.8.49 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Worker process for claude-evolve.
4
+ Processes a single pending candidate: generates code via AI and runs evaluator.
5
+
6
+ AIDEV-NOTE: This is the Python port of bin/claude-evolve-worker.
7
+ Exit codes:
8
+ 0 - Success
9
+ 1 - General failure
10
+ 2 - Rate limit (should retry later)
11
+ 3 - API exhausted (stop all processing)
12
+ 77 - AI generation failed after retries
13
+ 78 - Missing parent algorithm
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Optional, Tuple, Dict, Any
28
+
29
+ # Add lib to path
30
+ SCRIPT_DIR = Path(__file__).parent
31
+ sys.path.insert(0, str(SCRIPT_DIR.parent))
32
+
33
+ from lib.evolution_csv import EvolutionCSV
34
+ from lib.ai_cli import call_ai, get_git_protection_warning, AIError, RateLimitError, APIExhaustedError, TimeoutError
35
+
36
+
37
+ @dataclass
38
+ class Config:
39
+ """Worker configuration."""
40
+ csv_path: str
41
+ evolution_dir: str
42
+ output_dir: str
43
+ algorithm_path: str
44
+ evaluator_path: str
45
+ brief_path: str
46
+ python_cmd: str = "python3"
47
+ memory_limit_mb: int = 0
48
+ timeout_seconds: int = 600
49
+ max_ai_retries: int = 3
50
+ max_candidates: int = 5
51
+
52
+
53
+ @dataclass
54
+ class Candidate:
55
+ """Candidate to process."""
56
+ id: str
57
+ based_on_id: str
58
+ description: str
59
+
60
+
61
+ class Worker:
62
+ """Processes evolution candidates."""
63
+
64
+ def __init__(self, config: Config):
65
+ self.config = config
66
+ self.csv = EvolutionCSV(config.csv_path)
67
+ self.current_candidate_id: Optional[str] = None
68
+ self._setup_signal_handlers()
69
+
70
+ def _setup_signal_handlers(self):
71
+ """Setup signal handlers for graceful shutdown."""
72
+ signal.signal(signal.SIGTERM, self._handle_signal)
73
+ signal.signal(signal.SIGINT, self._handle_signal)
74
+
75
+ def _handle_signal(self, signum, frame):
76
+ """Handle termination signal - reset current candidate to pending."""
77
+ sig_name = signal.Signals(signum).name
78
+ print(f"[WORKER-{os.getpid()}] Received {sig_name}", file=sys.stderr)
79
+
80
+ if self.current_candidate_id:
81
+ print(f"[WORKER-{os.getpid()}] Resetting {self.current_candidate_id} to pending", file=sys.stderr)
82
+ try:
83
+ with EvolutionCSV(self.config.csv_path) as csv:
84
+ info = csv.get_candidate_info(self.current_candidate_id)
85
+ status = info.get('status', '').lower() if info else ''
86
+ # Don't reset if already complete or permanently failed
87
+ if status not in ('complete', 'failed', 'failed-ai-retry', 'failed-parent-missing'):
88
+ csv.update_candidate_status(self.current_candidate_id, 'pending')
89
+ except Exception as e:
90
+ print(f"[WORKER-{os.getpid()}] Error resetting status: {e}", file=sys.stderr)
91
+
92
+ sys.exit(128 + signum)
93
+
94
+ def _resolve_parent_id(self, parent_id: str) -> Tuple[Optional[str], Optional[Path]]:
95
+ """
96
+ Resolve parent ID to actual file.
97
+
98
+ Args:
99
+ parent_id: Parent ID (may be comma-separated for multi-parent)
100
+
101
+ Returns:
102
+ Tuple of (resolved_parent_id, parent_file_path) or (None, None) if not found
103
+ """
104
+ if not parent_id or parent_id == "baseline-000":
105
+ return None, Path(self.config.algorithm_path)
106
+
107
+ # Split by comma or space and try each
108
+ candidates = re.split(r'[,;\s]+', parent_id)
109
+ for candidate in candidates:
110
+ candidate = candidate.strip()
111
+ if not candidate:
112
+ continue
113
+
114
+ parent_file = Path(self.config.output_dir) / f"evolution_{candidate}.py"
115
+ if parent_file.exists():
116
+ return candidate, parent_file
117
+
118
+ return None, None # No valid parent found
119
+
120
+ def _is_baseline(self, candidate_id: str, parent_id: str) -> bool:
121
+ """Check if this is a baseline candidate."""
122
+ if parent_id:
123
+ return False
124
+ return candidate_id in ('baseline', 'baseline-000', '000', '0', 'gen00-000')
125
+
126
+ def _build_prompt(self, candidate: Candidate, target_basename: str) -> str:
127
+ """Build the AI prompt for code evolution."""
128
+ return f"""{get_git_protection_warning()}
129
+
130
+ Modify the algorithm in {target_basename} based on this description: {candidate.description}
131
+
132
+ The modification should be substantial and follow the description exactly. Make sure the algorithm still follows all interface requirements and can run properly.
133
+
134
+ Important: Make meaningful changes that match the description. Don't just add comments or make trivial adjustments.
135
+
136
+ IMPORTANT: If you need to read Python (.py) or CSV files, read them in chunks using offset and limit parameters to avoid context overload
137
+ Example: Read(file_path='evolution_gen01-001.py', offset=0, limit=100) then Read(offset=100, limit=100), etc.
138
+ This is especially important for models with smaller context windows (like GLM).
139
+
140
+ CRITICAL: If you do not know how to implement what was asked for, or if the requested change is unclear or not feasible, you MUST refuse to make any changes. DO NOT modify the code if you are uncertain about the implementation. Simply respond that you cannot implement the requested change and explain why. It is better to refuse than to make incorrect or random changes."""
141
+
142
+ def _call_ai_with_retries(self, prompt: str, target_file: Path, source_file: Path) -> Tuple[bool, str]:
143
+ """
144
+ Call AI with retries.
145
+
146
+ Returns:
147
+ Tuple of (success, model_name)
148
+ """
149
+ for attempt in range(1, self.config.max_ai_retries + 1):
150
+ print(f"[WORKER-{os.getpid()}] AI attempt {attempt}/{self.config.max_ai_retries}", file=sys.stderr)
151
+
152
+ # Re-copy source if this is a retry
153
+ if attempt > 1:
154
+ print(f"[WORKER-{os.getpid()}] Re-copying source file for retry", file=sys.stderr)
155
+ shutil.copy(source_file, target_file)
156
+
157
+ # Get file hash before AI call
158
+ hash_before = self._file_hash(target_file) if target_file.exists() else None
159
+
160
+ try:
161
+ output, model = call_ai(prompt, command="run", working_dir=self.config.evolution_dir)
162
+
163
+ # Check if file was modified
164
+ hash_after = self._file_hash(target_file) if target_file.exists() else None
165
+
166
+ if hash_before != hash_after and hash_after is not None:
167
+ print(f"[WORKER-{os.getpid()}] AI successfully modified file (model: {model})", file=sys.stderr)
168
+ return True, model
169
+ else:
170
+ print(f"[WORKER-{os.getpid()}] AI did not modify file", file=sys.stderr)
171
+
172
+ except RateLimitError as e:
173
+ print(f"[WORKER-{os.getpid()}] Rate limit: {e}", file=sys.stderr)
174
+ raise # Propagate to caller
175
+ except APIExhaustedError as e:
176
+ print(f"[WORKER-{os.getpid()}] API exhausted: {e}", file=sys.stderr)
177
+ raise # Propagate to caller
178
+ except TimeoutError as e:
179
+ print(f"[WORKER-{os.getpid()}] Timeout: {e}", file=sys.stderr)
180
+ except AIError as e:
181
+ print(f"[WORKER-{os.getpid()}] AI error: {e}", file=sys.stderr)
182
+
183
+ if attempt < self.config.max_ai_retries:
184
+ print(f"[WORKER-{os.getpid()}] Will retry with different model...", file=sys.stderr)
185
+ time.sleep(2)
186
+
187
+ return False, ""
188
+
189
+ def _file_hash(self, path: Path) -> Optional[str]:
190
+ """Get file hash."""
191
+ try:
192
+ import hashlib
193
+ return hashlib.sha256(path.read_bytes()).hexdigest()
194
+ except Exception:
195
+ return None
196
+
197
+ def _check_syntax(self, file_path: Path) -> bool:
198
+ """Check Python syntax."""
199
+ try:
200
+ result = subprocess.run(
201
+ [self.config.python_cmd, "-m", "py_compile", str(file_path)],
202
+ capture_output=True,
203
+ text=True
204
+ )
205
+ return result.returncode == 0
206
+ except Exception:
207
+ return False
208
+
209
+ def _run_evaluator(self, candidate_id: str, is_baseline: bool) -> Tuple[Optional[float], Dict[str, Any]]:
210
+ """
211
+ Run the evaluator.
212
+
213
+ Returns:
214
+ Tuple of (score, extra_data_dict) or (None, {}) on failure
215
+ """
216
+ eval_arg = "" if is_baseline else candidate_id
217
+
218
+ cmd = [self.config.python_cmd]
219
+
220
+ # Add memory wrapper if configured
221
+ if self.config.memory_limit_mb > 0:
222
+ wrapper_path = SCRIPT_DIR / "memory_limit_wrapper.py"
223
+ cmd.extend([str(wrapper_path), str(self.config.memory_limit_mb)])
224
+
225
+ cmd.extend([self.config.evaluator_path, eval_arg])
226
+
227
+ print(f"[WORKER-{os.getpid()}] Running evaluator: {' '.join(cmd)}", file=sys.stderr)
228
+
229
+ try:
230
+ result = subprocess.run(
231
+ cmd,
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=self.config.timeout_seconds,
235
+ cwd=self.config.evolution_dir
236
+ )
237
+
238
+ if result.returncode != 0:
239
+ print(f"[WORKER-{os.getpid()}] Evaluator failed: {result.stderr}", file=sys.stderr)
240
+ return None, {}
241
+
242
+ output = result.stdout + result.stderr
243
+ return self._parse_evaluator_output(output)
244
+
245
+ except subprocess.TimeoutExpired:
246
+ print(f"[WORKER-{os.getpid()}] Evaluator timed out", file=sys.stderr)
247
+ return None, {}
248
+ except Exception as e:
249
+ print(f"[WORKER-{os.getpid()}] Evaluator error: {e}", file=sys.stderr)
250
+ return None, {}
251
+
252
+ def _parse_evaluator_output(self, output: str) -> Tuple[Optional[float], Dict[str, Any]]:
253
+ """
254
+ Parse evaluator output for score.
255
+
256
+ Supports:
257
+ - Simple numeric value
258
+ - JSON with 'performance' or 'score' field
259
+ - SCORE: prefix (legacy)
260
+ """
261
+ score = None
262
+ json_data = {}
263
+
264
+ for line in output.strip().split('\n'):
265
+ line = line.strip()
266
+
267
+ # Try JSON first
268
+ if line.startswith('{'):
269
+ try:
270
+ data = json.loads(line)
271
+ json_data = data
272
+ if 'performance' in data:
273
+ score = float(data['performance'])
274
+ elif 'score' in data:
275
+ score = float(data['score'])
276
+ break
277
+ except (json.JSONDecodeError, ValueError):
278
+ pass
279
+
280
+ # Try simple numeric
281
+ if score is None and line and not line.startswith('{'):
282
+ try:
283
+ score = float(line)
284
+ break
285
+ except ValueError:
286
+ pass
287
+
288
+ # Try SCORE: prefix (legacy)
289
+ if score is None:
290
+ match = re.search(r'^SCORE:\s*([+-]?\d*\.?\d+)', output, re.MULTILINE)
291
+ if match:
292
+ try:
293
+ score = float(match.group(1))
294
+ except ValueError:
295
+ pass
296
+
297
+ return score, json_data
298
+
299
+ def process_candidate(self, candidate: Candidate) -> int:
300
+ """
301
+ Process a single candidate.
302
+
303
+ Returns:
304
+ Exit code (0=success, 77=AI failed, 78=missing parent, etc.)
305
+ """
306
+ self.current_candidate_id = candidate.id
307
+ print(f"[WORKER-{os.getpid()}] Processing: {candidate.id}", file=sys.stderr)
308
+ print(f"[WORKER-{os.getpid()}] Description: {candidate.description}", file=sys.stderr)
309
+ print(f"[WORKER-{os.getpid()}] Based on: {candidate.based_on_id or 'baseline'}", file=sys.stderr)
310
+
311
+ is_baseline = self._is_baseline(candidate.id, candidate.based_on_id)
312
+ target_file = Path(self.config.output_dir) / f"evolution_{candidate.id}.py"
313
+
314
+ # Resolve parent
315
+ resolved_parent, source_file = self._resolve_parent_id(candidate.based_on_id)
316
+
317
+ if source_file is None and not is_baseline:
318
+ print(f"[WORKER-{os.getpid()}] ERROR: Parent not found: {candidate.based_on_id}", file=sys.stderr)
319
+ return 78 # Missing parent
320
+
321
+ if source_file is None:
322
+ source_file = Path(self.config.algorithm_path)
323
+
324
+ # Check if target already exists
325
+ if target_file.exists():
326
+ print(f"[WORKER-{os.getpid()}] File already exists, running evaluation only", file=sys.stderr)
327
+ elif not is_baseline:
328
+ # Copy source to target
329
+ print(f"[WORKER-{os.getpid()}] Copying {source_file} to {target_file}", file=sys.stderr)
330
+ shutil.copy(source_file, target_file)
331
+
332
+ # Call AI to modify
333
+ prompt = self._build_prompt(candidate, target_file.name)
334
+
335
+ try:
336
+ success, model = self._call_ai_with_retries(prompt, target_file, source_file)
337
+
338
+ if not success:
339
+ print(f"[WORKER-{os.getpid()}] AI failed after all retries", file=sys.stderr)
340
+ target_file.unlink(missing_ok=True)
341
+ return 77 # AI generation failed
342
+
343
+ # Record model used
344
+ if model:
345
+ with EvolutionCSV(self.config.csv_path) as csv:
346
+ csv.update_candidate_field(candidate.id, 'run-LLM', model)
347
+
348
+ except RateLimitError:
349
+ target_file.unlink(missing_ok=True)
350
+ with EvolutionCSV(self.config.csv_path) as csv:
351
+ csv.update_candidate_status(candidate.id, 'pending')
352
+ return 2 # Rate limit
353
+
354
+ except APIExhaustedError:
355
+ target_file.unlink(missing_ok=True)
356
+ with EvolutionCSV(self.config.csv_path) as csv:
357
+ csv.update_candidate_status(candidate.id, 'pending')
358
+ return 3 # API exhausted
359
+
360
+ # Check syntax
361
+ if not self._check_syntax(target_file):
362
+ print(f"[WORKER-{os.getpid()}] Syntax error in generated file", file=sys.stderr)
363
+ target_file.unlink(missing_ok=True)
364
+ with EvolutionCSV(self.config.csv_path) as csv:
365
+ csv.update_candidate_status(candidate.id, 'pending')
366
+ return 0 # Will retry
367
+
368
+ # Run evaluator
369
+ print(f"[WORKER-{os.getpid()}] Running evaluator...", file=sys.stderr)
370
+ score, json_data = self._run_evaluator(candidate.id, is_baseline)
371
+
372
+ if score is None:
373
+ print(f"[WORKER-{os.getpid()}] Evaluation failed - no score", file=sys.stderr)
374
+ with EvolutionCSV(self.config.csv_path) as csv:
375
+ csv.update_candidate_status(candidate.id, 'failed')
376
+ return 1
377
+
378
+ print(f"[WORKER-{os.getpid()}] Score: {score}", file=sys.stderr)
379
+
380
+ # Update CSV
381
+ with EvolutionCSV(self.config.csv_path) as csv:
382
+ csv.update_candidate_status(candidate.id, 'complete')
383
+ csv.update_candidate_performance(candidate.id, str(score))
384
+
385
+ # Update any extra fields from JSON
386
+ for key, value in json_data.items():
387
+ if key not in ('performance', 'score'):
388
+ csv.update_candidate_field(candidate.id, key, str(value))
389
+
390
+ self.current_candidate_id = None
391
+ return 0
392
+
393
+ def run(self) -> int:
394
+ """
395
+ Main worker loop.
396
+
397
+ Returns:
398
+ Exit code
399
+ """
400
+ print(f"[WORKER-{os.getpid()}] Started (max {self.config.max_candidates} candidates)", file=sys.stderr)
401
+ processed = 0
402
+
403
+ while processed < self.config.max_candidates:
404
+ # Get next pending candidate
405
+ with EvolutionCSV(self.config.csv_path) as csv:
406
+ result = csv.get_next_pending_candidate()
407
+
408
+ if not result:
409
+ print(f"[WORKER-{os.getpid()}] No pending candidates", file=sys.stderr)
410
+ break
411
+
412
+ candidate_id, _ = result
413
+
414
+ # Get full candidate info
415
+ with EvolutionCSV(self.config.csv_path) as csv:
416
+ info = csv.get_candidate_info(candidate_id)
417
+
418
+ if not info:
419
+ print(f"[WORKER-{os.getpid()}] Candidate info not found: {candidate_id}", file=sys.stderr)
420
+ continue
421
+
422
+ candidate = Candidate(
423
+ id=info['id'],
424
+ based_on_id=info.get('basedOnId', ''),
425
+ description=info.get('description', '')
426
+ )
427
+
428
+ exit_code = self.process_candidate(candidate)
429
+ processed += 1
430
+
431
+ if exit_code == 77: # AI failed
432
+ with EvolutionCSV(self.config.csv_path) as csv:
433
+ csv.update_candidate_status(candidate.id, 'failed-ai-retry')
434
+ elif exit_code == 78: # Missing parent
435
+ with EvolutionCSV(self.config.csv_path) as csv:
436
+ csv.update_candidate_status(candidate.id, 'failed-parent-missing')
437
+ elif exit_code == 2: # Rate limit
438
+ return 2
439
+ elif exit_code == 3: # API exhausted
440
+ return 3
441
+
442
+ print(f"[WORKER-{os.getpid()}] Processed {processed}/{self.config.max_candidates}", file=sys.stderr)
443
+
444
+ print(f"[WORKER-{os.getpid()}] Exiting", file=sys.stderr)
445
+ return 0
446
+
447
+
448
+ def load_config_from_yaml(config_path: Optional[str] = None) -> Config:
449
+ """Load configuration from YAML file."""
450
+ import yaml
451
+
452
+ # Find config file
453
+ if config_path:
454
+ yaml_path = Path(config_path)
455
+ elif os.environ.get('CLAUDE_EVOLVE_CONFIG'):
456
+ yaml_path = Path(os.environ['CLAUDE_EVOLVE_CONFIG'])
457
+ else:
458
+ # Look for config.yaml in evolution directory
459
+ yaml_path = Path('evolution/config.yaml')
460
+ if not yaml_path.exists():
461
+ yaml_path = Path('config.yaml')
462
+
463
+ if not yaml_path.exists():
464
+ raise FileNotFoundError(f"Config not found: {yaml_path}")
465
+
466
+ with open(yaml_path) as f:
467
+ data = yaml.safe_load(f) or {}
468
+
469
+ # Resolve paths relative to config file
470
+ base_dir = yaml_path.parent
471
+
472
+ def resolve(path: str) -> str:
473
+ p = Path(path)
474
+ if not p.is_absolute():
475
+ p = base_dir / p
476
+ return str(p.resolve())
477
+
478
+ return Config(
479
+ csv_path=resolve(data.get('csv_file', 'evolution.csv')),
480
+ evolution_dir=str(base_dir.resolve()),
481
+ output_dir=resolve(data.get('output_dir', '.')),
482
+ algorithm_path=resolve(data.get('algorithm_file', 'algorithm.py')),
483
+ evaluator_path=resolve(data.get('evaluator_file', 'evaluator.py')),
484
+ brief_path=resolve(data.get('brief_file', 'BRIEF.md')),
485
+ python_cmd=data.get('python_cmd', 'python3'),
486
+ memory_limit_mb=data.get('memory_limit_mb', 0),
487
+ timeout_seconds=data.get('timeout_seconds', 600),
488
+ max_ai_retries=data.get('max_retries', 3),
489
+ max_candidates=data.get('worker_max_candidates', 5)
490
+ )
491
+
492
+
493
+ def main():
494
+ parser = argparse.ArgumentParser(description='Claude Evolve Worker')
495
+ parser.add_argument('--config', help='Path to config.yaml')
496
+ parser.add_argument('--timeout', type=int, help='Timeout in seconds')
497
+ args = parser.parse_args()
498
+
499
+ try:
500
+ config = load_config_from_yaml(args.config)
501
+ if args.timeout:
502
+ config.timeout_seconds = args.timeout
503
+
504
+ worker = Worker(config)
505
+ sys.exit(worker.run())
506
+
507
+ except FileNotFoundError as e:
508
+ print(f"Error: {e}", file=sys.stderr)
509
+ sys.exit(1)
510
+ except Exception as e:
511
+ print(f"Error: {e}", file=sys.stderr)
512
+ import traceback
513
+ traceback.print_exc()
514
+ sys.exit(1)
515
+
516
+
517
+ if __name__ == '__main__':
518
+ main()
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "claude-evolve",
3
- "version": "1.8.49",
3
+ "version": "1.9.0",
4
4
  "bin": {
5
- "claude-evolve": "./bin/claude-evolve",
6
- "claude-evolve-main": "./bin/claude-evolve-main",
7
- "claude-evolve-setup": "./bin/claude-evolve-setup",
8
- "claude-evolve-ideate": "./bin/claude-evolve-ideate",
9
- "claude-evolve-run": "./bin/claude-evolve-run",
10
- "claude-evolve-worker": "./bin/claude-evolve-worker",
11
- "claude-evolve-analyze": "./bin/claude-evolve-analyze",
12
- "claude-evolve-config": "./bin/claude-evolve-config",
13
- "claude-evolve-killall": "./bin/claude-evolve-killall"
5
+ "claude-evolve": "bin/claude-evolve",
6
+ "claude-evolve-main": "bin/claude-evolve-main",
7
+ "claude-evolve-setup": "bin/claude-evolve-setup",
8
+ "claude-evolve-ideate": "bin/claude-evolve-ideate",
9
+ "claude-evolve-run": "bin/claude-evolve-run",
10
+ "claude-evolve-worker": "bin/claude-evolve-worker",
11
+ "claude-evolve-analyze": "bin/claude-evolve-analyze",
12
+ "claude-evolve-config": "bin/claude-evolve-config",
13
+ "claude-evolve-killall": "bin/claude-evolve-killall"
14
14
  },
15
15
  "files": [
16
16
  "bin/",