diff --git a/rock/env_vars.py b/rock/env_vars.py index 8da47d71..62ebf10d 100644 --- a/rock/env_vars.py +++ b/rock/env_vars.py @@ -44,6 +44,9 @@ ROCK_AGENT_PRE_STARTUP_BASH_CMD_LIST: list[str] = [] ROCK_AGENT_PYTHON_INSTALL_CMD: str + ROCK_AGENT_NPM_INSTALL_CMD: str + ROCK_AGENT_IFLOW_CLI_INSTALL_CMD: str + environment_variables: dict[str, Callable[[], Any]] = { "ROCK_LOGGING_PATH": lambda: os.getenv("ROCK_LOGGING_PATH"), @@ -84,6 +87,14 @@ "[ -f cpython31114.tar.gz ] && rm cpython31114.tar.gz; [ -d python ] && rm -rf python; wget -q -O cpython31114.tar.gz https://github.com/astral-sh/python-build-standalone/releases/download/20251120/cpython-3.11.14+20251120-x86_64-unknown-linux-gnu-install_only.tar.gz && tar -xzf cpython31114.tar.gz", ), "ROCK_AGENT_PRE_STARTUP_BASH_CMD_LIST": lambda: json.loads(os.getenv("ROCK_AGENT_PRE_STARTUP_BASH_CMD_LIST", "[]")), + "ROCK_AGENT_NPM_INSTALL_CMD": lambda: os.getenv( + "ROCK_AGENT_NPM_INSTALL_CMD", + "wget --tries=10 --waitretry=2 https://npmmirror.com/mirrors/node/v22.18.0/node-v22.18.0-linux-x64.tar.xz && tar -xf node-v22.18.0-linux-x64.tar.xz -C /opt/ && mv /opt/node-v22.18.0-linux-x64 /opt/nodejs && ln -sf /opt/nodejs/bin/node /usr/local/bin/node && ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm && ln -sf /opt/nodejs/bin/npx /usr/local/bin/npx && ln -sf /opt/nodejs/bin/corepack /usr/local/bin/corepack", + ), + "ROCK_AGENT_IFLOW_CLI_INSTALL_CMD": lambda: os.getenv( + "ROCK_AGENT_IFLOW_CLI_INSTALL_CMD", + "npm i -g @iflow-ai/iflow-cli@latest && ln -s /opt/nodejs/bin/iflow /usr/local/bin/iflow", + ), } diff --git a/rock/sdk/sandbox/agent/iflow_cli.py b/rock/sdk/sandbox/agent/iflow_cli.py index 28ccc8b0..e8090915 100644 --- a/rock/sdk/sandbox/agent/iflow_cli.py +++ b/rock/sdk/sandbox/agent/iflow_cli.py @@ -1,24 +1,497 @@ -from typing import Literal +import json +import os +import re +import shlex +import tempfile +from contextlib import contextmanager +from typing import Any +from rock import env_vars +from rock.actions import CreateBashSessionRequest, UploadRequest from rock.actions.sandbox.base import AbstractSandbox +from rock.logger import init_logger from rock.sdk.sandbox.agent.base import Agent from rock.sdk.sandbox.agent.config import AgentConfig +from rock.sdk.sandbox.client import Sandbox +from rock.utils.retry import retry_async + +logger = init_logger(__name__) + + +# Default IFlow settings +DEFAULT_IFLOW_SETTINGS: dict[str, Any] = { + "selectedAuthType": "openai-compatible", + "apiKey": "", + "baseUrl": "", + "modelName": "", + "searchApiKey": "88888888", + "disableAutoUpdate": True, + "shellTimeout": 360000, + "tokensLimit": 128000, + "coreTools": [ + "Edit", + "exit_plan_mode", + "glob", + "list_directory", + "multi_edit", + "plan", + "read plan", + "read_file", + "read_many_files", + "save_memory", + "Search", + "Shell", + "task", + "web_fetch", + "web_search", + "write_file", + "xml_escape", + ], +} class IFlowCliConfig(AgentConfig): - agent_type: Literal["iflow-cli"] = "iflow-cli" - install_url: str + """IFlow CLI Agent Configuration Class. + + Used to define and configure various parameters for the IFlow CLI sandbox agent, + including session settings, installation scripts, timeout configurations, etc. + + Attributes: + agent_type: Agent type identifier, fixed to "iflow-cli". + agent_session: Bash session name for agent operations. + pre_startup_bash_cmd_list: Commands to execute before agent initialization + (e.g., bashrc setup, hosts config). + npm_install_cmd: NPM installation command that downloads Node.js binary from + OSS and extracts to /opt/nodejs. + npm_ln_cmd: Command to create symbolic links for NPM related commands to + system paths. + npm_install_timeout: NPM installation command timeout in seconds. + iflow_cli_install_cmd: IFlow CLI installation command that downloads .tgz + package and installs globally. + iflow_cli_ln_cmd: Command to create symbolic link for IFlow CLI executable + to system path. + iflow_settings: Default IFlow configuration settings dict. + iflow_run_cmd: Command template for running IFlow CLI. Supports {session_id} + and {problem_statement} placeholders. When session_id is empty string, + it generates: iflow -r "" -p {problem_statement} + agent_run_timeout: Agent execution timeout in seconds. Defaults to 30 minutes. + agent_run_check_interval: Interval for checking progress during agent + execution in seconds. + iflow_log_file: IFlow log file path in sandbox. + """ + + agent_type: str = "iflow-cli" + + agent_session: str = "iflow-cli-session" + + pre_startup_bash_cmd_list: list[str] = env_vars.ROCK_AGENT_PRE_STARTUP_BASH_CMD_LIST + + npm_install_cmd: str = env_vars.ROCK_AGENT_NPM_INSTALL_CMD + + npm_install_timeout: int = 300 + + iflow_cli_install_cmd: str = env_vars.ROCK_AGENT_IFLOW_CLI_INSTALL_CMD + + iflow_settings: dict[str, Any] = DEFAULT_IFLOW_SETTINGS + + iflow_run_cmd: str = "iflow -r {session_id} -p {problem_statement} --yolo > {iflow_log_file} 2>&1" + + iflow_log_file: str = "~/.iflow/session_info.log" + + session_envs: dict[str, str] = { + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } class IFlowCli(Agent): + """IFlow CLI Agent Class. + + Manages the lifecycle of the IFlow CLI, including initialization, installation, + and execution phases. Supports session resumption for continuing previous work. + """ + def __init__(self, sandbox: AbstractSandbox, config: IFlowCliConfig): + """Initialize IFlow CLI agent. + + Args: + sandbox: Sandbox instance used to execute commands and file operations. + config: Configuration object for IFlow CLI. + """ super().__init__(sandbox) self.config = config + @contextmanager + def _temp_iflow_settings_file(self): + """Context manager for creating temporary iflow settings file. + + Creates a temporary JSON file with the configured IFlow settings + and ensures cleanup after use. + + Yields: + str: Path to the temporary settings file + """ + # Create the settings json content using config settings + settings_content = json.dumps(self.config.iflow_settings, indent=2) + + # Create a temporary file to hold the settings + with tempfile.NamedTemporaryFile(mode="w", suffix="_iflow_settings.json", delete=False) as temp_file: + temp_file.write(settings_content) + temp_settings_path = temp_file.name + + try: + yield temp_settings_path + finally: + # Clean up the temporary file + os.unlink(temp_settings_path) + async def init(self): - # Initialization logic for IFlow CLI agent - pass + """Initialize IFlow CLI agent. + + Sets up all the environment required for agent execution, including: + 1. Creating dedicated bash session + 2. Executing pre-startup configuration commands + 3. Installing NPM and Node.js + 4. Installing IFlow CLI tool + 5. Generating and uploading configuration files from default config dict + + Raises: + Exception: If any critical initialization step fails (e.g., creating + directories, generating and uploading config files). + """ + assert isinstance(self._sandbox, Sandbox), "Sandbox must be an instance of Sandbox class" + + sandbox_id = self._sandbox.sandbox_id + + logger.info(f"[{sandbox_id}] Starting IFlow CLI-agent initialization") + + # Step 1: Create dedicated bash session for agent operations + logger.info(f"[{sandbox_id}] Creating bash session: {self.config.agent_session}") + await self._sandbox.create_session( + CreateBashSessionRequest( + session=self.config.agent_session, + env_enable=True, + env=self.config.session_envs, + ) + ) + logger.debug(f"[{sandbox_id}] Bash session '{self.config.agent_session}' created successfully") + + # Step 2: Execute pre-startup configuration commands + logger.info(f"[{sandbox_id}] Executing {len(self.config.pre_startup_bash_cmd_list)} pre-startup commands") + for idx, cmd in enumerate(self.config.pre_startup_bash_cmd_list, 1): + logger.debug( + f"[{sandbox_id}] Executing pre-startup command {idx}/{len(self.config.pre_startup_bash_cmd_list)}: {cmd[:100]}..." + ) + result = await self._sandbox.arun( + cmd=cmd, + session=self.config.agent_session, + ) + if result.exit_code != 0: + logger.warning( + f"[{sandbox_id}] Pre-startup command {idx} failed with exit code {result.exit_code}: {result.output[:200]}..." + ) + else: + logger.debug(f"[{sandbox_id}] Pre-startup command {idx} completed successfully") + logger.info(f"[{sandbox_id}] Completed {len(self.config.pre_startup_bash_cmd_list)} pre-startup commands") + + # Step 3: Install npm with retry + logger.info(f"[{sandbox_id}] Installing npm") + logger.debug(f"[{sandbox_id}] NPM install command: {self.config.npm_install_cmd[:100]}...") + + await self._arun_with_retry( + cmd=f"bash -c {shlex.quote(self.config.npm_install_cmd)}", + session=self.config.agent_session, + mode="nohup", + wait_timeout=self.config.npm_install_timeout, + error_msg="npm installation failed", + ) + logger.info(f"[{sandbox_id}] npm installation completed") + + # Step 4: Configure npm to use mirror registry for faster downloads + logger.info(f"[{sandbox_id}] Configuring npm registry") + result = await self._sandbox.arun( + cmd="npm config set registry https://registry.npmmirror.com", + session=self.config.agent_session, + ) + if result.exit_code != 0: + logger.warning(f"[{sandbox_id}] Failed to set npm registry: {result.output}") + else: + logger.debug(f"[{sandbox_id}] Npm registry configured successfully") + + # Step 5: Install iflow-cli with retry + logger.info(f"[{sandbox_id}] Installing iflow-cli") + logger.debug(f"[{sandbox_id}] IFlow CLI install command: {self.config.iflow_cli_install_cmd[:100]}...") + + await self._arun_with_retry( + cmd=f"bash -c {shlex.quote(self.config.iflow_cli_install_cmd)}", + session=self.config.agent_session, + mode="nohup", + wait_timeout=self.config.npm_install_timeout, + error_msg="iflow-cli installation failed", + ) + + logger.info(f"[{sandbox_id}] iflow-cli installation completed successfully") + + # Step 6: Create iflow config directories + logger.info(f"[{sandbox_id}] Creating iflow settings directories") + result = await self._sandbox.arun( + cmd="mkdir -p /root/.iflow && mkdir -p ~/.iflow", + session=self.config.agent_session, + ) + if result.exit_code != 0: + logger.error(f"[{sandbox_id}] Failed to create iflow directories: {result.output}") + raise Exception(f"Failed to create iflow directories: {result.output}") + logger.debug(f"[{sandbox_id}] IFlow settings directories created") + + # Step 7: Generate and upload iflow-settings.json configuration file from config dict + logger.info(f"[{sandbox_id}] Generating and uploading iflow settings from config dict") + + # Use context manager to create and clean up temporary settings file + with self._temp_iflow_settings_file() as temp_settings_path: + await self._sandbox.upload( + UploadRequest( + source_path=temp_settings_path, + target_path="/root/.iflow/settings.json", + ) + ) + logger.debug(f"[{sandbox_id}] Settings uploaded to /root/.iflow/settings.json") + + logger.info(f"[{sandbox_id}] IFlow settings configuration completed successfully") + + @retry_async(max_attempts=3, delay_seconds=5.0, backoff=2.0) + async def _arun_with_retry( + self, + cmd: str, + session: str, + mode: str = "nohup", + wait_timeout: int = 300, + wait_interval: int = 10, + error_msg: str = "Command failed", + ): + """Execute command with retry logic. + + Executes a command and automatically retries up to 3 times when the command + fails (non-zero exit code). Implements exponential backoff strategy with + delay between retries that increases progressively. + + Args: + cmd: Command to be executed. + session: Session name where the command will be executed. + mode: Execution mode (normal, nohup, etc.). Defaults to "nohup". + wait_timeout: Timeout for command execution in seconds. Defaults to 300. + wait_interval: Check interval for nohup commands in seconds. Defaults to 10. + error_msg: Error message to use when exception occurs. Defaults to + "Command failed". + + Returns: + Command result object upon successful execution. + + Raises: + Exception: Raises exception when command execution fails (non-zero exit + code) to trigger retry. + """ + assert isinstance(self._sandbox, Sandbox), "Sandbox must be an instance of Sandbox class" + + sandbox_id = self._sandbox.sandbox_id + logger.debug(f"[{sandbox_id}] Executing command with retry: {cmd[:100]}...") + logger.debug( + f"[{sandbox_id}] Command execution parameters: mode={mode}, timeout={wait_timeout}, interval={wait_interval}" + ) + + result = await self._sandbox.arun( + cmd=cmd, session=session, mode=mode, wait_timeout=wait_timeout, wait_interval=wait_interval + ) + + logger.debug(f"[{sandbox_id}] Command execution result: exit_code={result.exit_code}") + + # If exit_code is not 0, raise an exception to trigger retry + if result.exit_code != 0: + logger.warning(f"[{sandbox_id}] Command attempt failed: {error_msg}, exit code: {result.exit_code}") + logger.debug(f"[{sandbox_id}] Command output: {result.output[:500]}...") + raise Exception(f"{error_msg} with exit code: {result.exit_code}, output: {result.output}") + + logger.debug(f"[{sandbox_id}] Command executed successfully with retry mechanism") + return result + + def _extract_session_id_from_log(self, log_content: str) -> str: + """Extract session ID from IFlow log file content. + + Parses the log content to find JSON block and extracts + the session-id field. + + Args: + log_content: Content from the log file (ideally last 1000 lines). + + Returns: + Session ID string if found, empty string otherwise. + """ + assert isinstance(self._sandbox, Sandbox), "Sandbox must be an instance of Sandbox class" + + sandbox_id = self._sandbox.sandbox_id + logger.debug(f"[{sandbox_id}] Attempting to extract session-id from log content") + + try: + # Extract JSON content between tags + json_match = re.search(r"\s*(.*?)\s*", log_content, re.DOTALL) + + if not json_match: + logger.debug(f"[{sandbox_id}] No block found in log") + return "" + + json_str = json_match.group(1).strip() + logger.debug(f"[{sandbox_id}] Found Execution Info block, parsing JSON") + + data = json.loads(json_str) + session_id = data.get("session-id", "") + + if session_id: + logger.info(f"[{sandbox_id}] Successfully extracted session-id: {session_id}") + return session_id + else: + logger.debug(f"[{sandbox_id}] session-id field not found in Execution Info") + return "" + + except json.JSONDecodeError as e: + logger.warning(f"[{sandbox_id}] Failed to parse JSON in Execution Info: {str(e)}") + return "" + except Exception as e: + logger.warning(f"[{sandbox_id}] Error extracting session-id: {str(e)}") + return "" + + async def _get_session_id_from_sandbox(self) -> str: + """Retrieve session ID from IFlow log file in sandbox. + + Fetches the last 1000 lines of the log file and extracts the session ID. + Returns empty string if log file is empty, not found, or parsing fails. + + The command uses 'tail -1000 ... 2>/dev/null || echo ""' to ensure: + - Errors (file not found, permission denied) are suppressed via 2>/dev/null + - If tail fails, echo returns empty string via || operator + - Exit code is always 0, making error checking unnecessary + + Returns: + Session ID string if found, empty string otherwise. + """ + assert isinstance(self._sandbox, Sandbox), "Sandbox must be an instance of Sandbox class" + + sandbox_id = self._sandbox.sandbox_id + logger.info(f"[{sandbox_id}] Retrieving session ID from sandbox log file") + + try: + log_file_path = self.config.iflow_log_file + logger.debug(f"[{sandbox_id}] Reading log file: {log_file_path}") + + result = await self._sandbox.arun( + cmd=f"tail -1000 {log_file_path} 2>/dev/null || echo ''", + session=self.config.agent_session, + ) + + log_content = result.output.strip() + + if not log_content: + logger.debug(f"[{sandbox_id}] Log file is empty or not found") + return "" + + logger.debug(f"[{sandbox_id}] Retrieved log content ({len(log_content)} bytes)") + session_id = self._extract_session_id_from_log(log_content) + return session_id + + except Exception as e: + logger.error(f"[{sandbox_id}] Error retrieving session ID from sandbox: {str(e)}") + return "" + + async def run( + self, + problem_statement: str, + project_path: str, + agent_run_timeout: int = 1800, + agent_run_check_interval: int = 30, + ): + """Run IFlow CLI to solve a specified problem. + + Automatically attempts to retrieve the previous session ID from the log file. + If a session ID is found, it will be used to resume the previous execution. + If not found, a fresh execution is started with an empty session ID. + + Args: + problem_statement: Problem statement that IFlow CLI will attempt to solve. + project_path: Project path, can be a string or Path object. + agent_run_timeout: Agent execution timeout in seconds. Defaults to 1800 (30 minutes). + agent_run_check_interval: Interval for checking progress during agent execution in seconds. Defaults to 30. + + Returns: + Object containing command execution results, including exit code and output. + """ + assert isinstance(self._sandbox, Sandbox), "Sandbox must be an instance of Sandbox class" + + sandbox_id = self._sandbox.sandbox_id + logger.info(f"[{sandbox_id}] Starting IFlow CLI run operation") + logger.debug(f"[{sandbox_id}] Project path: {project_path}, Problem statement: {problem_statement[:100]}...") + + # Step 1: Change to project directory + logger.info(f"[{sandbox_id}] Changing working directory to: {project_path}") + result = await self._sandbox.arun( + cmd=f"cd {project_path}", + session=self.config.agent_session, + ) + + if result.exit_code != 0: + logger.error(f"[{sandbox_id}] Failed to change directory to {project_path}: {result.output}") + return result + logger.debug(f"[{sandbox_id}] Successfully changed working directory") + + # Step 2: Attempt to retrieve session ID from previous execution + logger.info(f"[{sandbox_id}] Attempting to retrieve session ID from previous execution") + session_id = await self._get_session_id_from_sandbox() + if session_id: + logger.info(f"[{sandbox_id}] Using existing session ID: {session_id}") + else: + logger.info(f"[{sandbox_id}] No previous session found, will start fresh execution") + + # Step 3: Prepare and execute IFlow CLI command + logger.info( + f"[{sandbox_id}] Preparing to run IFlow CLI with timeout {agent_run_timeout}s " + f"and check interval {agent_run_check_interval}s" + ) + # iflow -r "session-c844f0f1-5754-4888-9c77-4ffe8dff10e5" -p "我是谁" + # Format the command with session ID (empty string if not found) and problem statement + iflow_run_cmd = self.config.iflow_run_cmd.format( + session_id=f'"{session_id}"', + problem_statement=shlex.quote(problem_statement), + iflow_log_file=self.config.iflow_log_file, + ) + logger.debug(f"[{sandbox_id}] IFlow command template: {self.config.iflow_run_cmd}") + logger.debug(f"[{sandbox_id}] Formatted IFlow command: {iflow_run_cmd}") + + # Wrap in 'bash -c' and quote the entire command to prevent shell parsing issues + safe_iflow_run_cmd = f"bash -c {shlex.quote(iflow_run_cmd)}" + logger.info(f"[{sandbox_id}] Executing IFlow CLI command with safety wrapping") + + result = await self._sandbox.arun( + cmd=safe_iflow_run_cmd, + session=self.config.agent_session, + mode="nohup", + wait_timeout=agent_run_timeout, + wait_interval=agent_run_check_interval, + ) + + # Step 4: Log execution outcome with detailed information + logger.info(f"[{sandbox_id}] IFlow CLI execution completed") + + # Read the last 1000 lines of log file since output was redirected + log_file_path = self.config.iflow_log_file + result_log = await self._sandbox.arun( + cmd=f"tail -1000 {log_file_path} 2>/dev/null || echo ''", + session=self.config.agent_session, + ) + log_content = result_log.output + + if result.exit_code == 0: + logger.info(f"[{sandbox_id}] ✓ IFlow-Cli completed successfully (exit_code: {result.exit_code})") + logger.debug(f"[{sandbox_id}] Command output: {log_content}") + else: + logger.error(f"[{sandbox_id}] ✗ IFlow-Cli failed with exit_code: {result.exit_code}") + logger.error(f"[{sandbox_id}] Error output: {log_content}") - async def run(self, **kwargs): - # Execution logic for IFlow CLI agent - pass + logger.info(f"[{sandbox_id}] IFlow CLI run operation finished") + return result diff --git a/rock/sdk/sandbox/agent/swe_agent.py b/rock/sdk/sandbox/agent/swe_agent.py index 89538119..176e7e86 100644 --- a/rock/sdk/sandbox/agent/swe_agent.py +++ b/rock/sdk/sandbox/agent/swe_agent.py @@ -217,12 +217,10 @@ class SweAgentConfig(AgentConfig): swe_agent_install_timeout: int = 600 - agent_run_timeout: int = 1800 - - agent_run_check_interval: int = 30 - default_run_single_config: dict[str, Any] = DEFAULT_RUN_SINGLE_CONFIG + session_envs: dict[str, str] = {} + class SweAgent(Agent): """ @@ -285,6 +283,7 @@ async def init(self): CreateBashSessionRequest( session=self.agent_session, env_enable=True, + env=self.config.session_envs, ) ) @@ -422,7 +421,14 @@ def _config_template_context(self, problem_statement: str, project_path: str, in except OSError as e: logger.warning(f"⚠ Could not clean up temporary config file {temp_file_path}: {e}") - async def run(self, problem_statement: str, project_path: str, instance_id: str): + async def run( + self, + problem_statement: str, + project_path: str, + instance_id: str, + agent_run_timeout: int = 1800, + agent_run_check_interval: int = 30, + ): """ Execute SWE-agent with the specified problem statement and project path. @@ -434,6 +440,8 @@ async def run(self, problem_statement: str, project_path: str, instance_id: str) problem_statement: The problem statement for the task project_path: Path to the target project instance_id: The instance identifier for the run + agent_run_timeout: Maximum seconds to wait for agent execution completion (default 1800) + agent_run_check_interval: Seconds between status checks during execution (default 30) Returns: CommandResult: Execution result containing exit code, stdout, and stderr @@ -470,15 +478,15 @@ async def run(self, problem_statement: str, project_path: str, instance_id: str) # Construct and execute SWE-agent run command swe_agent_run_cmd = f"cd {self.config.swe_agent_workdir} && {self.config.swe_agent_workdir}/python/bin/sweagent run --config {config_filename}" logger.info( - f"▶ Executing SWE-agent (timeout: {self.config.agent_run_timeout}s, check interval: {self.config.agent_run_check_interval}s)" + f"▶ Executing SWE-agent (timeout: {agent_run_timeout}s, check interval: {agent_run_check_interval}s)" ) result = await self._sandbox.arun( cmd=f"bash -c {shlex.quote(swe_agent_run_cmd)}", session=self.agent_session, mode="nohup", - wait_timeout=self.config.agent_run_timeout, - wait_interval=self.config.agent_run_check_interval, + wait_timeout=agent_run_timeout, + wait_interval=agent_run_check_interval, ) # Log execution outcome