Skip to content

Commit 3eb4529

Browse files
DeepfreezechillBrian KrafftCopilot
authored
Epic 5.5: Extract evolution engines to loop.py + strategies.py (#43)
* feat(5.5): extract evolution engines to loop.py + strategies.py Extract 6 methods (~670 lines) from evolver.py into evolution/ package: evolution/loop.py (~300 lines): - run_evolution_loop: token-driven LLM agent loop - parse_evolution_output: extract edit content or failure reason - apply_with_retry: apply edit with retry + validation + recording - Constants: _MAX_EVOLUTION_ITERATIONS, _MAX_EVOLUTION_ATTEMPTS evolution/strategies.py (~340 lines): - evolve_fix: in-place fix (same dir, new version) - evolve_derived: enhanced version (new dir, merge support) - evolve_captured: brand-new skill from pattern evolver.py: 1022 -> 370 lines (thin delegates, MRO preserved) All internal calls go through evolver._method() for subclass compat. Unused imports cleaned up (copy, json, re, shutil, uuid, etc.) EvolutionTrigger re-exported for backward compat. 45 new tests (29 loop + 16 strategies); 1,562 total pass, 127 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(5.5): remove unused imports, strengthen SkillRecord assertions (R1) 8eyes-impl R1: removed SkillEnginePrompts, _RECORDING_MAX_CHARS, _SKILL_CONTENT_MAX_CHARS, _truncate unused imports from evolver.py. GPT-5.4 R1: added SkillRecord field assertions to evolve_fix test (origin, generation, parent_ids, source_task_id, tool_deps). 1,562 total pass, 127 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9b486a commit 3eb4529

5 files changed

Lines changed: 1586 additions & 691 deletions

File tree

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
"""Evolution execution loop and apply-retry cycle.
2+
3+
Provides the core execution engine used by all three evolution strategies:
4+
5+
- ``run_evolution_loop`` — token-driven LLM agent loop with tool support
6+
- ``parse_evolution_output`` — extract edit content or failure reason
7+
- ``apply_with_retry`` — apply edit with retry on validation failure
8+
9+
All functions accept an ``evolver`` parameter (the ``SkillEvolver`` instance)
10+
and delegate back through ``evolver._method()`` to preserve method resolution
11+
order for subclass / hook / telemetry compatibility.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import shutil
17+
from pathlib import Path
18+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
19+
20+
from openspace.prompts import SkillEnginePrompts
21+
from openspace.utils.logging import Logger
22+
23+
from ..skill_utils import (
24+
extract_change_summary as _extract_change_summary,
25+
strip_markdown_fences as _strip_markdown_fences,
26+
truncate as _truncate,
27+
validate_skill_dir as _validate_skill_dir,
28+
)
29+
from .confirmation import _RECORDING_MAX_CHARS, _SKILL_CONTENT_MAX_CHARS
30+
from .models import EvolutionContext
31+
32+
if TYPE_CHECKING:
33+
from ..patch import SkillEditResult
34+
35+
logger = Logger.get_logger("openspace.skill_engine.evolver")
36+
37+
EVOLUTION_COMPLETE = SkillEnginePrompts.EVOLUTION_COMPLETE
38+
EVOLUTION_FAILED = SkillEnginePrompts.EVOLUTION_FAILED
39+
40+
# Agent loop / retry constants
41+
_MAX_EVOLUTION_ITERATIONS = 5 # Max tool-calling rounds for evolution agent
42+
_MAX_EVOLUTION_ATTEMPTS = 3 # Max apply-retry attempts per evolution
43+
44+
45+
async def run_evolution_loop(
46+
evolver,
47+
prompt: str,
48+
ctx: EvolutionContext,
49+
) -> Optional[str]:
50+
"""Run evolution as a token-driven agent loop.
51+
52+
Modeled after ``GroundingAgent.process()`` — the loop continues
53+
until the LLM outputs an explicit completion/failure token, NOT
54+
based on whether tools were called.
55+
56+
Termination signals (checked every iteration, regardless of tool use):
57+
- ``EVOLUTION_COMPLETE`` in assistant content → success, return edit.
58+
- ``EVOLUTION_FAILED`` in assistant content → failure, return None.
59+
60+
Tool availability:
61+
- Iterations 1 … N-1: tools enabled (LLM may gather information).
62+
- Iteration N (final): tools disabled, LLM must output a decision.
63+
64+
Each non-final iteration without a token gets a nudge message
65+
telling the LLM which iteration it is on and how many remain.
66+
67+
Conversations are recorded to ``conversations.jsonl`` via
68+
``RecordingManager`` (agent_name="SkillEvolver") so the full
69+
evolution dialogue is preserved for debugging and replay.
70+
"""
71+
from openspace.recording import RecordingManager
72+
73+
model = evolver._model or evolver._llm_client.model
74+
75+
# Merge tools from context and instance-level
76+
evolution_tools: List = list(ctx.available_tools or [])
77+
if not evolution_tools:
78+
evolution_tools = list(evolver._available_tools)
79+
80+
messages: List[Dict[str, Any]] = [
81+
{"role": "user", "content": prompt},
82+
]
83+
84+
# Record initial conversation setup (truncated for data minimization)
85+
recorded_setup = [
86+
{"role": m["role"], "content": _truncate(m["content"], _RECORDING_MAX_CHARS)}
87+
for m in messages
88+
]
89+
await RecordingManager.record_conversation_setup(
90+
setup_messages=recorded_setup,
91+
tools=evolution_tools if evolution_tools else None,
92+
agent_name="SkillEvolver",
93+
extra={
94+
"evolution_type": ctx.suggestion.evolution_type.value,
95+
"trigger": ctx.trigger.value,
96+
"target_skills": ctx.suggestion.target_skill_ids,
97+
},
98+
)
99+
100+
for iteration in range(_MAX_EVOLUTION_ITERATIONS):
101+
is_last = iteration == _MAX_EVOLUTION_ITERATIONS - 1
102+
103+
# Snapshot message count before any additions + LLM call
104+
msg_count_before = len(messages)
105+
106+
# Final round: disable tools and force a decision
107+
if is_last:
108+
messages.append(
109+
{
110+
"role": "system",
111+
"content": (
112+
f"This is your FINAL round (iteration "
113+
f"{iteration + 1}/{_MAX_EVOLUTION_ITERATIONS}) — "
114+
f"no more tool calls allowed. "
115+
f"You MUST output the skill edit content now based on "
116+
f"all information gathered so far. Follow the output "
117+
f"format specified in the original instructions. "
118+
f"End with {EVOLUTION_COMPLETE} if the edit is satisfactory, "
119+
f"or {EVOLUTION_FAILED} with a reason if you cannot produce one."
120+
),
121+
}
122+
)
123+
124+
try:
125+
result = await evolver._llm_client.complete(
126+
messages=messages,
127+
tools=evolution_tools if (evolution_tools and not is_last) else None,
128+
execute_tools=True,
129+
model=model,
130+
)
131+
except Exception as e:
132+
logger.error(f"Evolution LLM call failed (iter {iteration + 1}): {e}")
133+
return None
134+
135+
content = result["message"].get("content", "")
136+
updated_messages = result["messages"]
137+
has_tool_calls = result.get("has_tool_calls", False)
138+
139+
# Record iteration delta (truncated for data minimization)
140+
delta = updated_messages[msg_count_before:]
141+
recorded_delta = [
142+
{"role": m["role"], "content": _truncate(m.get("content", ""), _RECORDING_MAX_CHARS)}
143+
for m in delta
144+
]
145+
await RecordingManager.record_iteration_context(
146+
iteration=iteration + 1,
147+
delta_messages=recorded_delta,
148+
response_metadata={
149+
"has_tool_calls": has_tool_calls,
150+
"tool_calls_count": len(result.get("tool_results", [])),
151+
"has_completion_token": bool(
152+
content and (EVOLUTION_COMPLETE in content or EVOLUTION_FAILED in content)
153+
),
154+
},
155+
agent_name="SkillEvolver",
156+
)
157+
158+
messages = updated_messages
159+
160+
# ── Token check (every iteration, regardless of tool calls) ──
161+
if content and (EVOLUTION_COMPLETE in content or EVOLUTION_FAILED in content):
162+
edit_content, failure_reason = evolver._parse_evolution_output(content)
163+
if failure_reason is not None:
164+
targets = "+".join(ctx.suggestion.target_skill_ids) or "(new)"
165+
logger.warning(
166+
f"Evolution LLM signalled failure "
167+
f"[{ctx.suggestion.evolution_type.value}] "
168+
f"target={targets}: {failure_reason}"
169+
)
170+
return None
171+
return edit_content
172+
173+
# No token found
174+
if is_last:
175+
# Final round exhausted without a decision
176+
logger.warning(
177+
f"Evolution agent finished {_MAX_EVOLUTION_ITERATIONS} iterations "
178+
f"without signalling {EVOLUTION_COMPLETE} or {EVOLUTION_FAILED}"
179+
)
180+
return None
181+
182+
if has_tool_calls:
183+
logger.debug(f"Evolution agent used tools (iter {iteration + 1}/{_MAX_EVOLUTION_ITERATIONS})")
184+
else:
185+
# No tools, no token — nudge the LLM
186+
logger.debug(
187+
f"Evolution agent produced content without token or tools "
188+
f"(iter {iteration + 1}/{_MAX_EVOLUTION_ITERATIONS})"
189+
)
190+
191+
# Iteration guidance
192+
remaining = _MAX_EVOLUTION_ITERATIONS - iteration - 1
193+
messages.append(
194+
{
195+
"role": "system",
196+
"content": (
197+
f"Iteration {iteration + 1}/{_MAX_EVOLUTION_ITERATIONS} complete "
198+
f"({remaining} remaining). "
199+
f"If your edit is ready, output it and include {EVOLUTION_COMPLETE} "
200+
f"at the end. "
201+
f"If you cannot complete this evolution, output {EVOLUTION_FAILED} "
202+
f"with a reason. "
203+
f"Otherwise, continue gathering information with tools."
204+
),
205+
}
206+
)
207+
208+
# Should never reach here (is_last handles the final iteration)
209+
return None
210+
211+
212+
def parse_evolution_output(content: str) -> tuple[Optional[str], Optional[str]]:
213+
"""Extract edit content or failure reason from LLM output.
214+
215+
MUST only be called when ``EVOLUTION_COMPLETE`` or
216+
``EVOLUTION_FAILED`` is present in *content*.
217+
218+
Returns ``(clean_content, failure_reason)``:
219+
- ``(content, None)`` — ``EVOLUTION_COMPLETE`` found.
220+
- ``(None, reason)`` — ``EVOLUTION_FAILED`` found.
221+
"""
222+
stripped = content.strip()
223+
224+
# Failure takes priority (if both tokens appear, treat as failure)
225+
if EVOLUTION_FAILED in stripped:
226+
idx = stripped.index(EVOLUTION_FAILED)
227+
reason_part = stripped[idx + len(EVOLUTION_FAILED) :].strip()
228+
if reason_part.lower().startswith("reason:"):
229+
reason_part = reason_part[len("reason:") :].strip()
230+
reason = reason_part[:500] if reason_part else "LLM declined to produce edit (no reason given)"
231+
return None, reason
232+
233+
if EVOLUTION_COMPLETE in stripped:
234+
clean = stripped.replace(EVOLUTION_COMPLETE, "").strip()
235+
clean = _strip_markdown_fences(clean)
236+
return clean, None
237+
238+
# Caller guarantees a token is present; defensive fallback
239+
return None, "No completion token found (unexpected)"
240+
241+
242+
async def apply_with_retry(
243+
evolver,
244+
*,
245+
apply_fn,
246+
initial_content: str,
247+
skill_dir: Path,
248+
ctx: EvolutionContext,
249+
prompt: str,
250+
cleanup_on_retry: Optional[Path] = None,
251+
) -> "Optional[SkillEditResult]":
252+
"""Apply an edit with retry on failure.
253+
254+
If the first attempt fails (patch parse error, path mismatch, etc.),
255+
feeds the error back to the LLM and asks for a corrected version.
256+
257+
After successful application, runs structural validation.
258+
259+
Retry conversations are recorded to ``conversations.jsonl`` under
260+
agent_name="SkillEvolver.retry" so failed apply attempts and LLM
261+
corrections are preserved for debugging.
262+
263+
Args:
264+
evolver: The SkillEvolver instance.
265+
apply_fn: Callable that takes content str and returns SkillEditResult.
266+
initial_content: First LLM-generated content to try.
267+
skill_dir: Skill directory for validation.
268+
ctx: Evolution context (for retry LLM calls).
269+
prompt: Original prompt (for retry context).
270+
cleanup_on_retry: Directory to remove before retrying (for derive/create).
271+
"""
272+
from openspace.recording import RecordingManager
273+
274+
current_content = initial_content
275+
msg_history: List[Dict[str, Any]] = [
276+
{"role": "user", "content": prompt},
277+
{"role": "assistant", "content": initial_content},
278+
]
279+
280+
# Track whether we've recorded the retry setup (only on first retry)
281+
retry_setup_recorded = False
282+
283+
for attempt in range(_MAX_EVOLUTION_ATTEMPTS):
284+
# Clean up previous failed attempt (for derive/create)
285+
if attempt > 0 and cleanup_on_retry and cleanup_on_retry.exists():
286+
shutil.rmtree(cleanup_on_retry, ignore_errors=True)
287+
288+
# Apply the edit
289+
edit_result = apply_fn(current_content)
290+
291+
if edit_result.ok:
292+
# Validate the result
293+
validation_error = _validate_skill_dir(skill_dir)
294+
if validation_error is None:
295+
if attempt > 0:
296+
logger.info(f"Apply-retry succeeded on attempt {attempt + 1}/{_MAX_EVOLUTION_ATTEMPTS}")
297+
return edit_result
298+
else:
299+
# Validation failed — treat as error for retry
300+
error_msg = f"Validation failed: {validation_error}"
301+
logger.warning(
302+
f"Apply succeeded but validation failed "
303+
f"(attempt {attempt + 1}/{_MAX_EVOLUTION_ATTEMPTS}): "
304+
f"{validation_error}"
305+
)
306+
else:
307+
error_msg = edit_result.error or "Unknown apply error"
308+
logger.warning(f"Apply failed (attempt {attempt + 1}/{_MAX_EVOLUTION_ATTEMPTS}): {error_msg}")
309+
310+
# Last attempt? Give up.
311+
if attempt >= _MAX_EVOLUTION_ATTEMPTS - 1:
312+
logger.error(f"Apply-retry exhausted after {_MAX_EVOLUTION_ATTEMPTS} attempts. Last error: {error_msg}")
313+
# Clean up any partially created directory
314+
if cleanup_on_retry and cleanup_on_retry.exists():
315+
shutil.rmtree(cleanup_on_retry, ignore_errors=True)
316+
return None
317+
318+
# Record retry setup on first retry attempt
319+
if not retry_setup_recorded:
320+
recorded_retry = [
321+
{"role": m["role"], "content": _truncate(m.get("content", ""), _RECORDING_MAX_CHARS)}
322+
for m in msg_history
323+
]
324+
await RecordingManager.record_conversation_setup(
325+
setup_messages=recorded_retry,
326+
agent_name="SkillEvolver.retry",
327+
extra={
328+
"evolution_type": ctx.suggestion.evolution_type.value,
329+
"target_skills": ctx.suggestion.target_skill_ids,
330+
"first_error": error_msg[:300],
331+
},
332+
)
333+
retry_setup_recorded = True
334+
335+
# Feed error back to LLM for retry, including current file
336+
# content so the LLM doesn't hallucinate what's on disk.
337+
current_on_disk = evolver._format_skill_dir_content(skill_dir) if skill_dir.is_dir() else ""
338+
retry_prompt = f"The previous edit was not successful. This was the error:\n\n{error_msg}\n\n"
339+
if current_on_disk:
340+
retry_prompt += (
341+
f"Here is the CURRENT content of the skill files on disk "
342+
f"(use this as the ground truth for any SEARCH/REPLACE or "
343+
f"context anchors):\n\n{_truncate(current_on_disk, _SKILL_CONTENT_MAX_CHARS)}\n\n"
344+
)
345+
retry_prompt += "Please fix the issue and generate the edit again. Follow the same output format as before."
346+
msg_history.append({"role": "user", "content": retry_prompt})
347+
348+
# Call LLM for corrected version (no tools — just fix the edit)
349+
model = evolver._model or evolver._llm_client.model
350+
try:
351+
result = await evolver._llm_client.complete(
352+
messages=msg_history,
353+
model=model,
354+
)
355+
new_content = result["message"].get("content", "")
356+
if not new_content:
357+
logger.warning("Retry LLM returned empty content")
358+
continue
359+
360+
new_content = _strip_markdown_fences(new_content)
361+
# Strip evolution tokens that the LLM may include in retry responses
362+
new_content = new_content.replace(EVOLUTION_COMPLETE, "").replace(EVOLUTION_FAILED, "").strip()
363+
new_content, _ = _extract_change_summary(new_content)
364+
msg_history.append({"role": "assistant", "content": new_content})
365+
current_content = new_content
366+
367+
# Record retry iteration
368+
await RecordingManager.record_iteration_context(
369+
iteration=attempt + 1,
370+
delta_messages=[
371+
{"role": "user", "content": _truncate(retry_prompt, _RECORDING_MAX_CHARS)},
372+
{"role": "assistant", "content": _truncate(new_content, _RECORDING_MAX_CHARS)},
373+
],
374+
response_metadata={
375+
"has_tool_calls": False,
376+
"attempt": attempt + 1,
377+
"error": error_msg[:300],
378+
},
379+
agent_name="SkillEvolver.retry",
380+
)
381+
382+
except Exception as e:
383+
logger.error(f"Retry LLM call failed: {e}")
384+
continue
385+
386+
return None

0 commit comments

Comments
 (0)