import json import os import re from typing import Any, ClassVar import jinja2 from openhands.core.config import LLMConfig from openhands.events.event import Event from openhands.llm.llm import LLM from openhands.resolver.interfaces.issue import ( Issue, IssueHandlerInterface, ReviewThread, ) from openhands.resolver.utils import extract_image_urls class ServiceContext: issue_type: ClassVar[str] default_git_patch: ClassVar[str] = 'No changes made yet' def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig | None): self._strategy = strategy if llm_config is not None: self.llm = LLM(llm_config) def set_strategy(self, strategy: IssueHandlerInterface) -> None: self._strategy = strategy # Strategy context interface class ServiceContextPR(ServiceContext): issue_type: ClassVar[str] = 'pr' def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig): super().__init__(strategy, llm_config) def get_clone_url(self) -> str: return self._strategy.get_clone_url() def download_issues(self) -> list[Any]: return self._strategy.download_issues() def guess_success( self, issue: Issue, history: list[Event], git_patch: str | None = None, ) -> tuple[bool, None | list[bool], str]: """Guess if the issue is fixed based on the history, issue description and git patch. Args: issue: The issue to check history: The agent's history git_patch: Optional git patch showing the changes made """ last_message = history[-1].message issues_context = json.dumps(issue.closing_issues, indent=4) success_list = [] explanation_list = [] # Handle PRs with file-specific review comments if issue.review_threads: for review_thread in issue.review_threads: if issues_context and last_message: success, explanation = self._check_review_thread( review_thread, issues_context, last_message, git_patch ) else: success, explanation = False, 'Missing context or message' success_list.append(success) explanation_list.append(explanation) # Handle PRs with only thread comments (no file-specific review comments) elif issue.thread_comments: if issue.thread_comments and issues_context and last_message: success, explanation = self._check_thread_comments( issue.thread_comments, issues_context, last_message, git_patch ) else: success, explanation = ( False, 'Missing thread comments, context or message', ) success_list.append(success) explanation_list.append(explanation) elif issue.review_comments: # Handle PRs with only review comments (no file-specific review comments or thread comments) if issue.review_comments and issues_context and last_message: success, explanation = self._check_review_comments( issue.review_comments, issues_context, last_message, git_patch ) else: success, explanation = ( False, 'Missing review comments, context or message', ) success_list.append(success) explanation_list.append(explanation) else: # No review comments, thread comments, or file-level review comments found return False, None, 'No feedback was found to process' # Return overall success (all must be true) and explanations if not success_list: return False, None, 'No feedback was processed' return all(success_list), success_list, json.dumps(explanation_list) def get_converted_issues( self, issue_numbers: list[int] | None = None, comment_id: int | None = None ) -> list[Issue]: return self._strategy.get_converted_issues(issue_numbers, comment_id) def get_instruction( self, issue: Issue, user_instructions_prompt_template: str, conversation_instructions_prompt_template: str, repo_instruction: str | None = None, ) -> tuple[str, str, list[str]]: """Generate instruction for the agent.""" user_instruction_template = jinja2.Template(user_instructions_prompt_template) conversation_instructions_template = jinja2.Template( conversation_instructions_prompt_template ) images = [] issues_str = None if issue.closing_issues: issues_str = json.dumps(issue.closing_issues, indent=4) images.extend(extract_image_urls(issues_str)) # Handle PRs with review comments review_comments_str = None if issue.review_comments: review_comments_str = json.dumps(issue.review_comments, indent=4) images.extend(extract_image_urls(review_comments_str)) # Handle PRs with file-specific review comments review_thread_str = None review_thread_file_str = None if issue.review_threads: review_threads = [ review_thread.comment for review_thread in issue.review_threads ] review_thread_files = [] for review_thread in issue.review_threads: review_thread_files.extend(review_thread.files) review_thread_str = json.dumps(review_threads, indent=4) review_thread_file_str = json.dumps(review_thread_files, indent=4) images.extend(extract_image_urls(review_thread_str)) # Format thread comments if they exist thread_context = '' if issue.thread_comments: thread_context = '\n---\n'.join(issue.thread_comments) images.extend(extract_image_urls(thread_context)) user_instruction = user_instruction_template.render( review_comments=review_comments_str, review_threads=review_thread_str, files=review_thread_file_str, thread_context=thread_context, ) conversation_instructions = conversation_instructions_template.render( issues=issues_str, repo_instruction=repo_instruction ) return user_instruction, conversation_instructions, images def _check_feedback_with_llm(self, prompt: str) -> tuple[bool, str]: """Helper function to check feedback with LLM and parse response.""" response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) answer = response.choices[0].message.content.strip() pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' match = re.search(pattern, answer) if match: return match.group(1).lower() == 'true', match.group(2).strip() return False, f'Failed to decode answer from LLM response: {answer}' def _check_review_thread( self, review_thread: ReviewThread, issues_context: str, last_message: str, git_patch: str | None = None, ) -> tuple[bool, str]: """Check if a review thread's feedback has been addressed.""" files_context = json.dumps(review_thread.files, indent=4) with open( os.path.join( os.path.dirname(__file__), '../prompts/guess_success/pr-feedback-check.jinja', ), 'r', ) as f: template = jinja2.Template(f.read()) prompt = template.render( issue_context=issues_context, feedback=review_thread.comment, files_context=files_context, last_message=last_message, git_patch=git_patch or self.default_git_patch, ) return self._check_feedback_with_llm(prompt) def _check_thread_comments( self, thread_comments: list[str], issues_context: str, last_message: str, git_patch: str | None = None, ) -> tuple[bool, str]: """Check if thread comments feedback has been addressed.""" thread_context = '\n---\n'.join(thread_comments) with open( os.path.join( os.path.dirname(__file__), '../prompts/guess_success/pr-thread-check.jinja', ), 'r', ) as f: template = jinja2.Template(f.read()) prompt = template.render( issue_context=issues_context, thread_context=thread_context, last_message=last_message, git_patch=git_patch or self.default_git_patch, ) return self._check_feedback_with_llm(prompt) def _check_review_comments( self, review_comments: list[str], issues_context: str, last_message: str, git_patch: str | None = None, ) -> tuple[bool, str]: """Check if review comments feedback has been addressed.""" review_context = '\n---\n'.join(review_comments) with open( os.path.join( os.path.dirname(__file__), '../prompts/guess_success/pr-review-check.jinja', ), 'r', ) as f: template = jinja2.Template(f.read()) prompt = template.render( issue_context=issues_context, review_context=review_context, last_message=last_message, git_patch=git_patch or self.default_git_patch, ) return self._check_feedback_with_llm(prompt) class ServiceContextIssue(ServiceContext): issue_type: ClassVar[str] = 'issue' def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig | None): super().__init__(strategy, llm_config) def get_base_url(self) -> str: return self._strategy.get_base_url() def get_branch_url(self, branch_name: str) -> str: return self._strategy.get_branch_url(branch_name) def get_download_url(self) -> str: return self._strategy.get_download_url() def get_clone_url(self) -> str: return self._strategy.get_clone_url() def get_graphql_url(self) -> str: return self._strategy.get_graphql_url() def get_headers(self) -> dict[str, str]: return self._strategy.get_headers() def get_authorize_url(self) -> str: return self._strategy.get_authorize_url() def get_pull_url(self, pr_number: int) -> str: return self._strategy.get_pull_url(pr_number) def get_compare_url(self, branch_name: str) -> str: return self._strategy.get_compare_url(branch_name) def download_issues(self) -> list[Any]: return self._strategy.download_issues() def get_branch_name( self, base_branch_name: str, ) -> str: return self._strategy.get_branch_name(base_branch_name) def branch_exists(self, branch_name: str) -> bool: return self._strategy.branch_exists(branch_name) def get_default_branch_name(self) -> str: return self._strategy.get_default_branch_name() def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]: if data is None: data = {} return self._strategy.create_pull_request(data) def request_reviewers(self, reviewer: str, pr_number: int) -> None: return self._strategy.request_reviewers(reviewer, pr_number) def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None: return self._strategy.reply_to_comment(pr_number, comment_id, reply) def send_comment_msg(self, issue_number: int, msg: str) -> None: return self._strategy.send_comment_msg(issue_number, msg) def get_issue_comments( self, issue_number: int, comment_id: int | None = None ) -> list[str] | None: return self._strategy.get_issue_comments(issue_number, comment_id) def get_instruction( self, issue: Issue, user_instructions_prompt_template: str, conversation_instructions_prompt_template: str, repo_instruction: str | None = None, ) -> tuple[str, str, list[str]]: """Generate instruction for the agent.""" # Format thread comments if they exist thread_context = '' if issue.thread_comments: thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join( issue.thread_comments ) images = [] images.extend(extract_image_urls(issue.body)) images.extend(extract_image_urls(thread_context)) user_instructions_template = jinja2.Template(user_instructions_prompt_template) user_instructions = user_instructions_template.render( body=issue.title + '\n\n' + issue.body + thread_context ) # Issue body and comments conversation_instructions_template = jinja2.Template( conversation_instructions_prompt_template ) conversation_instructions = conversation_instructions_template.render( repo_instruction=repo_instruction, ) return user_instructions, conversation_instructions, images def guess_success( self, issue: Issue, history: list[Event], git_patch: str | None = None ) -> tuple[bool, None | list[bool], str]: """Guess if the issue is fixed based on the history and the issue description. Args: issue: The issue to check history: The agent's history git_patch: Optional git patch showing the changes made """ last_message = history[-1].message # Include thread comments in the prompt if they exist issue_context = issue.body if issue.thread_comments: issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join( issue.thread_comments ) with open( os.path.join( os.path.dirname(__file__), '../prompts/guess_success/issue-success-check.jinja', ), 'r', ) as f: template = jinja2.Template(f.read()) prompt = template.render( issue_context=issue_context, last_message=last_message, git_patch=git_patch or self.default_git_patch, ) response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) answer = response.choices[0].message.content.strip() pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' match = re.search(pattern, answer) if match: return match.group(1).lower() == 'true', None, match.group(2) return False, None, f'Failed to decode answer from LLM response: {answer}' def get_converted_issues( self, issue_numbers: list[int] | None = None, comment_id: int | None = None ) -> list[Issue]: return self._strategy.get_converted_issues(issue_numbers, comment_id)