|
| 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