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.