patcher: handle recursion errors and better reflection (#752)

* patcher: handle recursion errors and better reflection

* patcher: fix patch-strategy reflection state + add draw graph py

* fix lint

* add img
This commit is contained in:
Riccardo Schirone
2025-05-30 17:51:42 +02:00
committed by GitHub
parent d86ce91f33
commit b79717cd8e
8 changed files with 185 additions and 11 deletions
+2
View File
@@ -1 +1,3 @@
# Buttercup Patcher
![Patcher State Machine](patcher_state_machine.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -155,6 +155,7 @@ class ExecutionInfo(BaseModel):
"""Execution info"""
root_cause_analysis_tries: int = Field(default=0)
patch_strategy_tries: int = Field(default=0)
tests_tries: int = Field(default=0)
reflection_decision: PatcherAgentName | None = None
reflection_guidance: str | None = None
@@ -15,6 +15,7 @@ class PatcherConfig(BaseModel):
max_last_failure_retries: int = Field(default=3)
max_minutes_run_povs: int = Field(default=30)
max_root_cause_analysis_retries: int = Field(default=3)
max_patch_strategy_retries: int = Field(default=3)
max_tests_retries: int = Field(default=5)
ctx_retriever_recursion_limit: int = Field(default=80)
n_initial_stackframes: int = Field(default=4)
@@ -0,0 +1,88 @@
"""Module to visualize the patcher agent state machine."""
from pathlib import Path
from typing import Optional
import graphviz
def draw_state_machine(output_path: Optional[Path] = None) -> None:
"""Draw the patcher agent state machine and save it as a PNG file.
Args:
output_path: Optional path where to save the PNG file. If None, saves in current directory.
"""
# Create a new directed graph
dot = graphviz.Digraph(comment="Patcher Agent State Machine")
dot.attr(rankdir="LR") # Left to right layout
# Add nodes for each state
states = [
"START",
"INPUT_PROCESSING",
"FIND_TESTS",
"INITIAL_CODE_SNIPPET_REQUESTS",
"ROOT_CAUSE_ANALYSIS",
"PATCH_STRATEGY",
"CREATE_PATCH",
"BUILD_PATCH",
"RUN_POV",
"RUN_TESTS",
"REFLECTION",
"CONTEXT_RETRIEVER",
"END",
]
# Add nodes with styling
for state in states:
if state == "START":
dot.node(state, state, shape="oval", style="filled", fillcolor="green")
elif state == "END":
dot.node(state, state, shape="oval", style="filled", fillcolor="red")
else:
dot.node(state, state, shape="box", style="rounded,filled", fillcolor="lightblue")
# Add edges to show the flow
# Start and End transitions
dot.edge("START", "INPUT_PROCESSING", color="blue", penwidth="2.0")
dot.edge("RUN_TESTS", "END", color="blue", penwidth="2.0")
dot.edge("REFLECTION", "END", color="gray")
# Main flow (colored in blue)
dot.edge("INPUT_PROCESSING", "FIND_TESTS", color="blue", penwidth="2.0")
dot.edge("INPUT_PROCESSING", "INITIAL_CODE_SNIPPET_REQUESTS", color="blue", penwidth="2.0")
dot.edge("FIND_TESTS", "ROOT_CAUSE_ANALYSIS", color="blue", penwidth="2.0")
dot.edge("INITIAL_CODE_SNIPPET_REQUESTS", "ROOT_CAUSE_ANALYSIS", color="blue", penwidth="2.0")
dot.edge("ROOT_CAUSE_ANALYSIS", "PATCH_STRATEGY", color="blue", penwidth="2.0")
dot.edge("PATCH_STRATEGY", "CREATE_PATCH", color="blue", penwidth="2.0")
dot.edge("CREATE_PATCH", "BUILD_PATCH", color="blue", penwidth="2.0")
dot.edge("BUILD_PATCH", "RUN_POV", color="blue", penwidth="2.0")
dot.edge("RUN_POV", "RUN_TESTS", color="blue", penwidth="2.0")
# Add transitions to REFLECTION (in gray)
dot.edge("ROOT_CAUSE_ANALYSIS", "REFLECTION", color="gray")
dot.edge("PATCH_STRATEGY", "REFLECTION", color="gray")
dot.edge("CREATE_PATCH", "REFLECTION", color="gray")
dot.edge("BUILD_PATCH", "REFLECTION", color="gray")
dot.edge("RUN_POV", "REFLECTION", color="gray")
dot.edge("RUN_TESTS", "REFLECTION", color="gray")
# Add transitions from REFLECTION (in gray)
dot.edge("REFLECTION", "ROOT_CAUSE_ANALYSIS", color="gray")
dot.edge("REFLECTION", "PATCH_STRATEGY", color="gray")
dot.edge("REFLECTION", "CREATE_PATCH", color="gray")
dot.edge("REFLECTION", "CONTEXT_RETRIEVER", color="gray")
# Add transitions from CONTEXT_RETRIEVER (in gray)
dot.edge("CONTEXT_RETRIEVER", "REFLECTION", color="gray")
dot.edge("CONTEXT_RETRIEVER", "PATCH_STRATEGY", color="gray")
dot.edge("CONTEXT_RETRIEVER", "ROOT_CAUSE_ANALYSIS", color="gray")
# Save the graph
if output_path is None:
output_path = Path("patcher_state_machine")
dot.render(str(output_path), format="png", cleanup=True)
if __name__ == "__main__":
draw_state_machine()
@@ -24,6 +24,7 @@ from buttercup.patcher.agents.common import (
PatchAttempt,
CodeSnippetRequest,
PatchAnalysis,
PatchStrategy,
)
from buttercup.patcher.agents.config import PatcherConfig
from buttercup.common.llm import ButtercupLLM, create_default_llm
@@ -680,7 +681,16 @@ class ReflectionAgent(PatcherAgentBase):
state.context.task_id,
state.context.submission_index,
)
return Command(goto=PatcherAgentName.PATCH_STRATEGY.value)
root_cause = state.root_cause
if root_cause is None:
root_cause = "No root cause found, figure out a root cause"
return Command(
update={
"root_cause": root_cause,
},
goto=PatcherAgentName.PATCH_STRATEGY.value,
)
execution_info = state.execution_info
execution_info.root_cause_analysis_tries += 1
@@ -691,7 +701,35 @@ class ReflectionAgent(PatcherAgentBase):
update={
"execution_info": execution_info,
},
goto=PatcherAgentName.INPUT_PROCESSING.value,
goto=PatcherAgentName.ROOT_CAUSE_ANALYSIS.value,
)
def _patch_strategy_failed(self, state: PatcherAgentState, configuration: PatcherConfig) -> Command:
"""Patch strategy failed, reflect on the failure."""
if state.execution_info.patch_strategy_tries >= configuration.max_patch_strategy_retries:
logger.warning(
"[%s / %s] Reached max patch strategy failures, just move forward with what we have",
state.context.task_id,
state.context.submission_index,
)
strategy = state.patch_strategy
if strategy is None:
strategy = PatchStrategy(full="Figure out a patch strategy", summary="Figure out a patch strategy")
return Command(
update={
"patch_strategy": strategy,
},
goto=PatcherAgentName.CREATE_PATCH.value,
)
execution_info = state.execution_info
execution_info.patch_strategy_tries += 1
return Command(
update={
"execution_info": execution_info,
},
goto=PatcherAgentName.PATCH_STRATEGY.value,
)
def reflect_on_patch(self, state: PatcherAgentState, config: RunnableConfig) -> Command:
@@ -738,8 +776,7 @@ class ReflectionAgent(PatcherAgentBase):
goto=PatcherAgentName.CONTEXT_RETRIEVER.value,
)
current_patch_attempt = state.get_last_patch_attempt()
if not current_patch_attempt or state.execution_info.prev_node == PatcherAgentName.ROOT_CAUSE_ANALYSIS:
if state.execution_info.prev_node == PatcherAgentName.ROOT_CAUSE_ANALYSIS:
logger.warning(
"[%s / %s] No patch attempt found, the root cause analysis is probably wrong",
state.context.task_id,
@@ -747,4 +784,21 @@ class ReflectionAgent(PatcherAgentBase):
)
return self._root_cause_analysis_failed(state, configuration)
if state.execution_info.prev_node == PatcherAgentName.PATCH_STRATEGY:
logger.warning(
"[%s / %s] Patch strategy failed, reflecting on it",
state.context.task_id,
state.context.submission_index,
)
return self._patch_strategy_failed(state, configuration)
current_patch_attempt = state.get_last_patch_attempt()
if not current_patch_attempt:
logger.error(
"[%s / %s] No patch attempt found, this should never happen, going back to input processing",
state.context.task_id,
state.context.submission_index,
)
return Command(goto=PatcherAgentName.INPUT_PROCESSING.value)
return self._analyze_failure(state, configuration, current_patch_attempt)
@@ -2,6 +2,7 @@
import logging
import re
import langgraph.errors
from typing import Annotated, Literal
from langgraph.prebuilt import InjectedState
from langchain_core.prompts import MessagesPlaceholder
@@ -225,6 +226,18 @@ class RootCauseAgent(PatcherAgentBase):
try:
root_cause_dict = self.root_cause_chain.invoke(state)
except langgraph.errors.GraphRecursionError:
logger.error(
"Reached recursion limit for root cause analysis in Challenge Task %s/%s",
state.context.task_id,
self.challenge.name,
)
return Command(
update={
"execution_info": execution_info,
},
goto=PatcherAgentName.REFLECTION.value,
)
except Exception as e:
logger.exception("Error parsing root cause: %s", e)
return Command(
+22 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import difflib
import uuid
import langgraph.errors
from langchain_core.messages import BaseMessage
from langgraph.prebuilt import InjectedState
from langchain_core.prompts import MessagesPlaceholder
@@ -700,12 +701,26 @@ class SWEAgent(PatcherAgentBase):
configurable = {
"thread_id": str(uuid.uuid4()),
}
strategy_state_dict = self.patch_strategy_chain.invoke(
state,
config=RunnableConfig(
configurable=configurable,
),
)
try:
strategy_state_dict = self.patch_strategy_chain.invoke(
state,
config=RunnableConfig(
configurable=configurable,
),
)
except langgraph.errors.GraphRecursionError:
logger.error(
"Reached recursion limit for patch strategy in Challenge Task %s/%s",
state.context.task_id,
self.challenge.name,
)
return Command(
update={
"execution_info": execution_info,
},
goto=PatcherAgentName.REFLECTION.value,
)
try:
strategy_state = PatcherAgentState.model_validate(strategy_state_dict)
except ValidationError as e:
@@ -729,13 +744,13 @@ class SWEAgent(PatcherAgentBase):
patch_strategy_str = str(strategy_state.messages[-1].content)
if "<request_information>" in patch_strategy_str:
new_code_snippet_requests = self._parse_code_snippet_requests(patch_strategy_str)
execution_info.code_snippet_requests = new_code_snippet_requests
logger.info(
"[%s / %s] Requesting additional information", state.context.task_id, state.context.submission_index
)
return Command(
update={
"execution_info": execution_info,
"code_snippet_requests": new_code_snippet_requests,
},
goto=PatcherAgentName.REFLECTION.value,
)