diff --git a/evolution/core/config.py b/evolution/core/config.py index 39b1090..81d2d51 100644 --- a/evolution/core/config.py +++ b/evolution/core/config.py @@ -37,6 +37,14 @@ class EvolutionConfig: # for the actionable hint surfaced to users. reflection_minibatch_size: int = 3 + # GEPA acceptance criterion. "improvement_or_equal" (default) accepts + # plateau-equal candidates so noisy LM-judge ties don't reject "true + # zero-difference" mutations ~50% of the time; "strict_improvement" + # preserves the gepa<0.1.2 implicit behavior. Forwarded as the literal + # kwarg expected by gepa.optimize via dspy.GEPA's gepa_kwargs passthrough + # (valid gepa values: "strict_improvement", "improvement_or_equal"). + gepa_acceptance: str = "improvement_or_equal" + # Per-role model overrides. When set, treated as explicit LiteLLM model # strings that bypass Hermes resolution. When None, get_lm() falls back # to resolve_default_lm() against ~/.hermes/config.yaml + auth.json + diff --git a/evolution/core/run_inputs.py b/evolution/core/run_inputs.py index 7862b1c..213dac6 100644 --- a/evolution/core/run_inputs.py +++ b/evolution/core/run_inputs.py @@ -19,6 +19,7 @@ def build_run_inputs( optimizer_model: str, quality_gate_preset: str, eval_source: str, + gepa_acceptance: str, fitness_profile: Optional[str] = None, enable_confusable_bucket: Optional[bool] = None, ) -> dict[str, Any]: @@ -37,6 +38,7 @@ def build_run_inputs( "holdout_ratio": config.holdout_ratio, "quality_gate_preset": quality_gate_preset, "eval_source": eval_source, + "gepa_acceptance": gepa_acceptance, } if fitness_profile is not None: run_inputs["fitness_profile"] = fitness_profile diff --git a/evolution/skills/evolve_skill.py b/evolution/skills/evolve_skill.py index 71c78f7..cc69348 100644 --- a/evolution/skills/evolve_skill.py +++ b/evolution/skills/evolve_skill.py @@ -298,6 +298,7 @@ def _default_gepa_runner( instruction_proposer=None, reflection_model: Optional[str] = None, reflection_minibatch_size: int = 3, + gepa_acceptance: str = "improvement_or_equal", ): # max_tokens=32000 satisfies DSPy's reasoning-model floor of 16000 # (DSPy raises ValueError below that). @@ -327,6 +328,7 @@ def _default_gepa_runner( track_stats=True, instruction_proposer=instruction_proposer, reflection_minibatch_size=reflection_minibatch_size, + gepa_kwargs={"acceptance_criterion": gepa_acceptance}, ) return optimizer.compile(baseline_module, trainset=trainset, valset=valset) @@ -385,6 +387,7 @@ def _build_optimizer_and_compile( instruction_proposer=None, reflection_model: Optional[str] = None, reflection_minibatch_size: int = 3, + gepa_acceptance: str = "improvement_or_equal", _gepa_runner=_default_gepa_runner, _mipro_runner=_default_mipro_runner, ): @@ -407,6 +410,7 @@ def _build_optimizer_and_compile( instruction_proposer=instruction_proposer, reflection_model=reflection_model, reflection_minibatch_size=reflection_minibatch_size, + gepa_acceptance=gepa_acceptance, ) return optimized, "GEPA" except CostCeilingExceeded: @@ -638,6 +642,7 @@ def evolve( skip_saturation_check: bool = False, force_saturation_check: bool = False, gepa_minibatch_size: int = 3, + gepa_acceptance: str = "improvement-or-equal", closed_loop_suite_path: Optional[Path] = None, closed_loop_saturation_threshold: float = 0.95, closed_loop_min_iters: int = 3, @@ -689,6 +694,7 @@ def evolve( if holdout_ratio is not None: config_kwargs["holdout_ratio"] = holdout_ratio config_kwargs["reflection_minibatch_size"] = gepa_minibatch_size + config_kwargs["gepa_acceptance"] = gepa_acceptance.replace("-", "_") config = EvolutionConfig(**config_kwargs) explicit_dirs = [Path(d) for d in (skill_source_dirs or [])] if explicit_dirs: @@ -1010,6 +1016,7 @@ def evolve( instruction_proposer=proposer, reflection_model=config.reflection_model, reflection_minibatch_size=config.reflection_minibatch_size, + gepa_acceptance=config.gepa_acceptance, ) elapsed = time.time() - start_time @@ -1101,6 +1108,7 @@ def evolve( optimizer_model=optimizer_model, quality_gate_preset=quality_gate, eval_source=eval_source, + gepa_acceptance=config.gepa_acceptance, ), }) console.print(f" Saved failed variant to {failed_path}") @@ -1141,6 +1149,7 @@ def evolve( optimizer_model=optimizer_model, quality_gate_preset=quality_gate, eval_source=eval_source, + gepa_acceptance=config.gepa_acceptance, ) use_cl_primary = ( @@ -1531,6 +1540,7 @@ def evolve( optimizer_model=optimizer_model, quality_gate_preset=quality_gate, eval_source=eval_source, + gepa_acceptance=config.gepa_acceptance, ), schema_version="5", ) @@ -1826,6 +1836,18 @@ def evolve( "to preserve the proposal count. Aborts at startup if the " "value exceeds the trainset size.", ) +@click.option( + "--gepa-acceptance", + "gepa_acceptance", + default="improvement-or-equal", + type=click.Choice(["strict-improvement", "improvement-or-equal"]), + help="GEPA acceptance criterion. 'strict-improvement': only accept " + "candidates with strictly better minibatch score (legacy gepa<0.1.2 " + "default). 'improvement-or-equal' (default): allow plateau-equal " + "candidates for more lateral exploration — the literature-recommended " + "fix for noisy LM-judge fitness where strict acceptance rejects " + "~50% of true-equal mutations.", +) @click.option( "--closed-loop-during-evolution", "closed_loop_suite_path", @@ -1920,6 +1942,7 @@ def main(skill, iterations, eval_source, dataset_path, optimizer_model, reflecti skip_saturation_check, force_saturation_check, gepa_minibatch_size, + gepa_acceptance, closed_loop_suite_path, closed_loop_saturation_threshold, closed_loop_min_iters, @@ -1968,6 +1991,7 @@ def main(skill, iterations, eval_source, dataset_path, optimizer_model, reflecti skip_saturation_check=skip_saturation_check, force_saturation_check=force_saturation_check, gepa_minibatch_size=gepa_minibatch_size, + gepa_acceptance=gepa_acceptance, closed_loop_suite_path=closed_loop_suite_path, closed_loop_saturation_threshold=closed_loop_saturation_threshold, closed_loop_min_iters=closed_loop_min_iters, diff --git a/evolution/tools/evolve_tool.py b/evolution/tools/evolve_tool.py index 7b62edc..b76388d 100644 --- a/evolution/tools/evolve_tool.py +++ b/evolution/tools/evolve_tool.py @@ -383,6 +383,7 @@ def evolve( skip_saturation_check: bool = False, force_saturation_check: bool = False, gepa_minibatch_size: int = 3, + gepa_acceptance: str = "improvement-or-equal", ) -> dict[str, Any]: """Evolve one tool description inside a manifest. @@ -429,6 +430,7 @@ def evolve( holdout_ratio=holdout_ratio, enable_confusable_bucket=enable_confusable_bucket, reflection_minibatch_size=gepa_minibatch_size, + gepa_acceptance=gepa_acceptance.replace("-", "_"), ) console.print( @@ -745,6 +747,7 @@ def evolve( track_stats=True, instruction_proposer=proposer, reflection_minibatch_size=config.reflection_minibatch_size, + gepa_kwargs={"acceptance_criterion": config.gepa_acceptance}, ) optimized_module = optimizer.compile( baseline_module, trainset=trainset, valset=valset, @@ -800,6 +803,7 @@ def evolve( optimizer_model=optimizer_model, quality_gate_preset=quality_gate, eval_source=eval_source, + gepa_acceptance=config.gepa_acceptance, fitness_profile=fitness_profile, enable_confusable_bucket=config.enable_confusable_bucket, ) @@ -1239,6 +1243,7 @@ def evolve( optimizer_model=optimizer_model, quality_gate_preset=quality_gate, eval_source=eval_source, + gepa_acceptance=config.gepa_acceptance, fitness_profile=fitness_profile, enable_confusable_bucket=config.enable_confusable_bucket, ) @@ -1476,6 +1481,18 @@ def evolve( "~10 to preserve the proposal count. Aborts at startup if the " "value exceeds the trainset size.", ) +@click.option( + "--gepa-acceptance", + "gepa_acceptance", + default="improvement-or-equal", + type=click.Choice(["strict-improvement", "improvement-or-equal"]), + help="GEPA acceptance criterion. 'strict-improvement': only accept " + "candidates with strictly better minibatch score (legacy gepa<0.1.2 " + "default). 'improvement-or-equal' (default): allow plateau-equal " + "candidates for more lateral exploration — the literature-recommended " + "fix for noisy LM-judge fitness where strict acceptance rejects " + "~50% of true-equal mutations.", +) @click.option( "--closed-loop-in-valset/--no-closed-loop-in-valset", "closed_loop_in_valset", @@ -1530,6 +1547,7 @@ def main( skip_saturation_check: bool, force_saturation_check: bool, gepa_minibatch_size: int, + gepa_acceptance: str, closed_loop_suite_path: Optional[Path], closed_loop_hermes_repo: Optional[Path], closed_loop_saturation_threshold: float, @@ -1583,6 +1601,7 @@ def main( skip_saturation_check=skip_saturation_check, force_saturation_check=force_saturation_check, gepa_minibatch_size=gepa_minibatch_size, + gepa_acceptance=gepa_acceptance, ) except HermesProviderError as exc: # Render a clean error panel instead of dumping a Python traceback — diff --git a/pyproject.toml b/pyproject.toml index eed93ce..f5540a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ keywords = ["llm", "optimization", "evolution", "dspy", "gepa", "agent", "skill" dependencies = [ "dspy>=3.2.0,<3.3", + # Pinned to PR-304 merge SHA for the acceptance_criterion API; swap to + # a PyPI version when 0.1.2 ships (latest released 0.1.1 predates the merge). + "gepa @ git+https://github.com/gepa-ai/gepa.git@5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b", # Pinned because lm_timing_callback.py uses litellm.failure_callback # (a module-level list mutation API) and dspy.LM forwards # request_timeout/num_retries to litellm. Both are stable at 1.82 @@ -47,6 +50,13 @@ dev = [ Homepage = "https://github.com/jramos/agent-self-evolution" Repository = "https://github.com/jramos/agent-self-evolution" +[tool.uv] +# DSPy 3.2.0 hard-pins gepa[dspy]==0.0.27; override to take PR-304's +# acceptance_criterion API before 0.1.2 ships on PyPI. +override-dependencies = [ + "gepa @ git+https://github.com/gepa-ai/gepa.git@5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b", +] + [tool.setuptools.packages.find] include = ["evolution*"] diff --git a/reports/calibration_findings.md b/reports/calibration_findings.md index cf7ec97..be036ad 100644 --- a/reports/calibration_findings.md +++ b/reports/calibration_findings.md @@ -87,3 +87,25 @@ The `--max-absolute-chars 12000` override applied to Stage 7 validation runs (so ## Audit trail Full campaign artifacts — runbook, analysis scripts, `study_*_results.json`, and per-run `output///{gate_decision,band_holdout,metrics}.json` — live on the `archive/2026-deploy-gate-calibration` branch. No-op merge to main; the report and the one-line preset change are the only durable changes from this campaign. + +## Path D — GEPA acceptance criterion (improvement-or-equal default) + +The framework now defaults `acceptance_criterion` to `improvement_or_equal` +for the underlying `gepa.optimize` call, replacing the implicit strict +behavior baked into gepa <0.1.2. Flag: `--gepa-acceptance +{strict-improvement,improvement-or-equal}` on `evolve_skill` and `evolve_tool`. + +Why: strict-elitist acceptance under noisy LM-judge fitness rejects +"true zero-difference" candidates roughly half the time, narrowing +search and reducing Pareto-frontier diversity for no benefit. GEPA's +Algorithm 1 only says "if σ′ improved" — the strict tiebreak was an +implementation artifact, not a paper claim. Literature on this is +unambiguous (Beyer 2000 on noisy elitism; Aizawa & Wah 1994 and +Rakshit et al. 2017 on threshold/relaxed acceptance for noisy fitness). + +Upstream: gepa-ai/gepa PR #304 (merged 2026-04-06) introduced the +configurable `acceptance_criterion` API with `"strict"` and +`"improvement_or_equal"` shortcuts. We pin gepa to the merge SHA via a +git dependency until 0.1.2 ships on PyPI (0.1.1 was uploaded +2026-03-16, three weeks before the merge); migration path is a one-line +swap to `"gepa>=0.1.2"` once it's published. diff --git a/tests/core/test_run_inputs.py b/tests/core/test_run_inputs.py index e525d55..2857575 100644 --- a/tests/core/test_run_inputs.py +++ b/tests/core/test_run_inputs.py @@ -27,6 +27,7 @@ def test_skill_side_shape(self): optimizer_model="openai/gpt-4.1", quality_gate_preset="default", eval_source="synthetic", + gepa_acceptance="improvement_or_equal", ) assert set(result.keys()) == { "seed", @@ -39,7 +40,9 @@ def test_skill_side_shape(self): "holdout_ratio", "quality_gate_preset", "eval_source", + "gepa_acceptance", } + assert result["gepa_acceptance"] == "improvement_or_equal" def test_tool_side_adds_fitness_profile_and_confusable_bucket(self): config = _fake_config() @@ -50,6 +53,7 @@ def test_tool_side_adds_fitness_profile_and_confusable_bucket(self): optimizer_model="openai/gpt-4.1", quality_gate_preset="default", eval_source="synthetic", + gepa_acceptance="strict_improvement", fitness_profile="balanced", enable_confusable_bucket=True, ) @@ -64,9 +68,11 @@ def test_tool_side_adds_fitness_profile_and_confusable_bucket(self): "holdout_ratio", "quality_gate_preset", "eval_source", + "gepa_acceptance", "fitness_profile", "enable_confusable_bucket", } + assert result["gepa_acceptance"] == "strict_improvement" assert result["fitness_profile"] == "balanced" assert result["enable_confusable_bucket"] is True @@ -78,6 +84,7 @@ def test_resolved_lms_matches_helper_output(self): optimizer_model="openai/gpt-4.1", quality_gate_preset="default", eval_source="synthetic", + gepa_acceptance="improvement_or_equal", ) expected = resolved_lms_dump( optimizer="openai/gpt-4.1", @@ -98,6 +105,7 @@ def test_enable_confusable_bucket_round_trips_when_passed(self): optimizer_model="openai/gpt-4.1", quality_gate_preset="default", eval_source="synthetic", + gepa_acceptance="improvement_or_equal", fitness_profile="balanced", enable_confusable_bucket=config.enable_confusable_bucket, ) diff --git a/tests/skills/test_evolve_skill_validation_flow.py b/tests/skills/test_evolve_skill_validation_flow.py index 656c970..8a49759 100644 --- a/tests/skills/test_evolve_skill_validation_flow.py +++ b/tests/skills/test_evolve_skill_validation_flow.py @@ -878,3 +878,90 @@ def _raise_after_handler_attach(self, *args, **kwargs): f"evolve_skill.evolve() leaked {after - before} root-logger " f"handler(s) across two calls" ) + + +def _fake_skill_dataset(n: int = 50): + """Real-shaped EvalDataset with n fake examples — no LM calls.""" + examples = [ + EvalExample(task_input=f"task {i}", expected_behavior=f"rubric {i}") + for i in range(n) + ] + return EvalDataset( + train=examples[:30], val=examples[30:40], holdout=examples[40:50], + ) + + +class TestGepaAcceptanceFlag: + """--gepa-acceptance threads through to dspy.GEPA's gepa_kwargs, + forwarded onward to gepa.optimize as acceptance_criterion.""" + + @pytest.fixture + def skill_dir(self, tmp_path): + skills_root = tmp_path / "skills" + skill_path = skills_root / "demo-skill" + skill_path.mkdir(parents=True) + (skill_path / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: a test skill\n---\n\nDo X.\n" + ) + return skills_root + + def _run_with_capture(self, skill_dir: Path, extra_cli_args: list[str]): + captured: dict = {} + import dspy + original_init = dspy.GEPA.__init__ + + def recording_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + captured["gepa_kwargs"] = dict(self.gepa_kwargs) + + fake_candidate = MagicMock() + fake_candidate.skill_text = "evolved skill text" + fake_module = MagicMock() + fake_module.skill_text = "evolved skill text" + fake_module.detailed_results = SimpleNamespace( + candidates=[fake_candidate], + val_aggregate_scores=[1.0], + best_idx=0, + ) + fake_builder = MagicMock() + fake_builder.generate.return_value = _fake_skill_dataset() + with patch( + "evolution.skills.evolve_skill.SyntheticDatasetBuilder", return_value=fake_builder + ), patch( + "evolution.skills.evolve_skill._preflight_lm_credentials" + ), patch("evolution.skills.evolve_skill.dspy.GEPA.__init__", recording_init), patch( + "evolution.skills.evolve_skill.dspy.GEPA.compile", return_value=fake_module + ), patch( + "evolution.skills.evolve_skill._holdout_evaluate_with_metric", + return_value=(0.6, [0.6] * 10), + ): + runner = CliRunner() + result = runner.invoke( + evolve_skill_cli, + ["--skill", "demo-skill", "--skill-source-dir", str(skill_dir), + "--iterations", "1", "--no-preflight", + "--no-saturation-check", *extra_cli_args], + ) + return captured, result + + def test_gepa_acceptance_default_passes_improvement_or_equal(self, skill_dir): + captured, result = self._run_with_capture(skill_dir, extra_cli_args=[]) + assert "gepa_kwargs" in captured, ( + f"dspy.GEPA was never constructed; CLI output: {result.output}" + ) + assert captured["gepa_kwargs"].get("acceptance_criterion") == "improvement_or_equal", ( + f"Expected default acceptance_criterion=improvement_or_equal; " + f"got {captured['gepa_kwargs']!r}. CLI output: {result.output}" + ) + + def test_gepa_acceptance_strict_passes_strict_improvement(self, skill_dir): + captured, result = self._run_with_capture( + skill_dir, extra_cli_args=["--gepa-acceptance", "strict-improvement"], + ) + assert "gepa_kwargs" in captured, ( + f"dspy.GEPA was never constructed; CLI output: {result.output}" + ) + assert captured["gepa_kwargs"].get("acceptance_criterion") == "strict_improvement", ( + f"Expected acceptance_criterion=strict_improvement; " + f"got {captured['gepa_kwargs']!r}. CLI output: {result.output}" + ) diff --git a/tests/tools/test_evolve_tool_validation_flow.py b/tests/tools/test_evolve_tool_validation_flow.py index ca8f889..05198f2 100644 --- a/tests/tools/test_evolve_tool_validation_flow.py +++ b/tests/tools/test_evolve_tool_validation_flow.py @@ -913,3 +913,78 @@ def _force_reject(self, artifact_text, baseline_text, bootstrap_result): assert payload["reason"] == "growth_quality_gate" assert "benchmark" not in payload assert ran["called"] is False + + +class TestGepaAcceptanceFlag: + """--gepa-acceptance threads through to dspy.GEPA's gepa_kwargs, + forwarded onward to gepa.optimize as acceptance_criterion.""" + + def _make_capturing_fake_gepa(self, evolved_module: ToolModule, captured: dict): + class _FakeGEPA: + def __init__(self, **kwargs): + captured["gepa_kwargs"] = dict(kwargs.get("gepa_kwargs") or {}) + self.kwargs = kwargs + + def compile(self, baseline_module, *, trainset, valset): + evolved_module.detailed_results = SimpleNamespace( + candidates=[evolved_module], + val_aggregate_scores=[1.0], + best_idx=0, + ) + return evolved_module + + return _FakeGEPA + + def _run(self, temp_manifest: Path, tmp_path: Path, **evolve_kwargs): + manifest = ToolManifest.from_json_file(temp_manifest) + run_dir = tmp_path / "run" + captured: dict = {} + evolved = _build_evolved_module(manifest, EVOLVED_DESCRIPTION) + with ( + patch.object( + SyntheticDatasetBuilder, + "_call_lm_for_bucket", + side_effect=_bucket_side_effect(15, 9, 6), + ), + patch( + "evolution.tools.evolve_tool.dspy.GEPA", + new=self._make_capturing_fake_gepa(evolved, captured), + ), + patch.object( + ToolJudge, "score", + new=_scripted_judge_score(target_score=0.95, regression_score=0.0), + ), + patch.object( + ToolModule, "forward", + new=_scripted_module_forward(expected_tool_for_evolved="search_files"), + ), + ): + evolve( + tool_name="search_files", + manifest_path=temp_manifest, + iterations=1, + eval_dataset_size=30, + holdout_ratio=0.5, + quality_gate="non-inferiority", + enable_confusable_bucket=True, + output_dir=run_dir, + **evolve_kwargs, + ) + return captured + + def test_gepa_acceptance_default_passes_improvement_or_equal( + self, temp_manifest: Path, tmp_path: Path + ): + captured = self._run(temp_manifest, tmp_path) + assert captured.get("gepa_kwargs", {}).get("acceptance_criterion") == "improvement_or_equal", ( + f"Expected default acceptance_criterion=improvement_or_equal; " + f"got {captured!r}" + ) + + def test_gepa_acceptance_strict_passes_strict( + self, temp_manifest: Path, tmp_path: Path + ): + captured = self._run(temp_manifest, tmp_path, gepa_acceptance="strict") + assert captured.get("gepa_kwargs", {}).get("acceptance_criterion") == "strict", ( + f"Expected acceptance_criterion=strict; got {captured!r}" + ) diff --git a/uv.lock b/uv.lock index af728ec..7d7dd48 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +overrides = [{ name = "gepa", git = "https://github.com/gepa-ai/gepa.git?rev=5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b" }] + [[package]] name = "agent-self-evolution" version = "0.1.0" @@ -13,6 +16,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "dspy" }, + { name = "gepa" }, { name = "litellm" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -38,6 +42,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0" }, { name = "dspy", specifier = ">=3.2.0,<3.3" }, { name = "dspy", extras = ["optuna"], marker = "extra == 'miprov2'", specifier = ">=3.2.0,<3.3" }, + { name = "gepa", git = "https://github.com/gepa-ai/gepa.git?rev=5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b" }, { name = "litellm", specifier = ">=1.82.0,<2.0" }, { name = "numpy", specifier = ">=1.24" }, { name = "openai", specifier = ">=1.0.0" }, @@ -715,12 +720,8 @@ wheels = [ [[package]] name = "gepa" -version = "0.0.27" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/99/6840f84498f2dcbfd27e8a15eeb4e637e84e9dbbd6331977b71f6ffad9c7/gepa-0.0.27.tar.gz", hash = "sha256:02ecb19e4aa6a1f5bb2994cd54b2057f25cd5e8cd662fee514303b2a8ba0a738", size = 155106, upload-time = "2026-01-28T00:33:51.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/bd/f9e83519099a9fd5d090520ab0c51a6278e473b914a9b32d1e812662dd3b/gepa-0.0.27-py3-none-any.whl", hash = "sha256:592beb084fd638d525edae84ca75ad8e9e9c3758e5498b933c17153addf5dd2d", size = 146454, upload-time = "2026-01-28T00:33:50.262Z" }, -] +version = "0.1.1" +source = { git = "https://github.com/gepa-ai/gepa.git?rev=5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b#5e24ee5c8e1857a62a1ba19731de9da45ffb6f1b" } [[package]] name = "greenlet"