From bb728ba8b2d23f1b48246f5eec13b1610997bdcb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:59:41 +0000 Subject: [PATCH 1/4] Initial plan From cc6c0146b9ffa6a61528060b0c51e1668947c281 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:11:11 +0000 Subject: [PATCH 2/4] Implement multi-language execution environment support - Add 5 language-specific Dockerfiles (python, miniforge, node, java, go) - Update docker-compose.yml with executor-build profile services - Add environments config to config.yaml with image mappings - Extend ExecutionEnvironmentManager with environment selection - Add environment_name attribute to ContainerInfo - Extend PlanningCoordinator with environment selection prompt - Update system_prompt_command_executor.txt with library guide - Add comprehensive unit tests for new functionality Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> --- config.yaml | 13 + docker-compose.yml | 54 ++ docker/executor-go/Dockerfile | 26 + docker/executor-java/Dockerfile | 26 + docker/executor-miniforge/Dockerfile | 29 + docker/executor-node/Dockerfile | 26 + docker/executor-python/Dockerfile | 26 + handlers/execution_environment_manager.py | 162 ++++-- handlers/planning_coordinator.py | 517 +++++++++++------- system_prompt_command_executor.txt | 51 +- .../test_execution_environment_manager.py | 153 ++++++ tests/unit/test_planning_coordinator.py | 243 ++++++++ 12 files changed, 1103 insertions(+), 223 deletions(-) create mode 100644 docker/executor-go/Dockerfile create mode 100644 docker/executor-java/Dockerfile create mode 100644 docker/executor-miniforge/Dockerfile create mode 100644 docker/executor-node/Dockerfile create mode 100644 docker/executor-python/Dockerfile diff --git a/config.yaml b/config.yaml index b8eec5d..b0b2838 100644 --- a/config.yaml +++ b/config.yaml @@ -364,6 +364,18 @@ command_executor: # 環境変数 COMMAND_EXECUTOR_ENABLED で上書き可能 enabled: true + # 利用可能な実行環境(環境名: イメージ名) + # 計画フェーズでLLMがプロジェクトに適した環境を選択します + environments: + python: "coding-agent-executor-python:latest" + miniforge: "coding-agent-executor-miniforge:latest" + node: "coding-agent-executor-node:latest" + java: "coding-agent-executor-java:latest" + go: "coding-agent-executor-go:latest" + + # デフォルト環境(環境選択に失敗した場合) + default_environment: "python" + # MCP Server設定 mcp_server: # サーバー名 @@ -376,6 +388,7 @@ command_executor: # Docker実行環境設定 docker: # ベースイメージ(環境変数 EXECUTOR_BASE_IMAGE で上書き可能) + # ※environments設定が優先されます。環境選択に失敗した場合のフォールバック用 base_image: "ubuntu:25.04" # リソース制限 diff --git a/docker-compose.yml b/docker-compose.yml index 8818a11..be6e86f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -232,6 +232,60 @@ services: retries: 3 start_period: 30s + # === 実行環境イメージ(ビルド専用、executor-buildプロファイル) === + # 計画フェーズでLLMが選択する実行環境イメージ + # ビルドコマンド: docker compose --profile executor-build build + + # Python実行環境イメージ + executor-python: + build: + context: ./docker/executor-python + dockerfile: Dockerfile + image: coding-agent-executor-python:latest + profiles: + - executor-build + command: ["echo", "Build only"] + + # Miniforge(conda)実行環境イメージ + executor-miniforge: + build: + context: ./docker/executor-miniforge + dockerfile: Dockerfile + image: coding-agent-executor-miniforge:latest + profiles: + - executor-build + command: ["echo", "Build only"] + + # Node.js実行環境イメージ + executor-node: + build: + context: ./docker/executor-node + dockerfile: Dockerfile + image: coding-agent-executor-node:latest + profiles: + - executor-build + command: ["echo", "Build only"] + + # Java実行環境イメージ + executor-java: + build: + context: ./docker/executor-java + dockerfile: Dockerfile + image: coding-agent-executor-java:latest + profiles: + - executor-build + command: ["echo", "Build only"] + + # Go実行環境イメージ + executor-go: + build: + context: ./docker/executor-go + dockerfile: Dockerfile + image: coding-agent-executor-go:latest + profiles: + - executor-build + command: ["echo", "Build only"] + volumes: rabbitmq_data: user-config-data: diff --git a/docker/executor-go/Dockerfile b/docker/executor-go/Dockerfile new file mode 100644 index 0000000..0036605 --- /dev/null +++ b/docker/executor-go/Dockerfile @@ -0,0 +1,26 @@ +# Go実行環境イメージ +# Goプロジェクト用のDockerイメージ +FROM golang:1.22-bookworm + +# 共通パッケージのインストール +# git: バージョン管理 +# curl/wget: ファイルダウンロード +# jq: JSON処理 +# tree: ディレクトリ構造表示 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + wget \ + jq \ + tree \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 作業ディレクトリの設定 +WORKDIR /workspace + +# プロジェクト配置用ディレクトリを作成 +RUN mkdir -p /workspace/project + +# コンテナを永続実行するためのENTRYPOINT +ENTRYPOINT ["sleep", "infinity"] diff --git a/docker/executor-java/Dockerfile b/docker/executor-java/Dockerfile new file mode 100644 index 0000000..145a180 --- /dev/null +++ b/docker/executor-java/Dockerfile @@ -0,0 +1,26 @@ +# Java実行環境イメージ +# Java/Kotlinプロジェクト用のDockerイメージ +FROM eclipse-temurin:21-jdk-jammy + +# 共通パッケージのインストール +# git: バージョン管理 +# curl/wget: ファイルダウンロード +# jq: JSON処理 +# tree: ディレクトリ構造表示 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + wget \ + jq \ + tree \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 作業ディレクトリの設定 +WORKDIR /workspace + +# プロジェクト配置用ディレクトリを作成 +RUN mkdir -p /workspace/project + +# コンテナを永続実行するためのENTRYPOINT +ENTRYPOINT ["sleep", "infinity"] diff --git a/docker/executor-miniforge/Dockerfile b/docker/executor-miniforge/Dockerfile new file mode 100644 index 0000000..70569df --- /dev/null +++ b/docker/executor-miniforge/Dockerfile @@ -0,0 +1,29 @@ +# Miniforge(conda)実行環境イメージ +# 科学計算・データサイエンスプロジェクト用のDockerイメージ +FROM condaforge/miniforge3:latest + +# 共通パッケージのインストール +# git: バージョン管理 +# curl/wget: ファイルダウンロード +# jq: JSON処理 +# tree: ディレクトリ構造表示 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + wget \ + jq \ + tree \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 作業ディレクトリの設定 +WORKDIR /workspace + +# プロジェクト配置用ディレクトリを作成 +RUN mkdir -p /workspace/project + +# condaの初期設定 +RUN conda init bash + +# コンテナを永続実行するためのENTRYPOINT +ENTRYPOINT ["sleep", "infinity"] diff --git a/docker/executor-node/Dockerfile b/docker/executor-node/Dockerfile new file mode 100644 index 0000000..8bbf1ee --- /dev/null +++ b/docker/executor-node/Dockerfile @@ -0,0 +1,26 @@ +# Node.js実行環境イメージ +# JavaScript/TypeScriptプロジェクト用のDockerイメージ +FROM node:20-slim + +# 共通パッケージのインストール +# git: バージョン管理 +# curl/wget: ファイルダウンロード +# jq: JSON処理 +# tree: ディレクトリ構造表示 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + wget \ + jq \ + tree \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 作業ディレクトリの設定 +WORKDIR /workspace + +# プロジェクト配置用ディレクトリを作成 +RUN mkdir -p /workspace/project + +# コンテナを永続実行するためのENTRYPOINT +ENTRYPOINT ["sleep", "infinity"] diff --git a/docker/executor-python/Dockerfile b/docker/executor-python/Dockerfile new file mode 100644 index 0000000..78dcd1f --- /dev/null +++ b/docker/executor-python/Dockerfile @@ -0,0 +1,26 @@ +# Python実行環境イメージ +# コーディングエージェントの実行環境として使用するPython用Dockerイメージ +FROM python:3.11-slim-bookworm + +# 共通パッケージのインストール +# git: バージョン管理 +# curl/wget: ファイルダウンロード +# jq: JSON処理 +# tree: ディレクトリ構造表示 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + wget \ + jq \ + tree \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 作業ディレクトリの設定 +WORKDIR /workspace + +# プロジェクト配置用ディレクトリを作成 +RUN mkdir -p /workspace/project + +# コンテナを永続実行するためのENTRYPOINT +ENTRYPOINT ["sleep", "infinity"] diff --git a/handlers/execution_environment_manager.py b/handlers/execution_environment_manager.py index f4aef60..1b7d6b5 100644 --- a/handlers/execution_environment_manager.py +++ b/handlers/execution_environment_manager.py @@ -2,6 +2,7 @@ Command Executor MCP Server連携のためのDocker実行環境を管理するクラスを提供します。 タスク毎のコンテナ作成・削除、プロジェクトクローン、コマンド実行を担当します。 +また、計画フェーズで選択された言語環境に応じた適切なイメージを使用してコンテナを起動します。 """ from __future__ import annotations @@ -18,6 +19,19 @@ from handlers.task import Task +# デフォルトの利用可能環境定義 +DEFAULT_ENVIRONMENTS: dict[str, str] = { + "python": "coding-agent-executor-python:latest", + "miniforge": "coding-agent-executor-miniforge:latest", + "node": "coding-agent-executor-node:latest", + "java": "coding-agent-executor-java:latest", + "go": "coding-agent-executor-go:latest", +} + +# デフォルト環境名 +DEFAULT_ENVIRONMENT_NAME = "python" + + @dataclass class ContainerInfo: """コンテナ情報を保持するデータクラス. @@ -25,6 +39,7 @@ class ContainerInfo: Attributes: container_id: DockerコンテナID task_uuid: 関連するタスクのUUID + environment_name: 使用された環境名(python, node等) workspace_path: コンテナ内の作業ディレクトリパス created_at: コンテナ作成日時 status: コンテナの状態 @@ -33,6 +48,7 @@ class ContainerInfo: container_id: str task_uuid: str + environment_name: str = DEFAULT_ENVIRONMENT_NAME workspace_path: str = "/workspace/project" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) status: str = "created" @@ -61,6 +77,7 @@ class ExecutionEnvironmentManager: Docker APIを使用してコンテナの作成・削除、プロジェクトのクローン、 コマンドの実行を行います。 + 計画フェーズで選択された言語環境に応じた適切なイメージを使用します。 """ # コンテナ名のプレフィックス @@ -79,6 +96,16 @@ def __init__(self, config: dict[str, Any]) -> None: # Command Executor設定を取得 self._executor_config = config.get("command_executor", {}) + # 利用可能な環境の設定(環境名からイメージ名へのマッピング) + self._environments: dict[str, str] = self._executor_config.get( + "environments", DEFAULT_ENVIRONMENTS.copy(), + ) + + # デフォルト環境名 + self._default_environment = self._executor_config.get( + "default_environment", DEFAULT_ENVIRONMENT_NAME, + ) + # Docker設定 self._docker_config = self._executor_config.get("docker", {}) self._base_image = self._docker_config.get( @@ -118,10 +145,28 @@ def __init__(self, config: dict[str, Any]) -> None: # アクティブコンテナの追跡 self._active_containers: dict[str, ContainerInfo] = {} - + # 現在のタスク参照(コマンド実行時に使用) self._current_task: Task | None = None + def get_available_environments(self) -> dict[str, str]: + """利用可能な環境のリストを取得する. + + Returns: + 環境名からイメージ名へのマッピング辞書 + + """ + return self._environments.copy() + + def get_default_environment(self) -> str: + """デフォルト環境名を取得する. + + Returns: + デフォルト環境名 + + """ + return self._default_environment + def set_current_task(self, task: Task) -> None: """現在のタスクを設定する. @@ -230,7 +275,7 @@ def _get_clone_url(self, task: Task) -> tuple[str, str | None]: # APIURLからベースURLを抽出(/api/v4を除去) base_url = gitlab_url.replace("/api/v4", "").replace("/api/v3", "") - + # プロトコル(http/https)を保持 if base_url.startswith("https://"): protocol = "https://" @@ -273,11 +318,15 @@ def _get_clone_url(self, task: Task) -> tuple[str, str | None]: ) raise ValueError(error_msg) - def prepare(self, task: Task) -> ContainerInfo: + def prepare(self, task: Task, environment_name: str | None = None) -> ContainerInfo: """タスク用のコンテナを作成し、プロジェクトをクローンする. + 計画フェーズで選択された環境名に基づいて、対応するDockerイメージでコンテナを作成します。 + 環境名が指定されない場合や無効な場合は、デフォルト環境を使用します。 + Args: task: タスクオブジェクト + environment_name: 使用する環境名(python, node等)。Noneの場合はデフォルト環境を使用 Returns: 作成されたコンテナの情報 @@ -289,7 +338,14 @@ def prepare(self, task: Task) -> ContainerInfo: task_uuid = task.uuid container_name = self._get_container_name(task_uuid) - self.logger.info("実行環境を準備します: %s", container_name) + # 環境名の検証とイメージ選択 + selected_env = self._validate_and_select_environment(environment_name) + + self.logger.info( + "実行環境を準備します: %s (環境: %s)", + container_name, + selected_env, + ) # 既存コンテナがあれば削除 try: @@ -297,23 +353,26 @@ def prepare(self, task: Task) -> ContainerInfo: except (RuntimeError, subprocess.SubprocessError) as e: self.logger.warning("既存コンテナの削除に失敗: %s", e) - # コンテナを作成 - container_id = self._create_container(task) + # コンテナを作成(選択された環境のイメージを使用) + container_id = self._create_container(task, selected_env) - # コンテナ情報を作成 + # コンテナ情報を作成(environment_name属性を含める) container_info = ContainerInfo( container_id=container_id, task_uuid=task_uuid, + environment_name=selected_env, status="created", ) - # gitをインストール - try: - self._install_git(container_id) - except RuntimeError: - # コンテナを削除してエラーを再送出 - self._remove_container(task_uuid) - raise + # 事前にgitインストール済みのイメージを使用するため、インストールはスキップ + # ただし、従来のフォールバックイメージ(base_image)の場合はインストールが必要 + if selected_env not in self._environments: + try: + self._install_git(container_id) + except RuntimeError: + # コンテナを削除してエラーを再送出 + self._remove_container(task_uuid) + raise # プロジェクトをクローン try: @@ -334,14 +393,46 @@ def prepare(self, task: Task) -> ContainerInfo: # アクティブコンテナに登録 self._active_containers[task_uuid] = container_info - self.logger.info("実行環境の準備が完了しました: %s", container_id) + self.logger.info( + "実行環境の準備が完了しました: %s (環境: %s)", + container_id, + selected_env, + ) return container_info - def _create_container(self, task: Task) -> str: + def _validate_and_select_environment(self, environment_name: str | None) -> str: + """環境名を検証し、使用する環境を選択する. + + Args: + environment_name: 指定された環境名、またはNone + + Returns: + 使用する環境名 + + """ + if environment_name is None: + self.logger.info( + "環境名が指定されていません。デフォルト環境を使用します: %s", + self._default_environment, + ) + return self._default_environment + + if environment_name not in self._environments: + self.logger.warning( + "無効な環境名が指定されました: %s。デフォルト環境を使用します: %s", + environment_name, + self._default_environment, + ) + return self._default_environment + + return environment_name + + def _create_container(self, task: Task, environment_name: str | None = None) -> str: """Dockerコンテナを作成する. Args: task: タスクオブジェクト + environment_name: 使用する環境名。指定された場合は対応するイメージを使用 Returns: コンテナID @@ -353,6 +444,15 @@ def _create_container(self, task: Task) -> str: task_uuid = task.uuid container_name = self._get_container_name(task_uuid) + # 環境名に基づいてイメージを選択 + if environment_name and environment_name in self._environments: + image = self._environments[environment_name] + self.logger.info("環境 '%s' のイメージを使用: %s", environment_name, image) + else: + # フォールバック: base_imageを使用 + image = self._base_image + self.logger.info("デフォルトイメージを使用: %s", image) + # コンテナ作成コマンドを構築 create_args = [ "create", @@ -363,7 +463,7 @@ def _create_container(self, task: Task) -> str: # 非特権モードで実行 "--security-opt", "no-new-privileges", # コンテナを継続実行(sleepコマンド) - self._base_image, + image, "sleep", "infinity", ] @@ -800,18 +900,18 @@ def execute_command(self, command: str, working_directory: str | None = None) -> """ if self._current_task is None: raise RuntimeError("Current task not set. Call set_current_task() first.") - + task_uuid = self._current_task.uuid container_info = self._active_containers.get(task_uuid) - + if container_info is None or container_info.status != "ready": raise RuntimeError(f"Execution environment not ready for task {task_uuid}") - + container_id = container_info.container_id work_dir = working_directory or container_info.workspace_path - + self.logger.info("コマンドを実行します: %s (作業ディレクトリ: %s)", command, work_dir) - + # コマンド実行 exec_args = [ "exec", @@ -819,9 +919,9 @@ def execute_command(self, command: str, working_directory: str | None = None) -> container_id, "sh", "-c", command, ] - + start_time = time.time() - + try: result = self._run_docker_command( exec_args, @@ -829,23 +929,23 @@ def execute_command(self, command: str, working_directory: str | None = None) -> check=False, ) duration_ms = int((time.time() - start_time) * 1000) - + # 出力サイズを制限 stdout = result.stdout stderr = result.stderr - + if len(stdout) > self._max_output_size: stdout = stdout[:self._max_output_size] + "\n...(truncated)" if len(stderr) > self._max_output_size: stderr = stderr[:self._max_output_size] + "\n...(truncated)" - + return { "exit_code": result.returncode, "stdout": stdout, "stderr": stderr, "duration_ms": duration_ms, } - + except subprocess.TimeoutExpired: duration_ms = int((time.time() - start_time) * 1000) return { @@ -872,7 +972,7 @@ def get_function_calling_functions(self) -> list[dict[str, Any]]: """ if not self.is_enabled(): return [] - + return [ { "name": "command-executor_execute_command", @@ -891,7 +991,7 @@ def get_function_calling_functions(self) -> list[dict[str, Any]]: }, "required": ["command"], }, - } + }, ] def get_function_calling_tools(self) -> list[dict[str, Any]]: @@ -903,7 +1003,7 @@ def get_function_calling_tools(self) -> list[dict[str, Any]]: """ if not self.is_enabled(): return [] - + functions = self.get_function_calling_functions() return [ { diff --git a/handlers/planning_coordinator.py b/handlers/planning_coordinator.py index e3e3665..f39bf98 100644 --- a/handlers/planning_coordinator.py +++ b/handlers/planning_coordinator.py @@ -166,6 +166,9 @@ def __init__( # 計画前情報収集フェーズの結果 self.pre_planning_result: dict[str, Any] | None = None + # 計画で選択された実行環境名 + self.selected_environment: str | None = None + # PrePlanningManagerの初期化(有効な場合) self.pre_planning_manager: Any = None pre_planning_config = config.get("pre_planning", {}) @@ -225,28 +228,28 @@ def execute_with_planning(self) -> bool: self.logger.info("一時停止シグナルを検出、タスクを一時停止します") self._handle_pause() return True # Return success to avoid marking as failed - + # Check for stop signal before starting if self._check_stop_signal(): self.logger.info("アサイン解除を検出、タスクを停止します") self._handle_stop() return True # Return success to avoid marking as failed - + # Check for new comments before starting self._check_and_add_new_comments() - + # Step 0.5: Check for inheritance context and post notification self._handle_context_inheritance() - + # Step 0: Execute pre-planning phase (計画前情報収集フェーズ) if self.pre_planning_manager is not None: self._post_phase_comment("pre_planning", "started", "タスク内容を分析し、必要な情報を収集しています...") self.pre_planning_result = self._execute_pre_planning_phase() self._post_phase_comment("pre_planning", "completed", "計画前情報収集が完了しました") - + # Post planning start comment self._post_phase_comment("planning", "started", "Beginning task analysis and planning...") - + # Step 1: Check for existing plan if self.history_store.has_plan(): self.logger.info("Found existing plan, loading...") @@ -275,13 +278,13 @@ def execute_with_planning(self) -> bool: self.logger.info("一時停止シグナルを検出、タスクを一時停止します") self._handle_pause() return True - + # Check for stop signal after planning if self._check_stop_signal(): self.logger.info("アサイン解除を検出、タスクを停止します") self._handle_stop() return True - + # Check for new comments after planning phase self._check_and_add_new_comments() @@ -291,25 +294,25 @@ def execute_with_planning(self) -> bool: # Step 3: Execution loop max_iterations = self.config.get("max_subtasks", 100) iteration = 0 - + while iteration < max_iterations and not self._is_complete(): iteration += 1 - + # Check for pause signal before each action if self._check_pause_signal(): self.logger.info("一時停止シグナルを検出、タスクを一時停止します") self._handle_pause() return True - + # Check for stop signal before each action if self._check_stop_signal(): self.logger.info("アサイン解除を検出、タスクを停止します") self._handle_stop() return True - + # Check for new comments before each action self._check_and_add_new_comments() - + # Execute next action result = self._execute_action() @@ -325,13 +328,13 @@ def execute_with_planning(self) -> bool: error_msg = result.get("error", "Unknown error occurred") self._post_phase_comment( - "execution", "failed", f"Action failed: {error_msg}" + "execution", "failed", f"Action failed: {error_msg}", ) # 再計画判断をLLMに依頼 if self.replan_manager.enabled: decision = self._request_execution_replan_decision( - current_action, result + current_action, result, ) if self._handle_replan(decision): # 再計画が実行された場合、ループを継続 @@ -346,7 +349,7 @@ def execute_with_planning(self) -> bool: # Update progress checklist self._update_checklist_progress(self.action_counter - 1) - + # Check if reflection is needed if self._should_reflect(result): # Check for pause signal before reflection @@ -354,36 +357,36 @@ def execute_with_planning(self) -> bool: self.logger.info("一時停止シグナルを検出、タスクを一時停止します") self._handle_pause() return True - + # Check for stop signal before reflection if self._check_stop_signal(): self.logger.info("アサイン解除を検出、タスクを停止します") self._handle_stop() return True - + # Check for new comments before reflection self._check_and_add_new_comments() - + self._post_phase_comment("reflection", "started", f"Analyzing results after {self.action_counter} actions...") self.current_phase = "reflection" reflection = self._execute_reflection_phase(result) - + if reflection and reflection.get("plan_revision_needed"): # Check for pause signal before revision if self._check_pause_signal(): self.logger.info("一時停止シグナルを検出、タスクを一時停止します") self._handle_pause() return True - + # Check for stop signal before revision if self._check_stop_signal(): self.logger.info("アサイン解除を検出、タスクを停止します") self._handle_stop() return True - + # Check for new comments before revision self._check_and_add_new_comments() - + # Revise plan if needed self._post_phase_comment("revision", "started", "Plan revision needed based on reflection.") self.current_phase = "revision" @@ -395,15 +398,15 @@ def execute_with_planning(self) -> bool: self._post_phase_comment("revision", "failed", "Could not revise plan.") else: self._post_phase_comment("reflection", "completed", "Reflection complete, continuing with current plan.") - + # Reset to execution phase self.current_phase = "execution" - + # Check for completion if result.get("done"): self.logger.info("Task completed successfully") break - + # Step 4: Verification phase verification_config = self.config.get("verification", {}) if verification_config.get("enabled", True): @@ -509,7 +512,7 @@ def execute_with_planning(self) -> bool: if self.replan_manager.enabled: decision = self._request_execution_replan_decision( - result.get("action", {}), result + result.get("action", {}), result, ) if self._handle_replan(decision): continue @@ -534,9 +537,9 @@ def execute_with_planning(self) -> bool: # Mark all tasks complete self._mark_checklist_complete() self._post_phase_comment("execution", "completed", "All planned actions have been executed successfully.") - + return True - + except Exception as e: self.logger.exception("Planning execution failed: %s", e) self._post_phase_comment("execution", "failed", f"Error during execution: {str(e)}") @@ -547,10 +550,10 @@ def _handle_pause(self) -> None: if self.pause_manager is None: self.logger.warning("Pause manager not set, cannot pause") return - + # Get current planning state planning_state = self.get_planning_state() - + # Pause the task with planning state self.pause_manager.pause_task(self.task, self.task.uuid, planning_state=planning_state) @@ -676,6 +679,8 @@ def _execute_pre_planning_phase(self) -> dict[str, Any] | None: def _execute_planning_phase(self) -> dict[str, Any] | None: """Execute the planning phase. + 計画作成と同時に実行環境を選択します。 + Returns: Planning result dictionary or None if planning failed """ @@ -688,32 +693,78 @@ def _execute_planning_phase(self) -> dict[str, Any] | None: past_history = [] if issue_id: past_history = self.history_store.get_past_executions_for_issue(str(issue_id)) - + # Prepare planning prompt planning_prompt = self._build_planning_prompt(past_history) - + # Request plan from LLM self.llm_client.send_user_message(planning_prompt) response, _, tokens = self.llm_client.get_response() # Unpack tuple with tokens self.logger.info("Planning LLM response (tokens: %d)", tokens) - + # トークン数を記録 self.context_manager.update_statistics(llm_calls=1, tokens=tokens) - + # Parse response plan = self._parse_planning_response(response) + # 計画応答から選択された環境を抽出 + if plan: + self.selected_environment = self._extract_selected_environment(plan) + self.logger.info("選択された実行環境: %s", self.selected_environment) + # LLM呼び出し完了コメントを投稿 self._post_llm_call_comment("planning", plan) - + return plan - + except Exception as e: self.logger.exception("Planning phase execution failed") # LLMエラーコメントを投稿 self._post_llm_error_comment("planning", str(e)) return None + def _extract_selected_environment(self, plan: dict[str, Any]) -> str | None: + """計画応答から選択された実行環境を抽出する. + + 仕様書に従い、計画応答のselected_environmentフィールドから + 環境名を抽出します。 + + Args: + plan: 計画応答の辞書 + + Returns: + 選択された環境名、または見つからない場合はNone + + """ + if not isinstance(plan, dict): + return None + + selected_env = plan.get("selected_environment") + + if selected_env is None: + self.logger.info("計画応答にselected_environmentが含まれていません") + return None + + # selected_environmentが辞書形式の場合 + if isinstance(selected_env, dict): + env_name = selected_env.get("name") + reasoning = selected_env.get("reasoning", "理由なし") + if env_name: + self.logger.info( + "環境 '%s' が選択されました。理由: %s", + env_name, + reasoning[:100] if len(reasoning) > 100 else reasoning, + ) + return env_name + return None + + # selected_environmentが文字列形式の場合 + if isinstance(selected_env, str): + return selected_env + + return None + def _execute_action(self) -> dict[str, Any] | None: """Execute the next action from the plan. @@ -723,38 +774,38 @@ def _execute_action(self) -> dict[str, Any] | None: try: if not self.current_plan: return None - + # Get next action from plan action_plan = self.current_plan.get("action_plan", {}) actions = action_plan.get("actions", []) - + if self.action_counter >= len(actions): # No more actions return {"done": True, "status": "completed"} - + current_action = actions[self.action_counter] task_id = current_action.get("task_id", f"task_{self.action_counter + 1}") self.action_counter += 1 - + # Execute the action via LLM action_prompt = self._build_action_prompt(current_action) self.llm_client.send_user_message(action_prompt) - + # Get LLM response with function calls resp, functions, tokens = self.llm_client.get_response() self.logger.info("Action execution LLM response: %s", resp) - + # トークン数を記録 self.context_manager.update_statistics(llm_calls=1, tokens=tokens) - + # Initialize error state for tool execution error_state = {"last_tool": None, "tool_error_count": 0} - + # Process function calls if any if functions: if not isinstance(functions, list): functions = [functions] - + # Execute all function calls for function in functions: if self._execute_function_call(function, error_state, task_id): @@ -764,25 +815,25 @@ def _execute_action(self) -> dict[str, Any] | None: "error": "Too many consecutive tool errors", "action": current_action, } - + # Try to parse JSON response try: data = json.loads(resp) if isinstance(resp, str) else resp - + # LLM呼び出し完了コメントを投稿 # Note: commentフィールドの投稿はここで統一的に処理される self._post_llm_call_comment("execution", data, task_id) - + # Check if done if isinstance(data, dict) and data.get("done"): return {"done": True, "status": "completed", "result": data} - + return {"status": "success", "result": data, "action": current_action} except (json.JSONDecodeError, ValueError): # テキスト応答の場合もLLM呼び出しコメントを投稿 self._post_llm_call_comment("execution", None, task_id) return {"status": "success", "result": resp, "action": current_action} - + except Exception as e: self.logger.exception("Action execution failed: %s", e) # LLMエラーコメントを投稿 @@ -807,18 +858,18 @@ def _execute_function_call( """ # Maximum consecutive tool errors before aborting MAX_CONSECUTIVE_TOOL_ERRORS = 3 - + try: # Get function name name = function["name"] if isinstance(function, dict) else function.name - + # Parse MCP server and tool name if "_" not in name: self.logger.error("Invalid function name format: %s", name) return False - + mcp_server, tool_name = name.split("_", 1) - + # Get arguments args = function["arguments"] if isinstance(function, dict) else function.arguments if isinstance(args, str): @@ -827,12 +878,12 @@ def _execute_function_call( except json.JSONDecodeError: self.logger.error("Failed to parse arguments JSON: %s", args) return False - + self.logger.info("Executing function: %s with args: %s", name, args) # ツール呼び出し前のコメントを投稿 self._post_tool_call_before_comment(name, args) - + # Check if this is a command-executor tool if mcp_server == "command-executor": # Handle command execution through ExecutionEnvironmentManager @@ -841,7 +892,7 @@ def _execute_function_call( self.logger.error(error_msg) self.llm_client.send_function_result(name, f"error: {error_msg}") return False - + try: # Execute command through execution manager if tool_name == "execute_command": @@ -852,63 +903,63 @@ def _execute_function_call( else: error_msg = f"Unknown command-executor tool: {tool_name}" raise ValueError(error_msg) - + # Reset error count on success if error_state["last_tool"] == tool_name: error_state["tool_error_count"] = 0 - + # Send result back to LLM self.llm_client.send_function_result(name, json.dumps(result, ensure_ascii=False)) # ツール完了コメントを投稿(成功) self._post_tool_call_after_comment(name, success=True) - + return False - + except Exception as e: error_msg = str(e) self.logger.exception("Command execution failed: %s", error_msg) - + # ツール完了コメントを投稿(失敗) self._post_tool_call_after_comment(name, success=False) # ツールエラーコメントを投稿 self._post_tool_error_comment(name, error_msg, task_id) - + # Update error count if error_state["last_tool"] == tool_name: error_state["tool_error_count"] += 1 else: error_state["tool_error_count"] = 1 error_state["last_tool"] = tool_name - + # Send error result to LLM self.llm_client.send_function_result(name, f"error: {error_msg}") - + # Check if we should abort if error_state["tool_error_count"] >= MAX_CONSECUTIVE_TOOL_ERRORS: self.task.comment( - f"同じツール({name})で{MAX_CONSECUTIVE_TOOL_ERRORS}回連続エラーが発生したため処理を中止します。" + f"同じツール({name})で{MAX_CONSECUTIVE_TOOL_ERRORS}回連続エラーが発生したため処理を中止します。", ) return True - + return False - + # Execute the tool through MCP client try: result = self.mcp_clients[mcp_server].call_tool(tool_name, args) - + # Reset error count on success if error_state["last_tool"] == tool_name: error_state["tool_error_count"] = 0 - + # Send result back to LLM self.llm_client.send_function_result(name, str(result)) # ツール完了コメントを投稿(成功) self._post_tool_call_after_comment(name, success=True) - + return False - + except Exception as e: # Handle tool execution error error_msg = str(e) @@ -918,33 +969,33 @@ def _execute_function_call( error_msg = str(e.exceptions[0].exceptions[0]) else: error_msg = str(e.exceptions[0]) - + self.logger.exception("Tool execution failed: %s", error_msg) - + # ツール完了コメントを投稿(失敗) self._post_tool_call_after_comment(name, success=False) # ツールエラーコメントを投稿 self._post_tool_error_comment(name, error_msg, task_id) - + # Update error count if error_state["last_tool"] == tool_name: error_state["tool_error_count"] += 1 else: error_state["tool_error_count"] = 1 error_state["last_tool"] = tool_name - + # Send error result to LLM self.llm_client.send_function_result(name, f"error: {error_msg}") - + # Check if we should abort if error_state["tool_error_count"] >= MAX_CONSECUTIVE_TOOL_ERRORS: self.task.comment( - f"同じツール({name})で{MAX_CONSECUTIVE_TOOL_ERRORS}回連続エラーが発生したため処理を中止します。" + f"同じツール({name})で{MAX_CONSECUTIVE_TOOL_ERRORS}回連続エラーが発生したため処理を中止します。", ) return True - + return False - + except Exception as e: self.logger.exception("Function call execution failed: %s", e) return False @@ -961,17 +1012,17 @@ def _should_reflect(self, result: dict[str, Any]) -> bool: # Reflect on error if result.get("status") == "error": return True - + # Reflect at configured intervals reflection_config = self.config.get("reflection", {}) if not reflection_config.get("enabled", True): return False - + interval = reflection_config.get("trigger_interval", 3) # Only reflect at intervals after at least one action has been executed if interval > 0 and self.action_counter > 0 and self.action_counter % interval == 0: return True - + return False def _execute_reflection_phase(self, result: dict[str, Any]) -> dict[str, Any] | None: @@ -986,27 +1037,27 @@ def _execute_reflection_phase(self, result: dict[str, Any]) -> dict[str, Any] | try: # Build reflection prompt reflection_prompt = self._build_reflection_prompt(result) - + # Get reflection from LLM self.llm_client.send_user_message(reflection_prompt) response, _, tokens = self.llm_client.get_response() # Unpack tuple with tokens self.logger.info("Reflection LLM response (tokens: %d)", tokens) - + # トークン数を記録 self.context_manager.update_statistics(llm_calls=1, tokens=tokens) - + # Parse reflection reflection = self._parse_reflection_response(response) # LLM呼び出し完了コメントを投稿 self._post_llm_call_comment("reflection", reflection) - + # Save reflection if reflection: self.history_store.save_reflection(reflection) - + return reflection - + except Exception as e: self.logger.exception(f"Reflection phase failed: {e}") # LLMエラーコメントを投稿 @@ -1025,25 +1076,25 @@ def _revise_plan(self, reflection: dict[str, Any]) -> dict[str, Any] | None: try: # Check revision limit max_revisions = self.config.get("revision", {}).get("max_revisions", 3) - + if self.revision_counter >= max_revisions: self.logger.error("Maximum plan revisions exceeded") return None - + # Increment counter after check self.revision_counter += 1 - + # Build revision prompt revision_prompt = self._build_revision_prompt(reflection) - + # Get revised plan from LLM self.llm_client.send_user_message(revision_prompt) response, _, tokens = self.llm_client.get_response() # Unpack tuple with tokens self.logger.info("Plan revision LLM response (tokens: %d)", tokens) - + # トークン数を記録 self.context_manager.update_statistics(llm_calls=1, tokens=tokens) - + # Parse revised plan revised_plan = self._parse_planning_response(response) @@ -1242,12 +1293,12 @@ def _execute_partial_replan(self, decision: ReplanDecision) -> None: # 完了済みアクションを保持しつつ、残りのアクションを再生成 if self.current_plan: completed_actions = self.current_plan.get("action_plan", {}).get( - "actions", [] + "actions", [], )[: self.action_counter] # LLMに残りのアクションの再生成を依頼 remaining_prompt = self._build_partial_replan_prompt( - completed_actions, decision + completed_actions, decision, ) self.llm_client.send_user_message(remaining_prompt) response, _, tokens = self.llm_client.get_response() @@ -1280,7 +1331,7 @@ def _execute_full_replan(self, decision: ReplanDecision) -> None: completed_actions = [] if self.current_plan: completed_actions = self.current_plan.get("action_plan", {}).get( - "actions", [] + "actions", [], )[:completed_count] # 新しい計画を生成 @@ -1422,7 +1473,7 @@ def _update_checklist_on_replan( checklist_lines.append( f"*Progress: {self.action_counter}/{len(actions)} ({progress_pct}%) complete " f"| Revision: #{self.plan_revision_number} " - f"at {datetime.now().strftime(DATETIME_FORMAT)}*" + f"at {datetime.now().strftime(DATETIME_FORMAT)}*", ) checklist_content = "\n".join(checklist_lines) @@ -1448,11 +1499,11 @@ def _is_complete(self) -> bool: """ if not self.current_plan: return False - + # Check if all actions are executed action_plan = self.current_plan.get("action_plan", {}) actions = action_plan.get("actions", []) - + return self.action_counter >= len(actions) def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: @@ -1466,18 +1517,18 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: """ # Get task information including comments/discussions task_info = self.task.get_prompt() - + prompt_parts = [ "Create a comprehensive plan for the following task:", "", task_info, # This includes issue/MR details and all comments "", ] - + # 計画前情報収集フェーズの結果を追加 if self.pre_planning_result: pre_planning = self.pre_planning_result.get("pre_planning_result", {}) - + # 理解した依頼内容のサマリー request_understanding = pre_planning.get("request_understanding", {}) if request_understanding: @@ -1488,7 +1539,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: f"理解の確信度: {request_understanding.get('understanding_confidence', 0):.0%}", "", ]) - + # 成果物 deliverables = request_understanding.get("expected_deliverables", []) if deliverables: @@ -1496,7 +1547,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: for d in deliverables: prompt_parts.append(f" - {d}") prompt_parts.append("") - + # 制約 constraints = request_understanding.get("constraints", []) if constraints: @@ -1504,7 +1555,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: for c in constraints: prompt_parts.append(f" - {c}") prompt_parts.append("") - + # スコープ scope = request_understanding.get("scope", {}) if scope: @@ -1515,7 +1566,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: if out_of_scope: prompt_parts.append(f"スコープ外: {', '.join(out_of_scope)}") prompt_parts.append("") - + # 曖昧な点と選択した解釈 ambiguities = request_understanding.get("ambiguities", []) if ambiguities: @@ -1531,7 +1582,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: # 文字列の場合はそのまま使用 prompt_parts.append(f" - {amb}") prompt_parts.append("") - + # 収集した情報 collected_info = pre_planning.get("collected_information", {}) if collected_info: @@ -1546,7 +1597,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: else: prompt_parts.append(json_str) prompt_parts.append("") - + # 推測した内容 assumptions = pre_planning.get("assumptions", []) if assumptions: @@ -1557,7 +1608,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: confidence = assumption.get("confidence", 0) prompt_parts.append(f" - {info_id}: {value} (確信度: {confidence:.0%})") prompt_parts.append("") - + # 情報ギャップ gaps = pre_planning.get("information_gaps", []) if gaps: @@ -1567,7 +1618,7 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: impact = gap.get("impact", "") prompt_parts.append(f" - {desc} (影響: {impact})") prompt_parts.append("") - + # 計画への推奨事項 recommendations = pre_planning.get("recommendations_for_planning", []) if recommendations: @@ -1575,7 +1626,11 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: for rec in recommendations: prompt_parts.append(f" - {rec}") prompt_parts.append("") - + + # 実行環境選択情報を追加 + environment_selection_prompt = self._build_environment_selection_prompt() + prompt_parts.append(environment_selection_prompt) + prompt_parts.extend([ "IMPORTANT - Task Complexity Assessment:", "Before creating your plan, evaluate the task complexity:", @@ -1586,26 +1641,99 @@ def _build_planning_prompt(self, past_history: list[dict[str, Any]]) -> str: "Default to SIMPLER plans. Most tasks are simpler than they appear.", "Combine related operations. Don't over-decompose simple tasks.", ]) - + if past_history: prompt_parts.extend([ "", "Past execution history for this issue:", json.dumps(past_history, indent=2), ]) - + prompt_parts.extend([ "", "Please provide a plan in the following JSON format:", "{", ' "goal_understanding": {...},', ' "task_decomposition": {...},', - ' "action_plan": {...}', + ' "action_plan": {...},', + ' "selected_environment": {', + ' "name": "python",', + ' "reasoning": "選択理由..."', + ' }', "}", ]) - + return "\n".join(prompt_parts) + def _build_environment_selection_prompt(self) -> str: + """実行環境選択プロンプトを構築する. + + 利用可能な環境リストと選択指示を含むプロンプトを生成します。 + + Returns: + 環境選択プロンプト文字列 + + """ + # ExecutionEnvironmentManagerから環境リストを取得(利用可能な場合) + environments = {} + default_env = "python" + + if self.execution_manager is not None: + environments = self.execution_manager.get_available_environments() + default_env = self.execution_manager.get_default_environment() + else: + # デフォルトの環境リスト + environments = { + "python": "coding-agent-executor-python:latest", + "miniforge": "coding-agent-executor-miniforge:latest", + "node": "coding-agent-executor-node:latest", + "java": "coding-agent-executor-java:latest", + "go": "coding-agent-executor-go:latest", + } + + # 環境ごとの推奨用途 + env_recommendations = { + "python": "Pure Python projects, Django/Flask web frameworks, data processing scripts", + "miniforge": "Data science, scientific computing, NumPy/pandas/scikit-learn, conda environments (condaenv.yaml, environment.yml)", + "node": "JavaScript/TypeScript, React/Vue/Angular, Node.js backend (Express, NestJS)", + "java": "Java/Kotlin, Spring Boot, Quarkus, Maven/Gradle projects", + "go": "Go projects, CLI tools, microservices", + } + + prompt_lines = [ + "", + "## Execution Environment Selection", + "", + "You must select an appropriate execution environment for this task. The following environments are available:", + "", + "| Environment | Image | Recommended For |", + "|-------------|-------|-----------------|", + ] + + for env_name, image in environments.items(): + recommendation = env_recommendations.get(env_name, "General purpose") + prompt_lines.append(f"| {env_name} | {image} | {recommendation} |") + + prompt_lines.extend([ + "", + f"**Default Environment**: {default_env}", + "", + "**Selection Criteria:**", + "- Check the project's dependency files (requirements.txt, package.json, go.mod, pom.xml, condaenv.yaml, environment.yml)", + "- Consider the main programming language of the task", + "- For data science projects with conda environments, select 'miniforge'", + "- For pure Python projects without conda, select 'python'", + "", + "Include your selection in the response with 'selected_environment' field:", + ' "selected_environment": {', + ' "name": "environment_name",', + ' "reasoning": "Why this environment was selected"', + ' }', + "", + ]) + + return "\n".join(prompt_lines) + def _build_action_prompt(self, action: dict[str, Any]) -> str: """Build prompt for action execution. @@ -1619,7 +1747,7 @@ def _build_action_prompt(self, action: dict[str, Any]) -> str: tool_name = action.get("tool", "unknown") parameters = action.get("parameters", {}) purpose = action.get("purpose", "") - + prompt_parts = [ f"Execute the following action using the `{tool_name}` tool:", "", @@ -1630,7 +1758,7 @@ def _build_action_prompt(self, action: dict[str, Any]) -> str: "", "Please use function calling to execute this tool with the exact parameters provided above.", ] - + return "\n".join(prompt_parts) def _build_reflection_prompt(self, result: dict[str, Any]) -> str: @@ -1668,14 +1796,14 @@ def _parse_planning_response(self, response: str) -> dict[str, Any] | None: # Try to extract JSON from response if isinstance(response, dict): return response - + # Remove tags if present response = re.sub(r".*?", "", response, flags=re.DOTALL) response = response.strip() - + # Log the response for debugging self.logger.debug("Planning response: %s", response[:500]) - + # Try to parse as JSON try: return json.loads(response) @@ -1684,14 +1812,14 @@ def _parse_planning_response(self, response: str) -> dict[str, Any] | None: json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL) if json_match: return json.loads(json_match.group(1)) - + # Try to find JSON object in text json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: return json.loads(json_match.group(0)) - + raise - + except (json.JSONDecodeError, AttributeError): self.logger.warning("Failed to parse planning response as JSON. Response: %s", response[:200]) return None @@ -1708,11 +1836,11 @@ def _parse_reflection_response(self, response: str) -> dict[str, Any] | None: try: if isinstance(response, dict): return response - + # Remove tags if present response = re.sub(r".*?", "", response, flags=re.DOTALL) response = response.strip() - + # Try to parse as JSON try: return json.loads(response) @@ -1721,13 +1849,13 @@ def _parse_reflection_response(self, response: str) -> dict[str, Any] | None: json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL) if json_match: return json.loads(json_match.group(1)) - + json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: return json.loads(json_match.group(0)) - + raise - + except (json.JSONDecodeError, AttributeError): self.logger.warning("Failed to parse reflection response as JSON. Response: %s", response[:200]) return None @@ -1742,24 +1870,24 @@ def _post_plan_as_checklist(self, plan: dict[str, Any]) -> None: # Extract actions from the plan action_plan = plan.get("action_plan", {}) actions = action_plan.get("actions", []) - + if not actions: self.logger.warning("No actions found in plan, skipping checklist posting") return - + # Build markdown checklist checklist_lines = ["## 📋 Execution Plan", ""] - + for i, action in enumerate(actions, 1): task_id = action.get("task_id", f"task_{i}") purpose = action.get("purpose", "Execute action") checklist_lines.append(f"- [ ] **{task_id}**: {purpose}") - + checklist_lines.append("") checklist_lines.append("*Progress will be updated as tasks complete.*") - + checklist_content = "\n".join(checklist_lines) - + # Post to Issue/MR using task's comment method and save comment ID if hasattr(self.task, "comment"): result = self.task.comment(checklist_content) @@ -1770,7 +1898,7 @@ def _post_plan_as_checklist(self, plan: dict[str, Any]) -> None: self.logger.info("Posted execution plan checklist to Issue/MR (comment_id=%s)", self.checklist_comment_id) else: self.logger.warning("Task does not support comment, cannot post checklist") - + except Exception as e: self.logger.error("Failed to post plan as checklist: %s", str(e)) @@ -1783,30 +1911,30 @@ def _update_checklist_progress(self, completed_action_index: int) -> None: try: if not self.current_plan: return - + action_plan = self.current_plan.get("action_plan", {}) actions = action_plan.get("actions", []) - + if completed_action_index >= len(actions): return - + # Build updated checklist checklist_lines = ["## 📋 Execution Plan", ""] - + for i, action in enumerate(actions, 1): task_id = action.get("task_id", f"task_{i}") purpose = action.get("purpose", "Execute action") - + # Mark completed actions with [x] checkbox = "[x]" if i <= completed_action_index + 1 else "[ ]" checklist_lines.append(f"- {checkbox} **{task_id}**: {purpose}") - + checklist_lines.append("") progress_pct = int((completed_action_index + 1) / len(actions) * 100) checklist_lines.append(f"*Progress: {completed_action_index + 1}/{len(actions)} ({progress_pct}%) complete*") - + checklist_content = "\n".join(checklist_lines) - + # Update the existing comment instead of posting a new one if self.checklist_comment_id and hasattr(self.task, "update_comment"): self.task.update_comment(self.checklist_comment_id, checklist_content) @@ -1817,7 +1945,7 @@ def _update_checklist_progress(self, completed_action_index: int) -> None: if isinstance(result, dict): self.checklist_comment_id = result.get("id") self.logger.info("Posted new checklist progress comment") - + except Exception as e: self.logger.error("Failed to update checklist progress: %s", str(e)) @@ -1826,23 +1954,23 @@ def _mark_checklist_complete(self) -> None: try: if not self.current_plan: return - + action_plan = self.current_plan.get("action_plan", {}) actions = action_plan.get("actions", []) - + # Build completed checklist checklist_lines = ["## 📋 Execution Plan", ""] - + for i, action in enumerate(actions, 1): task_id = action.get("task_id", f"task_{i}") purpose = action.get("purpose", "Execute action") checklist_lines.append(f"- [x] **{task_id}**: {purpose}") - + checklist_lines.append("") checklist_lines.append(f"*✅ All {len(actions)} tasks completed successfully!*") - + checklist_content = "\n".join(checklist_lines) - + # Update the existing comment instead of posting a new one if self.checklist_comment_id and hasattr(self.task, "update_comment"): self.task.update_comment(self.checklist_comment_id, checklist_content) @@ -1853,7 +1981,7 @@ def _mark_checklist_complete(self) -> None: if isinstance(result, dict): self.checklist_comment_id = result.get("id") self.logger.info("Posted new completion checklist comment") - + except Exception as e: self.logger.error("Failed to mark checklist complete: %s", str(e)) @@ -2157,7 +2285,7 @@ def _update_checklist_for_additional_work( progress_pct = int(completed / total_actions * 100) if total_actions else 100 checklist_lines.append( f"*Progress: {completed}/{total_actions} ({progress_pct}%) - " - f"Verification found {len(additional_actions)} additional items*" + f"Verification found {len(additional_actions)} additional items*", ) checklist_content = "\n".join(checklist_lines) @@ -2183,41 +2311,41 @@ def _load_planning_system_prompt(self) -> None: try: # Read system_prompt_planning.txt prompt_path = Path("system_prompt_planning.txt") - + if not prompt_path.exists(): self.logger.warning("system_prompt_planning.txt not found, using default behavior") return - + with prompt_path.open("r", encoding="utf-8") as f: planning_prompt = f.read() - + # Get MCP client system prompts (function calling definitions) mcp_prompt = "" for client in self.mcp_clients.values(): mcp_prompt += client.system_prompt + "\n" - + # Replace placeholder with MCP prompts planning_prompt = planning_prompt.replace("{mcp_prompt}", mcp_prompt) - + # プロジェクト固有のエージェントルールを読み込み project_rules = self._load_project_agent_rules() if project_rules: planning_prompt = planning_prompt + "\n" + project_rules self.logger.info("Added project-specific agent rules to planning prompt") - + # プロジェクトファイル一覧を読み込み file_list_context = self._load_file_list_context() if file_list_context: planning_prompt = planning_prompt + "\n" + file_list_context self.logger.info("Added project file list to planning prompt") - + # Send system prompt to LLM client if hasattr(self.llm_client, "send_system_prompt"): self.llm_client.send_system_prompt(planning_prompt) self.logger.info("Loaded planning system prompt with MCP function definitions") else: self.logger.warning("LLM client does not support send_system_prompt") - + except Exception as e: self.logger.error("Failed to load planning system prompt: %s", str(e)) @@ -2239,26 +2367,26 @@ def _post_phase_comment(self, phase: str, status: str, details: str = "") -> Non "revision": "📝", "verification": "🔍", } - + status_emoji_map = { "started": "▶️", "completed": "✅", "failed": "❌", "in_progress": "🔄", } - + phase_emoji = emoji_map.get(phase, "📌") status_emoji = status_emoji_map.get(status, "ℹ️") - + # Build comment title phase_title = phase.replace("_", " ").title() status_title = status.replace("_", " ").title() - + comment_lines = [ f"## {phase_emoji} {phase_title} Phase - {status_emoji} {status_title}", "", ] - + # Add details if provided if details: comment_lines.append(details) @@ -2269,14 +2397,14 @@ def _post_phase_comment(self, phase: str, status: str, details: str = "") -> Non comment_lines.append(f"*{timestamp}*") comment_content = "\n".join(comment_lines) - + # Post comment to Issue/MR using Task.comment method if hasattr(self.task, "comment"): self.task.comment(comment_content) self.logger.info(f"Posted {phase} phase {status} comment to Issue/MR") else: self.logger.warning("Task does not support comment, cannot post phase comment") - + except Exception as e: self.logger.error("Failed to post phase comment: %s", str(e)) @@ -2344,7 +2472,7 @@ def _post_llm_call_comment( else: # commentフィールドがない場合: デフォルトメッセージ default_message = self.phase_default_messages.get( - phase, "処理が完了しました" + phase, "処理が完了しました", ) # executionフェーズの場合はtask_idを含める if phase == "execution" and task_id: @@ -2479,7 +2607,7 @@ def _post_tool_call_after_comment( if hasattr(self.task, "comment"): self.task.comment(comment_text) self.logger.info( - "ツール呼び出し後コメントを投稿: %s, success=%s", tool_name, success + "ツール呼び出し後コメントを投稿: %s, success=%s", tool_name, success, ) else: self.logger.warning("タスクがcommentをサポートしていません") @@ -2615,7 +2743,7 @@ def restore_planning_state(self, planning_state: dict[str, Any]) -> None: """ if not planning_state or not planning_state.get("enabled"): return - + # Restore planning state self.current_phase = planning_state.get("current_phase", "planning") self.action_counter = planning_state.get("action_counter", 0) @@ -2623,33 +2751,39 @@ def restore_planning_state(self, planning_state: dict[str, Any]) -> None: # LLM呼び出し回数を復元 self.llm_call_count = planning_state.get("llm_call_count", 0) - + # Restore checklist comment ID if available saved_checklist_id = planning_state.get("checklist_comment_id") if saved_checklist_id is not None: self.checklist_comment_id = saved_checklist_id self.plan_comment_id = saved_checklist_id - + # Restore pre-planning result if available saved_pre_planning_result = planning_state.get("pre_planning_result") if saved_pre_planning_result is not None: self.pre_planning_result = saved_pre_planning_result - + + # Restore selected environment if available + saved_selected_environment = planning_state.get("selected_environment") + if saved_selected_environment is not None: + self.selected_environment = saved_selected_environment + # Restore pre-planning manager state if available saved_pre_planning_state = planning_state.get("pre_planning_state") if saved_pre_planning_state and self.pre_planning_manager: self.pre_planning_manager.restore_pre_planning_state(saved_pre_planning_state) - + self.logger.info( "Planning状態を復元しました: phase=%s, action_counter=%d, revision_counter=%d, " - "llm_call_count=%d, checklist_id=%s", + "llm_call_count=%d, checklist_id=%s, selected_environment=%s", self.current_phase, self.action_counter, self.revision_counter, self.llm_call_count, self.checklist_comment_id, + self.selected_environment, ) - + # Load existing plan from history if self.history_store.has_plan(): plan_entry = self.history_store.get_latest_plan() @@ -2668,7 +2802,7 @@ def get_planning_state(self) -> dict[str, Any]: if self.current_plan: action_plan = self.current_plan.get("action_plan", {}) total_actions = len(action_plan.get("actions", [])) - + state = { "enabled": True, "current_phase": self.current_phase, @@ -2678,12 +2812,13 @@ def get_planning_state(self) -> dict[str, Any]: "checklist_comment_id": self.plan_comment_id, "total_actions": total_actions, "pre_planning_result": self.pre_planning_result, + "selected_environment": self.selected_environment, } - + # Add pre-planning manager state if available if self.pre_planning_manager: state["pre_planning_state"] = self.pre_planning_manager.get_pre_planning_state() - + return state def _check_pause_signal(self) -> bool: @@ -2694,7 +2829,7 @@ def _check_pause_signal(self) -> bool: """ if self.pause_manager is None: return False - + return self.pause_manager.check_pause_signal() def _check_stop_signal(self) -> bool: @@ -2705,11 +2840,11 @@ def _check_stop_signal(self) -> bool: """ if self.stop_manager is None: return False - + # Check if it's time to check and if bot is unassigned if self.stop_manager.should_check_now(): return not self.stop_manager.check_assignee_status(self.task) - + return False def _handle_stop(self) -> None: @@ -2717,13 +2852,13 @@ def _handle_stop(self) -> None: if self.stop_manager is None: self.logger.warning("Stop manager not set, cannot stop") return - + # Get current planning state with total actions planning_state = self.get_planning_state() - + # 最終要約を作成してコンテキストをcompletedに移動 self.context_manager.stop() - + # コメントとラベル更新 self.stop_manager.post_stop_notification( self.task, @@ -2737,15 +2872,15 @@ def _check_and_add_new_comments(self) -> None: """ if self.comment_detection_manager is None: return - + try: new_comments = self.comment_detection_manager.check_for_new_comments() if new_comments: self.comment_detection_manager.add_to_context( - self.llm_client, new_comments + self.llm_client, new_comments, ) self.logger.info( - "新規コメント %d件をコンテキストに追加しました", len(new_comments) + "新規コメント %d件をコンテキストに追加しました", len(new_comments), ) except Exception as e: self.logger.warning("新規コメントの検出中にエラー発生: %s", e) diff --git a/system_prompt_command_executor.txt b/system_prompt_command_executor.txt index b28b6e6..172fa5a 100644 --- a/system_prompt_command_executor.txt +++ b/system_prompt_command_executor.txt @@ -7,6 +7,53 @@ You can execute commands in an isolated Docker execution environment with projec **Execution Environment Information:** - Working directory: `/workspace/project/` (where project files are cloned) - Dependencies: Automatically installed +- Environment: Selected during planning phase based on project requirements + +### Available Environments + +The following execution environments are available: + +| Environment | Base Image | Recommended For | +|-------------|-----------|-----------------| +| python | python:3.11-slim-bookworm | Pure Python projects, Django/Flask | +| miniforge | condaforge/miniforge3:latest | Data science, conda environments | +| node | node:20-slim | JavaScript/TypeScript, React/Vue/Angular | +| java | eclipse-temurin:21-jdk-jammy | Java/Kotlin, Spring Boot | +| go | golang:1.22-bookworm | Go projects, CLI tools | + +All environments include: git, curl, wget, jq, tree + +### Library Installation Guide + +Before executing project code, you may need to install required libraries: + +**Python Environment:** +- `pip install ` - Install a specific package +- `pip install -r requirements.txt` - Install from requirements file +- `pip install -e .` - Install project in editable mode + +**Miniforge (Conda) Environment:** +- `mamba install ` - Install via mamba (faster than conda) +- `mamba env update -f environment.yml` - Update environment from file +- `mamba env update -f condaenv.yaml` - Update environment from condaenv.yaml +- `conda activate ` - Activate a specific environment + +**Node.js Environment:** +- `npm install` - Install dependencies from package.json +- `npm install ` - Install a specific package +- `yarn install` - Install using yarn +- `pnpm install` - Install using pnpm + +**Java Environment:** +- `mvn dependency:resolve` - Download Maven dependencies +- `mvn install -DskipTests` - Install project without running tests +- `gradle dependencies` - Download Gradle dependencies +- `gradle build -x test` - Build without running tests + +**Go Environment:** +- `go mod download` - Download module dependencies +- `go get ` - Install a specific package +- `go mod tidy` - Clean up go.mod and go.sum ### Available Commands @@ -41,12 +88,14 @@ The following commands are available for execution: ### Usage Notes - Project source code is cloned to `/workspace/project/` in the execution environment -- Dependencies are automatically installed +- Dependencies are automatically installed when dependency files are detected - Check command execution results (stdout/stderr) and determine the next action - Long-running commands may timeout +- Use the appropriate package manager for the selected environment ### Recommended Usage 1. **Before code changes**: Search the codebase with `grep` to understand the impact scope of changes 2. **After code changes**: Run tests to verify the correctness of changes 3. **Before creating pull request**: Run linters to verify code quality +4. **If dependencies are missing**: Install them using the appropriate package manager for the environment diff --git a/tests/unit/test_execution_environment_manager.py b/tests/unit/test_execution_environment_manager.py index f8987ed..8d02cc3 100644 --- a/tests/unit/test_execution_environment_manager.py +++ b/tests/unit/test_execution_environment_manager.py @@ -430,5 +430,158 @@ def test_gitlab_clone_url(self) -> None: assert "group/project.git" in url +class TestMultiLanguageEnvironment(unittest.TestCase): + """複数言語環境対応機能のテスト.""" + + def setUp(self) -> None: + """テスト環境のセットアップ.""" + self.config: dict[str, Any] = { + "command_executor": { + "enabled": True, + "environments": { + "python": "coding-agent-executor-python:latest", + "miniforge": "coding-agent-executor-miniforge:latest", + "node": "coding-agent-executor-node:latest", + "java": "coding-agent-executor-java:latest", + "go": "coding-agent-executor-go:latest", + }, + "default_environment": "python", + "docker": { + "base_image": "ubuntu:25.04", + "resources": { + "cpu_limit": 2, + "memory_limit": "4g", + }, + }, + }, + } + self.manager = ExecutionEnvironmentManager(self.config) + + def test_get_available_environments(self) -> None: + """利用可能な環境リスト取得テスト.""" + environments = self.manager.get_available_environments() + + # すべての環境が含まれていることを確認 + assert "python" in environments + assert "miniforge" in environments + assert "node" in environments + assert "java" in environments + assert "go" in environments + + # イメージ名が正しいことを確認 + assert environments["python"] == "coding-agent-executor-python:latest" + assert environments["node"] == "coding-agent-executor-node:latest" + + def test_get_default_environment(self) -> None: + """デフォルト環境名取得テスト.""" + default_env = self.manager.get_default_environment() + assert default_env == "python" + + def test_validate_and_select_environment_valid(self) -> None: + """有効な環境名の検証テスト.""" + # 有効な環境名 + assert self.manager._validate_and_select_environment("python") == "python" + assert self.manager._validate_and_select_environment("node") == "node" + assert self.manager._validate_and_select_environment("java") == "java" + assert self.manager._validate_and_select_environment("go") == "go" + assert self.manager._validate_and_select_environment("miniforge") == "miniforge" + + def test_validate_and_select_environment_invalid(self) -> None: + """無効な環境名の検証テスト(デフォルトにフォールバック).""" + # 無効な環境名はデフォルト環境にフォールバック + assert self.manager._validate_and_select_environment("invalid") == "python" + assert self.manager._validate_and_select_environment("ruby") == "python" + + def test_validate_and_select_environment_none(self) -> None: + """環境名がNoneの場合の検証テスト.""" + # Noneの場合はデフォルト環境 + assert self.manager._validate_and_select_environment(None) == "python" + + @patch("subprocess.run") + def test_create_container_with_environment(self, mock_run: MagicMock) -> None: + """環境指定でのコンテナ作成テスト.""" + mock_run.side_effect = [ + subprocess.CompletedProcess( + args=["docker", "create"], + returncode=0, + stdout="container-id-123", + stderr="", + ), + subprocess.CompletedProcess( + args=["docker", "start"], + returncode=0, + stdout="", + stderr="", + ), + ] + + mock_task = MagicMock() + mock_task.uuid = "test-uuid-123" + + container_id = self.manager._create_container(mock_task, "node") + + assert container_id == "container-id-123" + + # docker create コマンドにnode用イメージが含まれることを確認 + create_call = mock_run.call_args_list[0] + cmd = create_call[0][0] + assert "coding-agent-executor-node:latest" in cmd + + @patch("subprocess.run") + def test_create_container_fallback_to_base_image(self, mock_run: MagicMock) -> None: + """無効な環境名でのbase_imageフォールバックテスト.""" + mock_run.side_effect = [ + subprocess.CompletedProcess( + args=["docker", "create"], + returncode=0, + stdout="container-id-456", + stderr="", + ), + subprocess.CompletedProcess( + args=["docker", "start"], + returncode=0, + stdout="", + stderr="", + ), + ] + + mock_task = MagicMock() + mock_task.uuid = "test-uuid-456" + + # 無効な環境名を渡す + container_id = self.manager._create_container(mock_task, "invalid_env") + + assert container_id == "container-id-456" + + # docker create コマンドにbase_imageが含まれることを確認 + create_call = mock_run.call_args_list[0] + cmd = create_call[0][0] + assert "ubuntu:25.04" in cmd + + def test_container_info_with_environment_name(self) -> None: + """環境名を含むContainerInfoの作成テスト.""" + info = ContainerInfo( + container_id="test-container-id", + task_uuid="test-uuid", + environment_name="node", + ) + + assert info.environment_name == "node" + assert info.container_id == "test-container-id" + assert info.task_uuid == "test-uuid" + + def test_default_environments_constant(self) -> None: + """デフォルト環境定数のテスト.""" + from handlers.execution_environment_manager import DEFAULT_ENVIRONMENTS + + # すべての環境が定義されていることを確認 + assert len(DEFAULT_ENVIRONMENTS) == 5 + assert "python" in DEFAULT_ENVIRONMENTS + assert "miniforge" in DEFAULT_ENVIRONMENTS + assert "node" in DEFAULT_ENVIRONMENTS + assert "java" in DEFAULT_ENVIRONMENTS + assert "go" in DEFAULT_ENVIRONMENTS + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_planning_coordinator.py b/tests/unit/test_planning_coordinator.py index eb9ffee..8a966fd 100644 --- a/tests/unit/test_planning_coordinator.py +++ b/tests/unit/test_planning_coordinator.py @@ -976,5 +976,248 @@ def test_phase_default_messages(self) -> None: assert "verification" in coordinator.phase_default_messages +class TestEnvironmentSelection(unittest.TestCase): + """実行環境選択機能のテスト.""" + + def setUp(self) -> None: + """テスト環境をセットアップ.""" + self.temp_dir = tempfile.mkdtemp() + self.task_uuid = "test-uuid" + self.config = { + "enabled": True, + "strategy": "chain_of_thought", + "max_subtasks": 100, + "llm_call_comments": { + "enabled": False, # テストではコメント機能を無効化 + }, + "reflection": { + "enabled": True, + "trigger_on_error": True, + "trigger_interval": 3, + }, + "revision": { + "max_revisions": 3, + }, + "main_config": { + "llm": { + "provider": "openai", + "model": "gpt-4", + "context_length": 8000, + "function_calling": False, + "openai": { + "api_key": "test-key", + "model": "gpt-4", + }, + }, + }, + } + self.task = MockTask(task_uuid=self.task_uuid) + self.mcp_clients = {"github": MagicMock()} + self.context_manager = MockContextManager(self.task_uuid, self.temp_dir) + + def tearDown(self) -> None: + """テスト環境をクリーンアップ.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_selected_environment_initialization(self) -> None: + """selected_environmentの初期化テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + assert coordinator.selected_environment is None + + def test_extract_selected_environment_dict_format(self) -> None: + """辞書形式のselected_environmentの抽出テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + plan = { + "selected_environment": { + "name": "node", + "reasoning": "This is a TypeScript project with package.json", + } + } + + env = coordinator._extract_selected_environment(plan) + assert env == "node" + + def test_extract_selected_environment_string_format(self) -> None: + """文字列形式のselected_environmentの抽出テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + plan = { + "selected_environment": "python" + } + + env = coordinator._extract_selected_environment(plan) + assert env == "python" + + def test_extract_selected_environment_missing(self) -> None: + """selected_environmentがない場合の抽出テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + plan = { + "goal_understanding": {}, + "task_decomposition": {}, + } + + env = coordinator._extract_selected_environment(plan) + assert env is None + + def test_extract_selected_environment_invalid_plan(self) -> None: + """無効な計画からの抽出テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + env = coordinator._extract_selected_environment(None) + assert env is None + + env = coordinator._extract_selected_environment("not a dict") + assert env is None + + def test_build_environment_selection_prompt(self) -> None: + """環境選択プロンプト構築テスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + prompt = coordinator._build_environment_selection_prompt() + + # プロンプトに必要な要素が含まれていることを確認 + assert "Execution Environment Selection" in prompt + assert "python" in prompt + assert "node" in prompt + assert "java" in prompt + assert "go" in prompt + assert "miniforge" in prompt + assert "Default Environment" in prompt + assert "Selection Criteria" in prompt + assert "selected_environment" in prompt + + def test_build_planning_prompt_includes_environment_selection(self) -> None: + """計画プロンプトに環境選択情報が含まれるかテスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + prompt = coordinator._build_planning_prompt([]) + + # 環境選択情報が含まれていることを確認 + assert "Execution Environment Selection" in prompt + assert "selected_environment" in prompt + + def test_get_planning_state_includes_selected_environment(self) -> None: + """get_planning_stateにselected_environmentが含まれるかテスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + coordinator.selected_environment = "node" + state = coordinator.get_planning_state() + + assert "selected_environment" in state + assert state["selected_environment"] == "node" + + def test_restore_planning_state_restores_selected_environment(self) -> None: + """restore_planning_stateでselected_environmentが復元されるかテスト.""" + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + planning_state = { + "enabled": True, + "current_phase": "execution", + "action_counter": 3, + "revision_counter": 1, + "selected_environment": "java", + } + + coordinator.restore_planning_state(planning_state) + + assert coordinator.selected_environment == "java" + + def test_build_environment_selection_prompt_with_execution_manager(self) -> None: + """ExecutionEnvironmentManagerがある場合の環境選択プロンプトテスト.""" + from handlers.execution_environment_manager import ExecutionEnvironmentManager + + llm_client = MagicMock() + coordinator = PlanningCoordinator( + config=self.config, + llm_client=llm_client, + mcp_clients=self.mcp_clients, + task=self.task, + context_manager=self.context_manager, + ) + + # ExecutionEnvironmentManagerをモックとして設定 + mock_execution_manager = MagicMock() + mock_execution_manager.get_available_environments.return_value = { + "python": "coding-agent-executor-python:latest", + "node": "coding-agent-executor-node:latest", + } + mock_execution_manager.get_default_environment.return_value = "python" + coordinator.execution_manager = mock_execution_manager + + prompt = coordinator._build_environment_selection_prompt() + + # ExecutionEnvironmentManagerから取得した環境が含まれていることを確認 + assert "python" in prompt + assert "node" in prompt + mock_execution_manager.get_available_environments.assert_called_once() + mock_execution_manager.get_default_environment.assert_called_once() + + if __name__ == "__main__": unittest.main() From 1c84ae1bdc98b5c6fb1fd45bb44ff65fc8370b38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:16:39 +0000 Subject: [PATCH 3/4] Address code review feedback - Improve git installation logic in prepare() method - Return (container_id, is_custom_image) tuple from _create_container() - Import DEFAULT_ENVIRONMENTS constant in planning_coordinator.py - Update unit tests for new return type Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> --- handlers/execution_environment_manager.py | 29 +++++++++------ handlers/planning_coordinator.py | 17 ++++----- .../test_execution_environment_manager.py | 37 ++++++++++--------- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/handlers/execution_environment_manager.py b/handlers/execution_environment_manager.py index 1b7d6b5..ca5bc2e 100644 --- a/handlers/execution_environment_manager.py +++ b/handlers/execution_environment_manager.py @@ -354,7 +354,7 @@ def prepare(self, task: Task, environment_name: str | None = None) -> ContainerI self.logger.warning("既存コンテナの削除に失敗: %s", e) # コンテナを作成(選択された環境のイメージを使用) - container_id = self._create_container(task, selected_env) + container_id, is_custom_image = self._create_container(task, selected_env) # コンテナ情報を作成(environment_name属性を含める) container_info = ContainerInfo( @@ -364,9 +364,9 @@ def prepare(self, task: Task, environment_name: str | None = None) -> ContainerI status="created", ) - # 事前にgitインストール済みのイメージを使用するため、インストールはスキップ - # ただし、従来のフォールバックイメージ(base_image)の場合はインストールが必要 - if selected_env not in self._environments: + # プレビルドイメージ(coding-agent-executor-*)にはgitが含まれているためスキップ + # base_imageへフォールバックした場合のみgitをインストール + if not is_custom_image: try: self._install_git(container_id) except RuntimeError: @@ -394,8 +394,8 @@ def prepare(self, task: Task, environment_name: str | None = None) -> ContainerI self._active_containers[task_uuid] = container_info self.logger.info( - "実行環境の準備が完了しました: %s (環境: %s)", - container_id, + "実行環境の準備が完了しました: %s (環境: %s)", + container_id, selected_env, ) return container_info @@ -427,16 +427,19 @@ def _validate_and_select_environment(self, environment_name: str | None) -> str: return environment_name - def _create_container(self, task: Task, environment_name: str | None = None) -> str: + def _create_container( + self, task: Task, environment_name: str | None = None, + ) -> tuple[str, bool]: """Dockerコンテナを作成する. - + Args: task: タスクオブジェクト environment_name: 使用する環境名。指定された場合は対応するイメージを使用 - + Returns: - コンテナID - + (コンテナID, カスタムイメージ使用フラグ) のタプル + カスタムイメージ使用フラグは、environments設定のイメージを使用した場合True + Raises: RuntimeError: コンテナ作成に失敗した場合 @@ -445,8 +448,10 @@ def _create_container(self, task: Task, environment_name: str | None = None) -> container_name = self._get_container_name(task_uuid) # 環境名に基づいてイメージを選択 + is_custom_image = False if environment_name and environment_name in self._environments: image = self._environments[environment_name] + is_custom_image = True self.logger.info("環境 '%s' のイメージを使用: %s", environment_name, image) else: # フォールバック: base_imageを使用 @@ -487,7 +492,7 @@ def _create_container(self, task: Task, environment_name: str | None = None) -> self._run_docker_command(["rm", "-f", container_id], check=False) raise RuntimeError(error_msg) from e - return container_id + return container_id, is_custom_image def _install_git(self, container_id: str) -> None: """コンテナ内にgitをインストールする. diff --git a/handlers/planning_coordinator.py b/handlers/planning_coordinator.py index f39bf98..f10baa8 100644 --- a/handlers/planning_coordinator.py +++ b/handlers/planning_coordinator.py @@ -1675,21 +1675,20 @@ def _build_environment_selection_prompt(self) -> str: """ # ExecutionEnvironmentManagerから環境リストを取得(利用可能な場合) + from handlers.execution_environment_manager import ( + DEFAULT_ENVIRONMENT_NAME, + DEFAULT_ENVIRONMENTS, + ) + environments = {} - default_env = "python" + default_env = DEFAULT_ENVIRONMENT_NAME if self.execution_manager is not None: environments = self.execution_manager.get_available_environments() default_env = self.execution_manager.get_default_environment() else: - # デフォルトの環境リスト - environments = { - "python": "coding-agent-executor-python:latest", - "miniforge": "coding-agent-executor-miniforge:latest", - "node": "coding-agent-executor-node:latest", - "java": "coding-agent-executor-java:latest", - "go": "coding-agent-executor-go:latest", - } + # ExecutionEnvironmentManagerの定数を使用 + environments = DEFAULT_ENVIRONMENTS.copy() # 環境ごとの推奨用途 env_recommendations = { diff --git a/tests/unit/test_execution_environment_manager.py b/tests/unit/test_execution_environment_manager.py index 8d02cc3..81b3913 100644 --- a/tests/unit/test_execution_environment_manager.py +++ b/tests/unit/test_execution_environment_manager.py @@ -199,7 +199,7 @@ def test_run_docker_command(self, mock_run: MagicMock) -> None: ) result = self.manager._run_docker_command(["ps"]) - + mock_run.assert_called_once() assert result.returncode == 0 assert result.stdout == "container list" @@ -222,14 +222,15 @@ def test_create_container(self, mock_run: MagicMock) -> None: stderr="", ), ] - + # タスクのモックを作成 mock_task = MagicMock() mock_task.uuid = "test-uuid-123" - - container_id = self.manager._create_container(mock_task) - + + container_id, is_custom = self.manager._create_container(mock_task) + assert container_id == "container-id-123" + assert is_custom is False # 環境名指定なしなのでFalse assert mock_run.call_count == 2 @patch("subprocess.run") @@ -514,14 +515,15 @@ def test_create_container_with_environment(self, mock_run: MagicMock) -> None: stderr="", ), ] - + mock_task = MagicMock() mock_task.uuid = "test-uuid-123" - - container_id = self.manager._create_container(mock_task, "node") - + + container_id, is_custom = self.manager._create_container(mock_task, "node") + assert container_id == "container-id-123" - + assert is_custom is True # 有効な環境名指定なのでTrue + # docker create コマンドにnode用イメージが含まれることを確認 create_call = mock_run.call_args_list[0] cmd = create_call[0][0] @@ -544,15 +546,16 @@ def test_create_container_fallback_to_base_image(self, mock_run: MagicMock) -> N stderr="", ), ] - + mock_task = MagicMock() mock_task.uuid = "test-uuid-456" - + # 無効な環境名を渡す - container_id = self.manager._create_container(mock_task, "invalid_env") - + container_id, is_custom = self.manager._create_container(mock_task, "invalid_env") + assert container_id == "container-id-456" - + assert is_custom is False # 無効な環境名なのでFalse + # docker create コマンドにbase_imageが含まれることを確認 create_call = mock_run.call_args_list[0] cmd = create_call[0][0] @@ -565,7 +568,7 @@ def test_container_info_with_environment_name(self) -> None: task_uuid="test-uuid", environment_name="node", ) - + assert info.environment_name == "node" assert info.container_id == "test-container-id" assert info.task_uuid == "test-uuid" @@ -573,7 +576,7 @@ def test_container_info_with_environment_name(self) -> None: def test_default_environments_constant(self) -> None: """デフォルト環境定数のテスト.""" from handlers.execution_environment_manager import DEFAULT_ENVIRONMENTS - + # すべての環境が定義されていることを確認 assert len(DEFAULT_ENVIRONMENTS) == 5 assert "python" in DEFAULT_ENVIRONMENTS From d95a64e4b63f081a1e19bb43c3b7263f91b03f36 Mon Sep 17 00:00:00 2001 From: notfolder Date: Sun, 30 Nov 2025 09:30:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E7=92=B0=E5=A2=83?= =?UTF-8?q?=E3=81=AE=E6=BA=96=E5=82=99=E3=82=92=E8=A8=88=E7=94=BB=E3=83=95?= =?UTF-8?q?=E3=82=A7=E3=83=BC=E3=82=BA=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=84?= =?UTF-8?q?=E3=81=A6=E9=81=85=E5=BB=B6=E3=81=95=E3=81=9B=E3=82=8B=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/execution_environment_manager.py | 2 - handlers/planning_coordinator.py | 65 +++++++++++++++++++++++ handlers/task_handler.py | 47 ++++++++++------ 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/handlers/execution_environment_manager.py b/handlers/execution_environment_manager.py index ca5bc2e..f0862cb 100644 --- a/handlers/execution_environment_manager.py +++ b/handlers/execution_environment_manager.py @@ -467,9 +467,7 @@ def _create_container( "--workdir", "/workspace", # 非特権モードで実行 "--security-opt", "no-new-privileges", - # コンテナを継続実行(sleepコマンド) image, - "sleep", "infinity", ] try: diff --git a/handlers/planning_coordinator.py b/handlers/planning_coordinator.py index f10baa8..25d89b9 100644 --- a/handlers/planning_coordinator.py +++ b/handlers/planning_coordinator.py @@ -256,6 +256,13 @@ def execute_with_planning(self) -> bool: plan_entry = self.history_store.get_latest_plan() if plan_entry: self.current_plan = plan_entry.get("plan") or plan_entry.get("updated_plan") + if self.current_plan: + # 履歴から選択環境を復元 + self.selected_environment = self._extract_selected_environment(self.current_plan) + self.logger.info( + "履歴に保存された実行環境: %s", + self.selected_environment, + ) self.current_phase = "execution" self._post_phase_comment("planning", "completed", "Loaded existing plan from history.") else: @@ -288,6 +295,11 @@ def execute_with_planning(self) -> bool: # Check for new comments after planning phase self._check_and_add_new_comments() + # Ensure execution environment is ready before execution phase + if not self._ensure_execution_environment_ready(): + self.logger.error("Execution environment preparation failed. Aborting task.") + return False + # Post execution start self._post_phase_comment("execution", "started", "Beginning execution of planned actions...") @@ -765,6 +777,59 @@ def _extract_selected_environment(self, plan: dict[str, Any]) -> str | None: return None + def _ensure_execution_environment_ready(self) -> bool: + """実行フェーズ開始前に実行環境コンテナを準備する. + + Returns: + 実行環境が利用可能な場合はTrue、準備に失敗した場合はFalse + + """ + if self.execution_manager is None: + # コマンド実行機能が無効な場合は処理不要 + return True + + if not self.task.uuid: + # UUIDがないとコンテナ名を一意にできないため実行不能 + warning_msg = "タスクにUUIDがないため実行環境を準備できません。" + self.logger.warning(warning_msg) + self.task.comment(f"⚠️ {warning_msg}") + return False + + container_info = self.execution_manager.get_container_info(self.task.uuid) + if container_info is not None and container_info.status == "ready": + # 既に準備済みのコンテナを再利用する + self.logger.info( + "既存の実行環境を再利用します: %s (%s)", + container_info.container_id, + container_info.environment_name, + ) + if self.selected_environment is None: + self.selected_environment = container_info.environment_name + return True + + # 計画で選択された環境がない場合はデフォルトを使用 + environment_name = self.selected_environment or self.execution_manager.get_default_environment() + if self.selected_environment is None: + self.selected_environment = environment_name + + try: + # コンテナを起動し、利用可能状態まで準備する + container_info = self.execution_manager.prepare(self.task, environment_name) + self.logger.info( + "実行環境を起動しました: %s (%s)", + container_info.container_id, + container_info.environment_name, + ) + self.task.comment( + f"選択された実行環境({container_info.environment_name})を起動しました。" + ) + return True + except Exception as error: + error_msg = f"実行環境の準備に失敗しました: {error}" + self.logger.exception(error_msg) + self.task.comment(f"⚠️ {error_msg}") + return False + def _execute_action(self) -> dict[str, Any] | None: """Execute the next action from the plan. diff --git a/handlers/task_handler.py b/handlers/task_handler.py index 82df29c..e361c3c 100644 --- a/handlers/task_handler.py +++ b/handlers/task_handler.py @@ -98,18 +98,22 @@ def handle(self, task: Task) -> None: """ # タスク固有の設定を取得 task_config = self._get_task_config(task) - - # 実行環境の初期化 - execution_manager = self._init_execution_environment(task, task_config) - + + # 計画機能の有効可否を先に判定する + planning_config = task_config.get("planning", {}) + planning_enabled = planning_config.get("enabled", True) + + # 実行環境の初期化(計画利用時はコンテナ起動を後回し) + execution_manager = self._init_execution_environment( + task, + task_config, + prepare=not planning_enabled, + ) + try: - # Check if planning is enabled - planning_config = task_config.get("planning", {}) - planning_enabled = planning_config.get("enabled", True) - if planning_enabled and task.uuid: # Use planning-based task handling - self._handle_with_planning(task, task_config) + self._handle_with_planning(task, task_config, execution_manager) else: # Check if context storage is enabled context_storage_enabled = task_config.get("context_storage", {}).get("enabled", False) @@ -128,6 +132,8 @@ def _init_execution_environment( self, task: Task, task_config: dict[str, Any], + *, + prepare: bool = True, ) -> Any | None: """実行環境を初期化する. @@ -136,6 +142,7 @@ def _init_execution_environment( Args: task: タスクオブジェクト task_config: タスク固有の設定 + prepare: 直ちにコンテナを起動するかどうか Returns: ExecutionEnvironmentManagerインスタンス(無効な場合はNone) @@ -160,6 +167,14 @@ def _init_execution_environment( ) return None + if not prepare: + # 計画フェーズ完了後にコンテナを起動する + self.logger.info( + "Command Executor実行環境の準備を遅延します: %s", + task.uuid, + ) + return manager + # 実行環境を準備 self.logger.info("Command Executor実行環境を準備します: %s", task.uuid) container_info = manager.prepare(task) @@ -335,12 +350,18 @@ def _handle_with_context_storage(self, task: Task, task_config: dict[str, Any]) context_manager.fail(str(e)) raise - def _handle_with_planning(self, task: Task, task_config: dict[str, Any]) -> None: + def _handle_with_planning( + self, + task: Task, + task_config: dict[str, Any], + execution_manager: Any | None, + ) -> None: """Handle task with planning-based approach. Args: task: Task object task_config: Task configuration + execution_manager: Execution environment manager instance """ from comment_detection_manager import CommentDetectionManager @@ -379,9 +400,6 @@ def _handle_with_planning(self, task: Task, task_config: dict[str, Any]) -> None is_resumed=is_resumed, ) - # Initialize execution environment if enabled - execution_manager = self._init_execution_environment(task, task_config) - try: # Get planning configuration planning_config = task_config.get("planning", {}) @@ -452,9 +470,6 @@ def _handle_with_planning(self, task: Task, task_config: dict[str, Any]) -> None context_manager.fail(str(e)) self.logger.exception("Planning-based task processing failed") raise - finally: - # Cleanup execution environment - self._cleanup_execution_environment(execution_manager, task) def _handle_legacy(self, task: Task, task_config: dict[str, Any]) -> None: """Handle task using legacy in-memory approach.