/
Watashicuvu
/
agentic-tools
Обзор
Документация
Войти
/
Watashicuvu
/
agentic-tools
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/cli_agent/cli.py
4 272 строки
156 KB
Your Name
added come roles
28 май 2026, 15:16
28 май 2026, 15:16
745363b
Код
Авторство
О чём код?
"""CLI интерфейс для агента. Модуль предоставляет команды для работы с LLM-агентом: - Интерактивный чат с динамическим определением ролей (strategist/executor/reviewer) - Генерация докстрингов (preview/apply, batch/single file) - Генерация тестов (single file / all) - Исправление lint issues (single file / all) - Quality pipeline (тесты → линтинг → фикс) - Swarm планирование и выполнение (swarm-plan, swarm-execute) Архитектура чата: ``` Пользователь ↓ RoleRouter (определение ролей) ↓ ┌─────────────────────────────────────┐ │ mode=auto: classifier → roles │ │ mode=command: явные команды: │ │ /plan → strategist+exec+rev │ │ /execute → executor │ │ /review → reviewer │ │ /approve → продолжить шаг │ │ /reject → вернуть на доработку │ └─────────────────────────────────────┘ ↓ InteractiveExecutor (выполнение с одобрением) ↓ ┌─────────────────────────────────────┐ │ 1. Strategist: анализ + план │ │ 2. Executor: генерация + tools │ │ 3. Reviewer: LLM + static checks │ │ → APPROVED или BLOCKER │ └─────────────────────────────────────┘ ↓ Результат пользователю ``` Quality команды: | Команда | Описание | |---------|----------| | `generate-tests <file>` | Генерация unit-тестов для непокрытых функций одного файла | | `lint-fix <file>` | Исправление lint issues одного файла (ruff --fix + LLM) | | `coverage-generate-all` | Генерация тестов для всех непокрытых функций проекта | | `lint-fix-all` | Исправление lint issues всего проекта | | `quality-pipeline` | Полный пайплайн: тесты → линтинг → фикс | Docstring команды: | Команда | Описание | |---------|----------| | `docstring <file>` | Генерация докстрингов для файла (preview/apply) | | `docstring-generate-all` | Batch генерация докстрингов для всех файлов | | `docstring-checklist` | Генерация чек-листа функций без докстрингов | Примеры использования: ```bash # Сгенерировать тесты для одного файла uv run cli-agent generate-tests src/service.py # Сгенерировать тесты с указанием проекта и слиянием .env uv run cli-agent generate-tests src/service.py \\ --project-dir /path/to/project \\ --merge-env \\ --concurrency 3 \\ --model qwen # Исправить lint issues одного файла uv run cli-agent lint-fix src/api/users.py # Исправить с указанием модели и сохранением результатов uv run cli-agent lint-fix src/api/users.py \\ --model qwen-plus \\ --output /tmp/lint-results.json # Batch генерация тестов для всего проекта uv run cli-agent coverage-generate-all \\ --project-dir /path/to/project \\ --concurrency 5 # Полный quality pipeline uv run cli-agent quality-pipeline \\ --steps tests,lint,fix \\ --concurrency 3 # Докстринги для файла (preview с авто-одобрением) uv run cli-agent docstring src/service.py -m preview -a # Докстринги для всех файлов с семафором uv run cli-agent docstring-generate-all -c 3 -a ``` Все команды поддерживают: - `--project-dir`: директория целевого проекта (по умолчанию cwd) - `--merge-env`: слить .env инструмента и проекта (target имеет приоритет) - `--concurrency`: количество параллельных задач (семафор) - `--model`: модель LLM - `--output`: путь для сохранения результатов (JSON) """ import asyncio import json import logging import os import re from datetime import datetime, timezone from pathlib import Path from typing import Optional, List, Dict, Any import typer from rich.console import Console from rich.panel import Panel from rich.table import Table from .context_loader import ContextLoader from .llm_client import LLMClient from .llm_streaming_client import LLMStreamingClient, StreamEventType, ToolCallError from .orchestrator import create_orchestrator from .stream_handler import InteractiveStreamHandler from .tools.registry import ToolRegistry from src.services.async_smart_client import AsyncSmartOpenAI from src.services.docstring_llm_generator import ( DocstringLLMGenerator ) from src.swarm.worker_executor import WorkerExecutor logger = logging.getLogger(__name__) app = typer.Typer( name="cli-agent", help="CLI агент для интерактивной работы с LLM", add_completion=False ) console = Console() def _parse_env_file(env_file: Path) -> Dict[str, str]: """Парсит .env файл и возвращает словарь переменных окружения.""" env_vars = {} if not env_file.exists(): return env_vars try: with open(env_file, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue match = re.match(r'^([A-Z_][A-Z0-9_]*)=(.*)$', line, re.IGNORECASE) if match: key, value = match.groups() value = value.strip('"\'') env_vars[key] = value except Exception as e: console.print(f"[yellow]⚠ Failed to parse {env_file}: {e}[/yellow]") return env_vars def _is_running_in_docker() -> bool: """Определяет, запущен ли процесс внутри Docker контейнера.""" import os # Проверка через cgroup (Linux) try: with open("/proc/1/cgroup", "r") as f: content = f.read() if "docker" in content or "containerd" in content or "kubepods" in content: return True except (FileNotFoundError, PermissionError): pass # Проверка через .dockerenv if os.path.exists("/.dockerenv"): return True # Проверка через переменную окружения (можно установить в Dockerfile) if os.getenv("RUNNING_IN_DOCKER", "").lower() in ("1", "true", "yes"): return True return False def _get_default_eval_dir(project_root: Path) -> Path: """Возвращает путь по умолчанию для eval директории. В Docker: /app/eval (внутри контейнера) Локально: /tmp/eval (системная временная директория) Args: project_root: Корень проекта (используется для логирования). Returns: Путь к eval директории. """ if _is_running_in_docker(): return Path("/app/eval") else: import tempfile return Path(tempfile.gettempdir()) / "eval" def _prepare_eval_dir(project_root: Path) -> Path: """Подготовить eval директорию для выполнения задач. Создает eval директорию с изолированным git репозиторием и отдельным семантическим индексом (.codecontext). В Docker: /app/eval Локально: /tmp/eval Args: project_root: Корень проекта. Returns: Путь к подготовленной eval директории. """ import shutil import subprocess eval_dir = _get_default_eval_dir(project_root) if eval_dir.exists(): console.print(f"[yellow]🧹 Cleaning existing eval directory...[/yellow]") # Удаляем содержимое, но сохраняем саму директорию for item in eval_dir.iterdir(): if item.is_dir(): shutil.rmtree(item) else: item.unlink() console.print(f" [green]✓[/green] Eval directory cleaned") else: console.print(f"[cyan]📁 Creating eval directory...[/cyan]") eval_dir.mkdir(parents=True, exist_ok=True) console.print(f" [green]✓[/green] {eval_dir}") # Инициализируем отдельный git репозиторий в eval console.print(f"[cyan]🔧 Initializing git repository in eval...[/cyan]") try: subprocess.run( ["git", "init"], cwd=str(eval_dir), check=True, capture_output=True, ) # Создаём .gitignore для игнорирования .codecontext (он будет пересоздан) gitignore_path = eval_dir / ".gitignore" gitignore_path.write_text( "# Auto-generated eval gitignore\n" ".codecontext/\n" "__pycache__/\n" "*.pyc\n" ".DS_Store\n" ) console.print(f" [green]✓[/green] Git repository initialized") except (subprocess.CalledProcessError, FileNotFoundError) as e: console.print(f" [yellow]⚠️ Git init failed: {e}[/yellow]") return eval_dir def _load_project_env(project_root: Path, merge_with_tool: bool = False) -> Dict[str, str]: """ Загружает .env из целевого проекта. Args: project_root: Корень целевого проекта merge_with_tool: Слить с .env инструмента (target имеет приоритет) Returns: Dict с переменными окружения """ target_env_path = project_root / ".env" target_env = _parse_env_file(target_env_path) if merge_with_tool: # Загружаем .env инструмента tool_env_path = Path(__file__).parent.parent.parent.parent / ".env" tool_env = _parse_env_file(tool_env_path) # Сливаем: target имеет приоритет merged = tool_env.copy() merged.update(target_env) if target_env: console.print(f"[green]✓ Loaded .env from target project ({target_env_path})[/green]") if tool_env: console.print(f"[green]✓ Merged with tool .env ({tool_env_path})[/green]") return merged if target_env: console.print(f"[green]✓ Loaded .env from target project: {len(target_env)} variables[/green]") return target_env def _apply_env_vars(env_vars: Dict[str, str]): """Применяет переменные окружения.""" for key, value in env_vars.items(): if key not in os.environ: os.environ[key] = value @app.command(name="docstring-checklist") def docstring_checklist( project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), force: bool = typer.Option( False, "--force", "-f", help="Пересоздать чек-лист даже если он существует" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ) ): """Генерация чек-листа функций, требующих докстрингов.""" asyncio.run( _generate_checklist( project_dir=project_dir, force=force, merge_env=merge_env ) ) @app.command() def docstring( file_path: str = typer.Argument( ..., help="Путь к Python файлу" ), mode: str = typer.Option( "preview", "--mode", "-m", help="Режим: preview (с одобрением) или apply (без одобрения)" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта (target имеет приоритет)" ), model: str = typer.Option( None, "--model", help="Модель LLM (по умолчанию из heuristics или .env)" ), all_approved: bool = typer.Option( False, "--all-approved", "-a", help="Автоматически одобрить все докстринги (только для mode=preview)" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Генерация докстрингов для файла с превью и одобрением.""" asyncio.run( _docstring_generate( file_path=file_path, mode=mode, model=model, all_approved=all_approved, output=output, project_dir=project_dir, merge_env=merge_env ) ) @app.command(name="docstring-generate-all") def docstring_generate_all( mode: str = typer.Option( "apply", "--mode", "-m", help="Режим: preview (с одобрением) или apply (без одобрения)" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта (target имеет приоритет)" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных файлов (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), all_approved: bool = typer.Option( False, "--all-approved", "-a", help="Авто-одобрение всех докстрингов" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Генерация докстрингов для всех файлов из чек-листа с семафором.""" asyncio.run( _docstring_generate_all( mode=mode, concurrency=concurrency, model=model, all_approved=all_approved, output=output, project_dir=project_dir, merge_env=merge_env ) ) async def _generate_checklist( project_dir: Optional[str] = None, force: bool = False, merge_env: bool = False ): """Генерация чек-листа функций, требующих докстрингов.""" from pathlib import Path as PathLib from datetime import datetime, timezone project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загрузка .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) checklists_dir = project_root / ".codecontext" / "checklists" checklists_dir.mkdir(exist_ok=True, parents=True) todo_file = checklists_dir / "docstring_todo.json" status_file = checklists_dir / "docstring.status" if todo_file.exists() and not force: console.print(f"[yellow]⚠ Checklist already exists: {todo_file}[/yellow]") console.print("[yellow]Use --force to regenerate.[/yellow]") if status_file.exists(): console.print(f"\n[cyan]📊 Current status:[/cyan]") console.print(status_file.read_text(encoding="utf-8")) return from src.services.repository_context import RepositoryContext from src.services.heuristics_loader import HeuristicsLoader from src.services.docstring_filter import DocstringFilter console.print("[cyan]🔍 Scanning repository for functions needing docstrings...[/cyan]") console.print(f" Project root: {project_root}") # Загружаем эвристики heuristics = HeuristicsLoader.load(project_root / ".codecontext") doc_filter = DocstringFilter(heuristics) # Создаём контекст (без семантического индекса для скорости) console.print(" Loading repository context...") repo_ctx = RepositoryContext(repo_root=project_root) functions_todo = [] files_scanned = 0 files_with_gaps = 0 files_excluded = 0 functions_excluded = 0 console.print(" Analyzing Python files...") for rel_path, analysis in repo_ctx.file_analyses.items(): if not analysis.get("success"): continue files_scanned += 1 if doc_filter.should_exclude_file(rel_path): files_excluded += 1 continue file_functions = [] for func in analysis.get("functions", []): if func.get("error"): continue func_name = func["name"] if not doc_filter.should_include_function(func_name, func): functions_excluded += 1 continue zone = analysis.get("architectural_zone", "unknown") priority = doc_filter.get_zone_priority(zone) file_functions.append({ "function_name": func["name"], "line_start": func.get("line_start", "?"), "reason": "missing" if not func.get("has_docstring") else "inadequate", "param_count": func.get("context", {}).get("complexity_metrics", {}).get("param_count", 0), "has_return_annotation": bool(func.get("context", {}).get("type_semantics", {}).get("return")), "status": "pending", "zone": zone, "priority": priority }) if file_functions: file_functions.sort(key=lambda f: (f["priority"], f["line_start"])) files_with_gaps += 1 functions_todo.append({ "file_path": rel_path, "functions": file_functions, "total_functions": len(file_functions), "architectural_zone": analysis.get("architectural_zone", "unknown") }) functions_todo.sort(key=lambda f: (f.get("priority", 6), f["file_path"])) import json checklist = { "generated_at": datetime.now(timezone.utc).isoformat(), "repo_root": str(project_root), "filtering_applied": { "files_excluded": files_excluded, "functions_excluded": functions_excluded, "filter_stats": doc_filter.get_filter_stats() }, "statistics": { "files_scanned": files_scanned, "files_with_gaps": files_with_gaps, "total_functions_todo": sum(f["total_functions"] for f in functions_todo) }, "files": functions_todo } todo_file.write_text( json.dumps(checklist, indent=2, ensure_ascii=False), encoding="utf-8" ) status_content = f"""# Docstring Documentation Status # Generated: {checklist['generated_at']} # DO NOT EDIT MANUALLY - This file is auto-updated ## Summary - **Files scanned**: {files_scanned} - **Files excluded**: {files_excluded} - **Files with gaps**: {files_with_gaps} - **Functions to document**: {checklist['statistics']['total_functions_todo']} - **Functions excluded**: {functions_excluded} ## Filtering Applied - File patterns: {doc_filter.get_filter_stats()['exclude_file_patterns']} - Function patterns: {doc_filter.get_filter_stats()['exclude_func_patterns']} ## Progress - Completed: 0 / {checklist['statistics']['total_functions_todo']} - Percentage: 0% ## Status per File """ for file_entry in functions_todo: zone = file_entry.get("architectural_zone", "unknown") status_content += f"\n### `{file_entry['file_path']}` (zone: {zone})\n" for func in file_entry["functions"]: status_content += f"- [ ] `{func['function_name']}` (line {func['line_start']}) - {func['reason']}\n" status_file.write_text(status_content, encoding="utf-8") filter_stats = doc_filter.get_filter_stats() console.print(f"\n[green]✅ Checklist generated successfully![/green]") console.print(f"\n[bold]📊 Statistics:[/bold]") console.print(f" Files scanned: {files_scanned}") console.print(f" Files excluded: {files_excluded}") console.print(f" Files with gaps: {files_with_gaps}") console.print(f" Functions to doc: {checklist['statistics']['total_functions_todo']}") console.print(f" Functions excluded: {functions_excluded}") console.print(f"\n[bold]🔧 Filtering applied:[/bold]") console.print(f" File patterns: {filter_stats['exclude_file_patterns']}") console.print(f" Function patterns: {filter_stats['exclude_func_patterns']}") console.print(f" Dunder min lines: {filter_stats['dunder_min_lines']}") console.print(f" Complexity min: {filter_stats['complexity_min_lines']}") console.print(f"\n[bold]📁 Output files:[/bold]") console.print(f" Checklist: {todo_file}") console.print(f" Status: {status_file}") async def _docstring_generate_all( mode: str, concurrency: int, model: Optional[str], all_approved: bool, output: Optional[str], project_dir: Optional[str] = None, merge_env: bool = False ): """Генерация докстрингов для всех файлов из чек-листа с семафором.""" from pathlib import Path as PathLib import libcst as cst from src.services.docstring_pipe import CodeContextExtractor, DocstringQualityChecker # Определение корня проекта project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загрузка .env из целевого проекта env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(f"[cyan]📁 Project root: {project_root}[/cyan]") # Генерация актуального чек-листа console.print("[cyan]🔄 Generating fresh checklist...[/cyan]") await _generate_checklist( project_dir=str(project_root), force=True, merge_env=False # .env уже загружен ) todo_file = project_root / ".codecontext" / "checklists" / "docstring_todo.json" if not todo_file.exists(): console.print(f"[red]Checklist generation failed[/red]") raise typer.Exit(1) console.print(f"[cyan]📋 Checklist: {todo_file}[/cyan]") import json checklist = json.loads(todo_file.read_text(encoding="utf-8")) files = checklist.get("files", []) total_functions = checklist.get("statistics", {}).get("total_functions_todo", 0) if not files: console.print("[green]✓ No functions need docstrings[/green]") return console.print(Panel( f"Found {len(files)} file(s), {total_functions} function(s) needing docstrings\n" f"Concurrency: {concurrency}", title="Batch Docstring Generation", border_style="blue" )) # Загрузка эвристик из целевого проекта heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) console.print(f"[green]✓ Heuristics loaded from target project[/green]") else: console.print("[yellow]⚠ Heuristics not found, using defaults[/yellow]") # Инициализация SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url ) # Семафор для контроля параллелизма semaphore = asyncio.Semaphore(concurrency) results = [] completed_files = 0 failed_files = 0 from typing import Dict, Any async def process_file(file_entry: Dict[str, Any]): """Обработать один файл.""" nonlocal completed_files, failed_files async with semaphore: file_path_str = file_entry["file_path"] # Резолвим путь относительно project_root file_path = PathLib(file_path_str) if not file_path.is_absolute(): file_path = project_root / file_path_str file_path = file_path.resolve() if not file_path.exists(): console.print(f"[red]File not found: {file_path_str}[/red]") failed_files += 1 return {"file": file_path_str, "error": "File not found", "functions": []} console.print(f"\n[cyan][{completed_files + failed_files + 1}/{len(files)}] Processing: {file_path_str}[/cyan]") try: # Парсинг файла source_code = file_path.read_text(encoding="utf-8") module = cst.parse_module(source_code) context_extractor = CodeContextExtractor(module, file_path) # Генератор generator = DocstringLLMGenerator( client=smart_client, model=model, heuristics=heuristics ) file_results = [] # Обработка функций из чек-листа todo_functions = {f["function_name"]: f for f in file_entry.get("functions", [])} class FunctionCollector(cst.CSTVisitor): def visit_FunctionDef(self, node: cst.FunctionDef) -> None: func_name = node.name.value if func_name not in todo_functions: return if not DocstringQualityChecker.needs_docstring(node, heuristics): return # Извлекаем код функции через оригинальный модуль func_code = module.code_for_node(node) func_context = context_extractor.extract_for_function(node) file_results.append({ "name": func_name, "code": func_code, "context": func_context }) collector = FunctionCollector() module.visit(collector) if not file_results: console.print(f" [yellow]No functions match checklist[/yellow]") completed_files += 1 return {"file": file_path_str, "functions": [], "skipped": True} console.print(f" Found {len(file_results)} function(s) to process") func_results = [] for idx, func in enumerate(file_results, 1): console.print(f" [{idx}/{len(file_results)}] {func['name']}...") try: if mode == "preview": preview = await generator.generate_docstring_preview( function_name=func["name"], function_code=func["code"], context=func["context"] ) if all_approved: preview["approved"] = True func_results.append(preview) else: result = await generator.generate_docstring( function_name=func["name"], function_code=func["code"], context=func["context"] ) func_results.append({ "function_name": func["name"], "docstring": result.docstring_content, "quality_score": result.quality_score, "applied": True }) console.print(f" [green]✓ Done[/green]") except Exception as e: console.print(f" [red]✗ Error: {e}[/red]") func_results.append({ "function_name": func["name"], "error": str(e) }) completed_files += 1 return {"file": file_path_str, "functions": func_results} except Exception as e: console.print(f" [red]✗ File error: {e}[/red]") failed_files += 1 return {"file": file_path_str, "error": str(e), "functions": []} # Запуск обработки с семафором console.print(f"\n[yellow]Starting batch processing (concurrency={concurrency})...[/yellow]") tasks = [process_file(f) for f in files] results = await asyncio.gather(*tasks, return_exceptions=True) # Фильтрация исключений results = [r if not isinstance(r, Exception) else {"error": str(r)} for r in results] # Сохранение результатов if output: PathLib(output).write_text( json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8' ) console.print(f"\n[blue]💾 Results saved to: {output}[/blue]") # Итог total_functions_processed = sum(len(r.get("functions", [])) for r in results if isinstance(r, dict)) total_functions_succeeded = sum( sum(1 for f in r.get("functions", []) if "error" not in f) for r in results if isinstance(r, dict) ) console.print(Panel( f"Files: {completed_files} completed, {failed_files} failed\n" f"Functions: {total_functions_succeeded}/{total_functions_processed} succeeded", title="Batch Complete", border_style="green" if failed_files == 0 else "yellow" )) # ============================================================================ # Quality Pipeline CLI Commands # ============================================================================ @app.command(name="coverage-generate-all") def coverage_generate_all( project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных функций (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Генерация unit-тестов для непокрытых функций.""" asyncio.run( _coverage_generate_all( project_dir=project_dir, merge_env=merge_env, concurrency=concurrency, model=model, output=output, ) ) @app.command(name="lint-fix-all") def lint_fix_all( project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных функций (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Исправление lint issues (ruff --fix + LLM).""" asyncio.run( _lint_fix_all( project_dir=project_dir, merge_env=merge_env, concurrency=concurrency, model=model, output=output, ) ) @app.command(name="quality-pipeline") def quality_pipeline_cmd( project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ), steps: str = typer.Option( "tests,lint,fix", "--steps", help="Шаги через запятую: tests,lint,fix" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных функций (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Единый пайплайн: тесты → линтинг → фикс.""" steps_list = [s.strip() for s in steps.split(",") if s.strip()] asyncio.run( _quality_pipeline_run( project_dir=project_dir, merge_env=merge_env, steps=steps_list, concurrency=concurrency, model=model, output=output, ) ) @app.command(name="generate-tests") def generate_tests( file_path: str = typer.Argument( ..., help="Путь к Python файлу для генерации тестов" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ), mode: str = typer.Option( "apply", "--mode", "-m", help="Режим: preview (показать без записи) или apply (записать в tests/.gen/)" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных функций (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Генерация unit-тестов для непокрытых функций одного файла.""" asyncio.run( _generate_tests_for_file( file_path=file_path, project_dir=project_dir, merge_env=merge_env, mode=mode, concurrency=concurrency, model=model, output=output, ) ) @app.command(name="lint-fix") def lint_fix( file_path: str = typer.Argument( ..., help="Путь к Python файлу для исправления lint issues" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ), mode: str = typer.Option( "apply", "--mode", "-m", help="Режим: preview (показать diff без записи) или apply (записать исправления)" ), concurrency: int = typer.Option( 1, "--concurrency", "-c", help="Количество параллельных функций (семафор)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения результатов (JSON)" ) ): """Исправление lint issues одного файла (ruff --fix + LLM).""" asyncio.run( _lint_fix_for_file( file_path=file_path, project_dir=project_dir, merge_env=merge_env, mode=mode, concurrency=concurrency, model=model, output=output, ) ) async def _docstring_generate( file_path: str, mode: str, model: Optional[str], all_approved: bool, output: Optional[str], project_dir: Optional[str] = None, merge_env: bool = False ): """Генерация докстрингов для файла.""" from pathlib import Path as PathLib import libcst as cst from src.services.docstring_pipe import CodeContextExtractor, DocstringQualityChecker # Определение корня проекта project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загрузка .env из целевого проекта env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) path = PathLib(file_path).resolve() if not path.is_absolute(): path = project_root / file_path if not path.exists(): console.print(f"[red]File not found: {file_path}[/red]") raise typer.Exit(1) # Мультиязычная проверка — поддерживаемые расширения SOURCE_EXTENSIONS = {'.py', '.pyi', '.lua', '.ts', '.tsx', '.js', '.jsx'} if not any(str(path).endswith(ext) for ext in SOURCE_EXTENSIONS): console.print(f"[red]Not a supported source file: {file_path}[/red]") console.print(f"[yellow]Supported: {', '.join(sorted(SOURCE_EXTENSIONS))}[/yellow]") raise typer.Exit(1) console.print(f"[cyan]📁 Project root: {project_root}[/cyan]") console.print("[cyan]⏳ Loading heuristics...[/cyan]") # Загрузка эвристик из целевого проекта heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): import json heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) console.print(f"[green]✓ Heuristics loaded from target project: {len(heuristics.get('architectural_zones', []))} zones[/green]") else: console.print("[yellow]⚠ Heuristics not found in target project, using defaults[/yellow]") console.print(f"[cyan]⏳ Parsing {path.relative_to(project_root) if str(path).startswith(str(project_root)) else path}...[/cyan]") # Инициализация SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") console.print(f"[cyan]⏳ Connecting to LLM ({base_url})...[/cyan]") smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url ) # Парсинг файла source_code = path.read_text(encoding="utf-8") module = cst.parse_module(source_code) console.print("[cyan]⏳ Extracting function context...[/cyan]") # Извлечение контекста context_extractor = CodeContextExtractor(module, path) # Сбор функций, нуждающихся в докстрингах functions_to_process = [] class FunctionCollector(cst.CSTVisitor): processed_count: int = 0 def visit_FunctionDef(self, node: cst.FunctionDef) -> None: self.processed_count += 1 if self.processed_count % 10 == 0: console.print(f" Scanned {self.processed_count} functions...") if DocstringQualityChecker.needs_docstring(node, heuristics): func_code = cst.Module([]).code_for_node(node) func_context = context_extractor.extract_for_function(node) functions_to_process.append({ "name": node.name.value, "code": func_code, "context": func_context, "node": node }) collector = FunctionCollector() module.visit(collector) console.print(f"[green]✓ Scanned {collector.processed_count} functions, {len(functions_to_process)} need docstrings[/green]") if not functions_to_process: console.print("[green]✓ All functions have adequate docstrings[/green]") return console.print(Panel( f"Found {len(functions_to_process)} function(s) needing docstrings", title="Docstring Generation", border_style="blue" )) # Инициализация генератора generator = DocstringLLMGenerator( client=smart_client, model=model, heuristics=heuristics ) results = [] if mode == "preview": # Режим превью с одобрением for idx, func in enumerate(functions_to_process, 1): console.print(f"\n[yellow][{idx}/{len(functions_to_process)}] Generating preview for: {func['name']}[/yellow]") try: preview = await generator.generate_docstring_preview( function_name=func["name"], function_code=func["code"], context=func["context"] ) if all_approved: preview["approved"] = True # Показ превью console.print(Panel( f"[bold]Original:[/bold]\n{preview.get('original_docstring', 'None')}\n\n" f"[bold]Generated:[/bold]\n{preview['generated_docstring']}\n\n" f"[bold]Quality:[/bold] {preview['quality_score']:.0f}/100\n" f"[bold]Approved:[/bold] {'Yes' if preview['approved'] else 'No'}", title=f"{func['name']}", border_style="green" if preview['approved'] else "yellow" )) results.append(preview) except Exception as e: console.print(f"[red]Error: {e}[/red]") results.append({ "function_name": func["name"], "error": str(e), "approved": False }) elif mode == "apply": # Режим прямого применения for idx, func in enumerate(functions_to_process, 1): console.print(f"\n[yellow][{idx}/{len(functions_to_process)}] Generating docstring for: {func['name']}[/yellow]") try: result = await generator.generate_docstring( function_name=func["name"], function_code=func["code"], context=func["context"] ) results.append({ "function_name": func["name"], "docstring": result.docstring_content, "quality_score": result.quality_score, "applied": True }) console.print(f"[green]✓ Generated (quality: {result.quality_score:.0f}/100)[/green]") except Exception as e: console.print(f"[red]Error: {e}[/red]") results.append({ "function_name": func["name"], "error": str(e), "applied": False }) else: console.print(f"[red]Unknown mode: {mode} (use 'preview' or 'apply')[/red]") raise typer.Exit(1) # Сохранение результатов if output: PathLib(output).write_text( json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8' ) console.print(f"\n[blue]💾 Results saved to: {output}[/blue]") # Итог approved_count = sum(1 for r in results if r.get("approved") or r.get("applied")) console.print(f"\n[green]✓ Generated {approved_count}/{len(functions_to_process)} docstrings[/green]") async def _handle_stream_event(event): """Обработчик событий стриминга.""" if event.type == StreamEventType.TEXT: if event.content: console.print(event.content, end="") elif event.type == StreamEventType.TOOL_CALL_START: console.print(f"\n[yellow]🔧 Calling tool: {event.tool_name}[/yellow]") if event.tool_args: console.print(f" Args: {json.dumps(event.tool_args, indent=2, ensure_ascii=False)}") elif event.type == StreamEventType.TOOL_RESULT: console.print(f"[green]✓ Tool {event.tool_name} completed[/green]") elif event.type == StreamEventType.TOOL_ERROR: console.print(f"[red]✗ Tool {event.tool_name} failed: {event.error}[/red]") elif event.type == StreamEventType.DONE: console.print() # Новая строка elif event.type == StreamEventType.ERROR: console.print(f"\n[red]✗ Error: {event.error}[/red]") @app.command() def run( context: str = typer.Option( None, "--context", "-c", help="Путь к .md файлу с контекстом агента" ), query: Optional[str] = typer.Argument( None, help="Запрос для обработки" ), model: str = typer.Option( os.getenv("EVAL_MODEL","gpt-4"), "--model", "-m", help="Модель LLM" ), save: Optional[str] = typer.Option( None, "--save", "-s", help="Путь для сохранения сессии (JSON)" ), enable_mcp: bool = typer.Option( False, "--mcp", help="Включить MCP инструменты" ), mcp_url: str = typer.Option( "http://localhost:8000", "--mcp-url", help="URL MCP сервера" ), interactive: bool = typer.Option( False, "--interactive", "-i", help="Интерактивный режим (чат)" ) ): """Запустить агент с контекстом из .md файла.""" asyncio.run( _run_agent( context=context, query=query, model=model, save_path=save, enable_mcp=enable_mcp, mcp_url=mcp_url, interactive=interactive ) ) @app.command() def chat( model: str = typer.Option( os.getenv('EVAL_MODEL',"gpt-4"), "--model", "-m", help="Модель LLM" ), context: Optional[str] = typer.Option( None, "--context", "-c", help="Путь к .md файлу с контекстом" ), save: Optional[str] = typer.Option( None, "--save", "-s", help="Путь для сохранения сессии" ), enable_mcp: bool = typer.Option( True, "--mcp", help="Включить MCP инструменты" ), mcp_url: str = typer.Option( "http://localhost:8000", "--mcp-url", help="URL MCP сервера" ), start_mcp_server: bool = typer.Option( True, "--start-mcp-server/--no-start-mcp-server", help="Автоматически запускать MCP сервер подпроцессом в project-dir" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта" ) ): """Интерактивный чат с LLM и динамическим определением ролей.""" asyncio.run( _chat_agent( model=model, context=context, save_path=save, enable_mcp=enable_mcp, mcp_url=mcp_url, start_mcp_server=start_mcp_server, project_dir=project_dir, merge_env=merge_env, ) ) @app.command() def tools( enable_mcp: bool = typer.Option( False, "--mcp", help="Показать MCP инструменты" ), mcp_url: str = typer.Option( "http://localhost:8000", "--mcp-url", help="URL MCP сервера" ) ): """Показать доступные инструменты.""" asyncio.run( _list_tools( enable_mcp=enable_mcp, mcp_url=mcp_url ) ) async def _run_agent( context: Optional[str], query: Optional[str], model: str, save_path: Optional[str], enable_mcp: bool, mcp_url: str, interactive: bool ): """Основная логика запуска агента.""" # Загрузка контекста agent_context = None if context: loader = ContextLoader() try: agent_context = loader.load(context) console.print(Panel( f"**Agent:** {agent_context.name}\n" f"**Description:** {agent_context.description}\n" f"**Tools:** {', '.join(agent_context.tools)}", title="Context Loaded", border_style="green" )) except Exception as e: console.print(f"[red]Error loading context: {e}[/red]") raise typer.Exit(1) # Инициализация SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url ) # Инициализация оркестратора orchestrator = await create_orchestrator( context=agent_context, enable_mcp=enable_mcp, mcp_url=mcp_url ) # Создание streaming клиента streaming_client = LLMStreamingClient( smart_client=smart_client, model=model, tool_registry=orchestrator.registry, on_event=_handle_stream_event ) # Формирование сообщений messages = [] if agent_context: messages.append({ "role": "system", "content": f"You are {agent_context.name}. {agent_context.description}\n\n{agent_context.prompt}" }) if query: messages.append({ "role": "user", "content": query }) elif interactive: # Интерактивный режим — запрос у пользователя query = console.input("\n[yellow]Query:[/yellow] ") if not query.strip(): console.print("[red]Empty query[/red]") raise typer.Exit(1) messages.append({ "role": "user", "content": query }) else: console.print("[red]No query provided. Use --interactive or provide a query argument.[/red]") raise typer.Exit(1) # Запуск console.print(Panel( f"Model: {model}\nStreaming with tool calls...", title="Agent Running", border_style="blue" )) try: # Выполнение с tool_calls final_messages = await streaming_client.run( messages=messages, tools=orchestrator.registry.list_tools() ) console.print("\n[green]✓ Session completed[/green]") # Сохранение результатов if save_path: session_data = { "messages": final_messages, "model": model } Path(save_path).write_text( json.dumps(session_data, indent=2, ensure_ascii=False), encoding='utf-8' ) console.print(f"[blue]💾 Session saved to: {save_path}[/blue]") except ToolCallError as e: console.print(f"\n[red]✗ Tool call error: {e}[/red]") raise typer.Exit(1) except Exception as e: console.print(f"\n[red]✗ Error: {e}[/red]") raise typer.Exit(1) async def _chat_agent( model: str, context: Optional[str], save_path: Optional[str], enable_mcp: bool, mcp_url: str, start_mcp_server: bool, project_dir: Optional[str] = None, merge_env: bool = False, ): """Интерактивный чат с динамическим определением ролей. Поддерживает: - Обычный чат (LLM без инструментов) - Команды: /plan, /execute, /review, /status, /approve, /reject, /help - Полный цикл: orchestrator → classifier → strategist → executor → reviewer - Интерактивное одобрение каждого шага """ from pathlib import Path as PathLib from src.cli_agent.role_router import RoleRouter, ExecutionMode, ChatCommand from src.cli_agent.interactive_executor import InteractiveExecutor, ExecutionStep # Настройка логирования в файл logs_dir = PathLib(__file__).resolve().parents[2] / "logs" logs_dir.mkdir(exist_ok=True) log_file = logs_dir / f"cli_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" file_handler = logging.FileHandler(log_file, encoding='utf-8') file_handler.setFormatter(logging.Formatter( '%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%H:%M:%S' )) file_handler.setLevel(logging.DEBUG) # Логируем в файлы: cli_agent, interactive_executor, role_router, architecture_analyzer, mcp_bridge for logger_name in [ 'src.cli_agent.cli', 'src.cli_agent.interactive_executor', 'src.cli_agent.role_router', 'src.cli_agent.architecture_analyzer', 'src.cli_agent.tools.mcp_bridge', ]: lg = logging.getLogger(logger_name) lg.addHandler(file_handler) lg.setLevel(logging.DEBUG) # Определение корня проекта project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загрузка .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) # Загрузка контекста агента agent_context = None if context: loader = ContextLoader() try: agent_context = loader.load(context) console.print(f"[green]✓ Context loaded: {agent_context.name}[/green]") except Exception as e: console.print(f"[red]Error loading context: {e}[/red]") raise typer.Exit(1) # Инициализация SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url ) # Инициализация RoleRouter role_router = RoleRouter( smart_client=smart_client, project_root=str(project_root), ) # Инициализация InteractiveExecutor executor_mcp_manager = None actual_mcp_url = None # Запуск MCP сервера если включено if start_mcp_server: from src.cli_agent.mcp_subprocess_manager import MCPSubprocessManager console.print(f"[cyan]🚀 Starting MCP Server for project: {project_root}...[/cyan]") console.print(f" Logs will be saved to: {project_root / 'logs'}") executor_mcp_manager = MCPSubprocessManager( project_root=str(project_root), merge_env=merge_env, startup_timeout=60, ) try: await executor_mcp_manager.start() actual_mcp_url = executor_mcp_manager.url console.print(f"[green]✓ MCP Server running at {actual_mcp_url}[/green]") except Exception as e: console.print(f"[yellow]⚠ MCP Server startup failed: {e}[/yellow]") console.print("[yellow] Continuing without MCP server...[/yellow]") executor_mcp_manager = None actual_mcp_url = None elif enable_mcp: # Используем переданный mcp_url без запуска actual_mcp_url = mcp_url executor = InteractiveExecutor( smart_client=smart_client, project_root=str(project_root), mcp_url=actual_mcp_url, model=model, ) # История сообщений messages = [] if agent_context: messages.append({ "role": "system", "content": f"You are {agent_context.name}. {agent_context.description}\n\n{agent_context.prompt}" }) # Состояние выполнения current_execution: Optional[Any] = None # Текущий ExecutionResult current_step: Optional[ExecutionStep] = None console.print(Panel( f"Interactive Chat Mode with Dynamic Role Routing\n" f"Model: {model}\n" f"Project: {project_root}\n\n" f"**Commands:**\n" f" `/plan <query>` — Полный цикл: strategist → executor → reviewer\n" f" `/execute <query>` — Только executor\n" f" `/review` — Проверить последние изменения\n" f" `/explore <query>` — Исследовать кодовую базу (поиск дубликатов)\n" f" `/explain <query>` — Объяснить код (как работает функция/модуль)\n" f" `/arch [модуль]` — Анализ архитектуры (модуля или проекта)\n" f" `/status` — Прогресс текущего плана\n" f" `/approve` — Одобрить текущий шаг\n" f" `/reject <reason>` — Вернуть на доработку\n" f" `/help` — Показать команды\n\n" f"Type 'exit' or 'quit' to exit, 'save <path>' to save session", title="Chat", border_style="cyan" )) # Цикл чата while True: try: # Запрос у пользователя — пробуем с автодополнением try: from src.cli_agent.interactive_input import cli_input_with_completion user_input = await cli_input_with_completion() except ImportError: # Fallback на простой ввод user_input = console.input("\n[yellow]You:[/yellow] ") if not user_input.strip(): continue if user_input.lower() in ['exit', 'quit', 'q']: console.print("[blue]Goodbye![/blue]") break # Команда сохранения if user_input.startswith('save '): save_path = user_input[5:].strip() session_data = { "messages": messages, "model": model } Path(save_path).write_text( json.dumps(session_data, indent=2, ensure_ascii=False), encoding='utf-8' ) console.print(f"[green]✓ Saved to {save_path}[/green]") continue # Определение роли через RoleRouter role_plan = await role_router.route(user_input) # Обработка команд без выполнения if role_plan.mode == ExecutionMode.COMMAND: if role_plan.command in (ChatCommand.STATUS, ChatCommand.HELP): # Показываем информацию info_text = role_router.format_plan_for_user(role_plan) console.print(f"\n[cyan]{info_text}[/cyan]") continue elif role_plan.command == ChatCommand.APPROVE: # Одобрение текущего шага logger.info( f"[CLI] Получена команда /approve, " f"_current_step={executor._current_step.step_id if executor._current_step else None}, " f"статус={executor._current_step.status.value if executor._current_step else None}" ) if executor._current_step and executor._current_step.status.value == "waiting_approval": logger.info(f"[CLI] Вызываю executor.approve_step()") executor.approve_step() continue else: logger.warning(f"[CLI] Нет ожидающего шага — _current_step={executor._current_step}") console.print("[yellow]⚠ Нет ожидающего одобрения шага[/yellow]") continue elif role_plan.command == ChatCommand.REJECT: # Отклонение текущего шага logger.info( f"[CLI] Получена команда /reject, query={role_plan.query!r}, " f"_current_step={executor._current_step.step_id if executor._current_step else None}" ) if executor._current_step and executor._current_step.status.value == "waiting_approval": reason = role_plan.query or "Не указано" logger.info(f"[CLI] Вызываю executor.reject_step(reason={reason!r})") executor.reject_step(reason) continue else: logger.warning(f"[CLI] Нет ожидающего шага для reject") console.print("[yellow]⚠ Нет ожидающего одобрения шага[/yellow]") continue # Обычный чат (без ролей) if not role_plan.roles: # Добавление сообщения messages.append({ "role": "user", "content": user_input }) # Стриминг ответа console.print("\n[cyan]Assistant:[/cyan]") llm_client = LLMClient(model=model) accumulated_text = "" async for event in llm_client.chat_stream(messages=messages): if event.type == "text" and event.content: accumulated_text += event.content console.print(event.content, end="") elif event.type == "done": messages.append({ "role": "assistant", "content": event.content }) console.print() break elif event.type == "error": console.print(f"\n[red]Error: {event.error}[/red]") break continue # Выполнение плана через InteractiveExecutor console.print(f"\n[blue]📋 {role_router.format_plan_for_user(role_plan)}[/blue]") # Если требуется одобрение — ждём if role_plan.requires_user_approval: console.print(f"\n[yellow]⏳ Введите `/approve` для начала или `/reject <причина>` для отмены[/yellow]") # Ждём команду approve/reject approved = False rejected = False reject_reason = "" while True: cmd_input = console.input("\n[yellow]Command:[/yellow] ") if cmd_input.lower() in ['exit', 'quit']: console.print("[blue]Отмена выполнения[/blue]") rejected = True break if cmd_input.strip() == '/approve': console.print("[green]✓ Выполнение одобрено[/green]") approved = True break if cmd_input.startswith('/reject'): reject_reason = cmd_input[8:].strip() or "Не указано" console.print(f"[yellow]✗ Выполнение отклонено: {reject_reason}[/yellow]") rejected = True break console.print("[yellow]⚠ Введите `/approve` или `/reject <причина>`[/yellow]") if rejected: continue if not approved: continue # Запуск выполнения async def on_step_complete(step: ExecutionStep): """Callback после каждого шага.""" nonlocal current_step current_step = step # Показываем результат шага if step.result: console.print(Panel( f"**Шаг завершён:** {step.role.value}\n" f"Статус: {step.status.value}\n" f"Результат: {json.dumps(step.result, indent=2, ensure_ascii=False)[:500]}", title="Step Complete", border_style="green" )) execution_result = await executor.execute_with_approval( role_plan=role_plan, on_step_complete=on_step_complete, ) current_execution = execution_result # Итог выполнения console.print(Panel( execution_result.to_summary(), title="Execution Complete", border_style="green" if execution_result.success else "red" )) # Добавляем результат в историю сообщений messages.append({ "role": "user", "content": user_input }) messages.append({ "role": "assistant", "content": execution_result.to_summary() }) except KeyboardInterrupt: console.print("\n[yellow]Interrupted by user[/yellow]") break # Остановка MCP сервера если запущен if executor_mcp_manager: console.print("[cyan]🛑 Stopping MCP Server...[/cyan]") await executor_mcp_manager.stop() console.print("[green]✓ MCP Server stopped[/green]") async def _list_tools(enable_mcp: bool, mcp_url: str): registry = ToolRegistry() registry.register_builtins() tools = registry.list_tools() console.print(Panel( f"Available Tools: {len(tools)}", title="Local Tools", border_style="green" )) for tool in tools: console.print(f" [bold]{tool['name']}[/bold]: {tool['description']}") # MCP инструменты if enable_mcp: try: from .tools.mcp_bridge import StreamableHTTPMCPBridge as MCPBridge bridge = MCPBridge(mcp_url) result = await bridge.connect() if result["success"]: console.print(Panel( f"MCP Tools: {result['count']}", title=f"MCP ({mcp_url})", border_style="blue" )) for tool_name in result["tools"]: console.print(f" [bold]{tool_name}[/bold]") await bridge.disconnect() except Exception as e: console.print(f"[red]MCP connection failed: {e}[/red]") def main(): """Точка входа.""" app() # ============================================================================ # Quality Pipeline Implementations # ============================================================================ async def _coverage_generate_all( project_dir: Optional[str], merge_env: bool, concurrency: int, model: Optional[str], output: Optional[str], ): """Генерация unit-тестов для непокрытых функций.""" from src.cli_agent.quality.pipeline import QualityPipeline project_root = Path(project_dir).resolve() if project_dir else Path.cwd() # Загрузка .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(Panel( f"Project: {project_root}\nConcurrency: {concurrency}", title="Coverage Test Generation", border_style="blue" )) smart_client = AsyncSmartOpenAI( api_key=os.getenv("OPENAI_API_KEY", ""), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") ) # Загрузка эвристик heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) pipeline = QualityPipeline( project_root=str(project_root), client=smart_client, heuristics=heuristics, model=model, concurrency=concurrency, ) result = await pipeline.run(steps=["tests"], output=output) console.print(f"\n[bold]Result:[/bold]") console.print(result.summary) async def _lint_fix_all( project_dir: Optional[str], merge_env: bool, concurrency: int, model: Optional[str], output: Optional[str], ): """Исправление lint issues.""" from src.cli_agent.quality.pipeline import QualityPipeline project_root = Path(project_dir).resolve() if project_dir else Path.cwd() env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(Panel( f"Project: {project_root}\nConcurrency: {concurrency}", title="Lint Fix", border_style="blue" )) smart_client = AsyncSmartOpenAI( api_key=os.getenv("OPENAI_API_KEY", ""), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") ) heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) pipeline = QualityPipeline( project_root=str(project_root), client=smart_client, heuristics=heuristics, model=model, concurrency=concurrency, ) result = await pipeline.run(steps=["lint", "fix"], output=output) console.print(f"\n[bold]Result:[/bold]") console.print(result.summary) async def _quality_pipeline_run( project_dir: Optional[str], merge_env: bool, steps: List[str], concurrency: int, model: Optional[str], output: Optional[str], ): """Единый пайплайн.""" from src.cli_agent.quality.pipeline import QualityPipeline project_root = Path(project_dir).resolve() if project_dir else Path.cwd() env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(Panel( f"Project: {project_root}\nSteps: {', '.join(steps)}\nConcurrency: {concurrency}", title="Quality Pipeline", border_style="blue" )) smart_client = AsyncSmartOpenAI( api_key=os.getenv("OPENAI_API_KEY", ""), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") ) heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) pipeline = QualityPipeline( project_root=str(project_root), client=smart_client, heuristics=heuristics, model=model, concurrency=concurrency, ) result = await pipeline.run(steps=steps, output=output) console.print(f"\n[bold]Result:[/bold]") console.print(result.summary) async def _generate_tests_for_file( file_path: str, project_dir: Optional[str], merge_env: bool, mode: str, concurrency: int, model: Optional[str], output: Optional[str], ): """Генерация unit-тестов для непокрытых функций одного файла.""" import libcst as cst from src.cli_agent.quality.coverage_analyzer import CoverageAnalyzer from src.cli_agent.quality.test_generator import TestFunctionGenerator, TestModuleAssembler project_root = Path(project_dir).resolve() if project_dir else Path.cwd() # Загрузка .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) # Резолвим путь к файлу target_file = Path(file_path) if not target_file.is_absolute(): target_file = project_root / target_file target_file = target_file.resolve() if not target_file.exists(): console.print(f"[red]✗ File not found: {target_file}[/red]") raise typer.Exit(1) console.print(Panel( f"File: {target_file}\nConcurrency: {concurrency}", title="Test Generation (Single File)", border_style="blue" )) # Загрузка эвристик heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) # Анализ покрытия console.print("[cyan]📊 Analyzing coverage...[/cyan]") coverage_analyzer = CoverageAnalyzer( project_root=str(project_root), coverage_report_path=str( project_root / ".codecontext" / "reports" / "coverage.json" ), ) coverage_result = coverage_analyzer.analyze() # Находим файл в результатах # Пытаемся вычислить относительный путь, обрабатываем случай когда файл вне project_root try: rel_path = str(target_file.relative_to(project_root)) except ValueError: # Файл не является подпутём project_root — используем абсолютный путь rel_path = str(target_file) file_coverage = None for fc in coverage_result.files: # Сравниваем с обоими форматами путей if fc.file_path == rel_path or fc.file_path == str(target_file): file_coverage = fc break # Если нет данных покрытия — парсим AST и считаем все функции непокрытыми if file_coverage is None: console.print("[yellow]⚠ No coverage data found, analyzing file directly...[/yellow]") source_code = target_file.read_text(encoding="utf-8") module = cst.parse_module(source_code) class FuncExtractor(cst.CSTVisitor): def __init__(self): self.functions = [] def visit_FunctionDef(self, node: cst.FunctionDef): from src.cli_agent.quality.coverage_analyzer import FunctionInfo # Определяем конец функции end_line = getattr(node, "lineno", 1) class LineTracker(cst.CSTVisitor): max_line = 0 def _visit_any(self, n): ln = getattr(n, "lineno", None) if ln and ln > self.max_line: self.max_line = ln tracker = LineTracker() node.body.visit(tracker) end_line = max(tracker.max_line, getattr(node, "lineno", 1)) self.functions.append(FunctionInfo( name=node.name.value, line_start=getattr(node, "lineno", 1), line_end=end_line, )) extractor = FuncExtractor() module.visit(extractor) if not extractor.functions: console.print("[green]✓ No functions found in file[/green]") return # Создаём фейковый FileCoverageResult from src.cli_agent.quality.coverage_analyzer import FileCoverageResult file_coverage = FileCoverageResult( file_path=rel_path, total_lines=len(source_code.splitlines()), covered_lines=set(), uncovered_lines=set(), all_functions=extractor.functions, uncovered_functions=extractor.functions, covered_functions=[], ) console.print(f"[cyan]📝 Found {len(extractor.functions)} function(s) (no coverage data)[/cyan]") # Определяем непокрытые функции # В fallback режиме (нет coverage) все функции считаются непокрытыми uncovered_functions = list(file_coverage.uncovered_functions) if not uncovered_functions: console.print(f"[green]✓ All functions in {rel_path} are covered[/green]") return console.print(f"[cyan]📝 Found {len(uncovered_functions)} uncovered function(s)[/cyan]") # SmartClient smart_client = AsyncSmartOpenAI( api_key=os.getenv("OPENAI_API_KEY", ""), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") ) # Парсинг файла (если ещё не распарсили в fallback) if 'source_code' not in locals(): source_code = target_file.read_text(encoding="utf-8") module = cst.parse_module(source_code) # Подготовка .gen/ директории tests_dir = project_root / "tests" gen_dir = tests_dir / ".gen" if mode == "apply": gen_dir.mkdir(parents=True, exist_ok=True) # Генератор generator = TestFunctionGenerator( client=smart_client, heuristics=heuristics, gen_dir=gen_dir, model=model, ) # Обработка функций console.print(f"\n[yellow]Generating tests for {len(uncovered_functions)} function(s)...[/yellow]") # Семафор semaphore = asyncio.Semaphore(concurrency) results = [] async def generate_test_for_func(func): async with semaphore: func_name = func.name # Извлекаем код функции func_code = None func_context = {"type_semantics": {}, "outgoing_calls": [], "architectural_zone": "unknown"} class FuncExtractor(cst.CSTVisitor): def visit_FunctionDef(self, node: cst.FunctionDef) -> None: nonlocal func_code, func_context if node.name.value == func_name: func_code = module.code_for_node(node) # Простой контекст func_context["complexity_metrics"] = { "param_count": len(node.params.params), "branch_count": 0, "max_nesting": 0, } extractor = FuncExtractor() module.visit(extractor) if func_code is None: console.print(f" [red]✗ {func_name}: not found[/red]") return {"function": func_name, "error": "Function not found"} try: result = await generator.generate( function_name=func_name, function_code=func_code, context=func_context, module_path=rel_path, ) if mode == "preview": # Показываем превью без записи console.print(Panel( f"[bold green]✓ {func_name}[/bold green]\n\n" f"[bold]Generated Test Code:[/bold]\n```python\n{result.test_code}\n```", title=f"Test Preview: {func_name}", border_style="green" )) return {"function": func_name, "status": "preview", "test_code": result.test_code} else: # apply mode — записываем console.print(f" [green]✓ {func_name}[/green]") return {"function": func_name, "gen_file": result.gen_file_path} except Exception as e: console.print(f" [red]✗ {func_name}: {e}[/red]") return {"function": func_name, "error": str(e)} tasks = [generate_test_for_func(func) for func in uncovered_functions] results = await asyncio.gather(*tasks, return_exceptions=True) results = [r if not isinstance(r, Exception) else {"error": str(r)} for r in results] # Сборка тестового модуля (только в apply mode) if mode == "apply": console.print("\n[cyan]🔨 Assembling test module...[/cyan]") assembler = TestModuleAssembler(gen_dir=gen_dir, tests_dir=tests_dir) module_name = target_file.stem assembled = assembler.assemble_module(module_name) if assembled: console.print(f"[green]✓ Test module: {assembled}[/green]") else: console.print("[yellow]⚠ No tests assembled[/yellow]") else: assembled = None console.print(f"\n[yellow]Preview mode — tests not written. Use --mode apply to save.[/yellow]") # Сохранение результатов if output: Path(output).write_text( json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8" ) console.print(f"[blue]💾 Results saved to: {output}[/blue]") # Итог success_count = sum(1 for r in results if "error" not in r) console.print(Panel( f"Generated: {success_count}/{len(uncovered_functions)}\nMode: {mode}", title="Test Generation Complete", border_style="green" if success_count == len(uncovered_functions) else "yellow" )) async def _lint_fix_for_file( file_path: str, project_dir: Optional[str], merge_env: bool, mode: str, concurrency: int, model: Optional[str], output: Optional[str], ): """Исправление lint issues одного файла.""" from src.cli_agent.quality.lint_analyzer import LintAnalyzer from src.cli_agent.quality.lint_fixer import LintFixer project_root = Path(project_dir).resolve() if project_dir else Path.cwd() # Загрузка .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) # Резолвим путь к файлу target_file = Path(file_path) if not target_file.is_absolute(): target_file = project_root / target_file target_file = target_file.resolve() if not target_file.exists(): console.print(f"[red]✗ File not found: {target_file}[/red]") raise typer.Exit(1) console.print(Panel( f"File: {target_file}\nConcurrency: {concurrency}", title="Lint Fix (Single File)", border_style="blue" )) # Загрузка эвристик heuristics_path = project_root / ".codecontext" / "heuristics.json" heuristics = {} if heuristics_path.exists(): heuristics = json.loads(heuristics_path.read_text(encoding="utf-8")) # Линт-анализ (ДО фикса — для отчёта) console.print("[cyan]🔍 Analyzing lint issues...[/cyan]") lint_analyzer = LintAnalyzer(project_root=str(project_root)) analysis_before = await lint_analyzer.analyze(files=[str(target_file)]) if analysis_before.total_issues == 0: console.print("[green]✓ No lint issues found[/green]") return console.print(f"[yellow]📝 Found {analysis_before.total_issues} issue(s) ({analysis_before.fixable_issues} fixable)[/yellow]") # SmartClient smart_client = AsyncSmartOpenAI( api_key=os.getenv("OPENAI_API_KEY", ""), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") ) # В preview mode — читаем оригинал до фикса original_content = None if mode == "preview": original_content = target_file.read_text(encoding="utf-8") # Фиксер lint_fixer = LintFixer( project_root=str(project_root), client=smart_client, heuristics=heuristics, model=model, ) # Фикс файла (ruff --fix + LLM) result = await lint_fixer.fix_file(file_path=str(target_file)) # В preview mode — показываем diff и восстанавливаем оригинал if mode == "preview": fixed_content = target_file.read_text(encoding="utf-8") # Восстанавливаем оригинал target_file.write_text(original_content, encoding="utf-8") # Генерируем diff import difflib original_lines = original_content.splitlines(keepends=True) fixed_lines = fixed_content.splitlines(keepends=True) diff = list(difflib.unified_diff( original_lines, fixed_lines, fromfile=f"a/{target_file.name}", tofile=f"b/{target_file.name}", n=3 )) if diff: diff_text = "".join(diff) console.print(Panel( f"```diff\n{diff_text}\n```", title=f"Lint Fix Preview: {target_file.name}", border_style="green" )) else: console.print("[yellow]⚠ No changes would be made (ruff --fix had no effect)[/yellow]") result_dict = { "file": str(target_file), "issues_before": analysis_before.total_issues, "issues_after": result.issues_after, "issues_fixed": len(result.issues_fixed), "issues_remaining": len(result.issues_remaining), "mode": "preview", "success": result.success, } else: # apply mode result_dict = { "file": str(target_file), "issues_before": analysis_before.total_issues, "issues_after": result.issues_after, "issues_fixed": len(result.issues_fixed), "issues_remaining": len(result.issues_remaining), "fix_method": result.fix_method, "success": result.success, } # Сохранение результатов if output: Path(output).write_text( json.dumps(result_dict, indent=2, ensure_ascii=False), encoding="utf-8" ) console.print(f"[blue]💾 Results saved to: {output}[/blue]") # Итог border_style = "green" if result.success else "yellow" mode_label = f" (mode: {mode})" if mode == "preview" else "" console.print(Panel( f"Issues before: {analysis_before.total_issues}\n" f"Issues after: {result.issues_after}\n" f"Fixed: {len(result.issues_fixed)}\n" f"Method: {result.fix_method}{mode_label}\n" f"Success: {'✅' if result.success else '❌'}", title="Lint Fix Complete", border_style=border_style )) # ============================================================================ # Swarm Plan CLI # ============================================================================ @app.command(name="swarm-plan") def swarm_plan( query: str = typer.Argument( ..., help="Описание задачи для планирования" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта (target имеет приоритет)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), max_questions: int = typer.Option( 5, "--max-questions", help="Максимум уточняющих вопросов" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Путь для сохранения плана (JSON)" ), ): """ Интерактивное планирование с роём субагентов. Workflow: 1. Classifier определяет роли и контекст 2. ClarificationLoop задаёт уточняющие вопросы 3. Planner генерирует план задач с DAG 4. PlanEditor показывает таблицу для редактирования Примеры: uv run cli-agent swarm-plan "Добавь валидацию email" uv run cli-agent swarm-plan "Реализуй кэширование" --model gpt-4o uv run cli-agent swarm-plan "Добавь аутентификацию" --project-dir /path/to/project """ asyncio.run( _swarm_plan( query=query, project_dir=project_dir, merge_env=merge_env, model=model, max_questions=max_questions, output=output, ) ) async def _swarm_plan( query: str, project_dir: Optional[str], merge_env: bool, model: Optional[str], max_questions: int, output: Optional[str], ): """Реализация swarm-plan.""" from pathlib import Path as PathLib from src.swarm.classifier import classify_request from src.swarm.planner import plan_request from src.swarm.plan_editor import PlanEditor from src.swarm.clarification_loop import ( ClarificationLoop, ClarificationPhase, DEFAULT_MAX_QUESTIONS, ) # Определяем корень проекта project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загружаем .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(f"[cyan]📁 Project root: {project_root}[/cyan]") # Инициализируем SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") if not api_key: console.print("[red]✗ OPENAI_API_KEY не установлен[/red]") console.print("[yellow]Установите OPENAI_API_KEY в .env файле или окружении[/yellow]") raise typer.Exit(1) smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url, ) # Шаг 1: Classification console.print("\n[bold cyan]⚡ Шаг 1: Классификация запроса[/bold cyan]") console.print(f" Запрос: {query}") try: classification = await classify_request( query=query, smart_client=smart_client, model=model, build_context=True, existing_files=[], # TODO: можно передать список файлов ) except Exception as e: console.print(f"[red]✗ Ошибка классификации: {e}[/red]") raise typer.Exit(1) # Показываем результат классификации roles_str = ", ".join( f"{r.role} ({r.confidence:.2f})" for r in classification.roles ) console.print(f" Роли: {roles_str}") console.print(f" Primary: {classification.primary_role}") if classification.task_context: ctx = classification.task_context if ctx.target_files: files_str = ", ".join(fc.path for fc in ctx.target_files[:5]) console.print(f" Файлы: {files_str}") if ctx.requires_strategist: console.print(f" [yellow]Требуется Strategist: Да[/yellow]") # Шаг 2: Clarification (Classification фаза) console.print("\n[bold cyan]💬 Шаг 2: Уточнение (Classification)[/bold cyan]") clar_loop = ClarificationLoop( smart_client=smart_client, model=model, console=console, max_questions=max_questions, ) try: clarification = await clar_loop.run( classification, phase=ClarificationPhase.CLASSIFICATION, ) except Exception as e: console.print(f"[red]✗ Ошибка уточнения: {e}[/red]") raise typer.Exit(1) # Обновляем контекст if clarification.updated_context: classification.task_context = clarification.updated_context # Шаг 3: Planning console.print("\n[bold cyan]📋 Шаг 3: Генерация плана[/bold cyan]") try: plan = await plan_request( classification, project_root=str(project_root), validate=True, ) except Exception as e: console.print(f"[red]✗ Ошибка планирования: {e}[/red]") raise typer.Exit(1) console.print(f" Задач: {plan.task_count}") if plan.dag.edges: console.print(f" Зависимостей: {len(plan.dag.edges)}") # Шаг 4: Interactive Edit console.print("\n[bold cyan]✏️ Шаг 4: Редактирование плана[/bold cyan]") editor = PlanEditor(plan, console=console) try: result = await editor.interactive_edit() except Exception as e: console.print(f"[red]✗ Ошибка редактирования: {e}[/red]") raise typer.Exit(1) if result is None: console.print("\n[yellow]❌ План отменён пользователем[/yellow]") raise typer.Exit(0) # План утверждён console.print(f"\n[bold green]✅ План утверждён![/bold green]") console.print(f" Plan ID: {result.plan_id}") console.print(f" Задач: {result.task_count}") # Сохраняем план if output: plan_dict = result.model_dump() PathLib(output).write_text( json.dumps(plan_dict, indent=2, ensure_ascii=False), encoding="utf-8", ) console.print(f" [blue]💾 План сохранён: {output}[/blue]") console.print("\n[green]✅ План готов! Используйте:[/green]") console.print(f" [bold]uv run cli-agent swarm-execute {output or '<plan.json>'}[/bold]") # ============================================================================ # Swarm Execute CLI Command # ============================================================================ @app.command(name="swarm-execute") def swarm_execute( plan_file: str = typer.Argument( ..., help="Путь к JSON файлу плана (результат swarm-plan)" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта (target имеет приоритет)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), ): """ Выполнение плана роя субагентов. Загружает JSON-план (результат swarm-plan) и выполняет задачи в порядке DAG зависимостей. Workflow: 1. Загрузка плана из JSON 2. Для каждой задачи (в порядке DAG): - Генерация .md файла субагента - Вызов LLM с системным промптом - Tool calls через ToolOrchestrator (MCP + fallback) - Стриминг прогресса в реальном времени 3. Итоговый отчёт Примеры: uv run cli-agent swarm-execute plan.json uv run cli-agent swarm-execute /tmp/plan_20260410.json --model gpt-4o uv run cli-agent swarm-execute plan.json --project-dir /path/to/project """ asyncio.run( _swarm_execute( plan_file=plan_file, project_dir=project_dir, merge_env=merge_env, model=model, ) ) async def _swarm_execute( plan_file: str, project_dir: Optional[str], merge_env: bool, model: Optional[str], ): """Реализация swarm-execute.""" from pathlib import Path as PathLib from src.swarm.planner import TaskPlan from src.swarm.worker_executor import WorkerExecutor, TaskResult from src.swarm.progress_tracker import PlanProgress from src.cli_agent.llm_streaming_client import StreamEvent, StreamEventType # Определяем корень проекта project_root = PathLib(plan_file).resolve().parent if not project_dir else PathLib(project_dir).resolve() if PathLib(plan_file).is_absolute(): plan_path = PathLib(plan_file) else: plan_path = PathLib.cwd() / plan_file # Загружаем .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(f"[cyan]📁 Project root: {project_root}[/cyan]") console.print(f"[cyan]📋 Plan file: {plan_path}[/cyan]") # Загружаем план if not plan_path.exists(): console.print(f"[red]✗ Файл плана не найден: {plan_path}[/red]") raise typer.Exit(1) try: plan_data = json.loads(plan_path.read_text(encoding="utf-8")) plan = TaskPlan(**plan_data) except Exception as e: console.print(f"[red]✗ Ошибка загрузки плана: {e}[/red]") raise typer.Exit(1) console.print(Panel( f"[bold]План:[/bold] {plan.original_query}\n" f"Plan ID: {plan.plan_id}\n" f"Задач: {plan.task_count}\n" f"Зависимостей: {len(plan.dag.edges)}", title="Выполнение плана", border_style="blue", )) # Инициализируем SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") if not api_key: console.print("[red]✗ OPENAI_API_KEY не установлен[/red]") raise typer.Exit(1) smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url, ) # Rich live display для стриминга from rich.live import Live from rich.layout import Layout from rich.text import Text current_task_display = "" streaming_text = "" progress_lines: list[str] = [] def make_layout() -> Layout: """Создаёт layout для отображения прогресса.""" layout = Layout() layout.split_column( Layout(name="header", size=3), Layout(name="body", ratio=1), Layout(name="footer", size=3), ) # Header layout["header"].update(Panel( f"[bold]{plan.original_query}[/bold]\n" f"Plan: {plan.plan_id}", title="Swarm Execution", border_style="blue", )) # Body: текущая задача + стриминг body_text = Text() if current_task_display: body_text.append(f"\n{current_task_display}\n", style="bold cyan") if streaming_text: # Показываем последние 20 строк стриминга lines = streaming_text.split("\n")[-20:] body_text.append("\n".join(lines), style="dim") layout["body"].update(Panel(body_text, title="Progress", border_style="yellow")) # Footer: общий прогресс progress = tracker.get_progress() if 'tracker' in dir() else {} pct = progress.get("progress_percent", 0) completed = progress.get("completed_tasks", 0) total = progress.get("total_tasks", 0) layout["footer"].update(Panel( f"[bold]Прогресс:[/bold] {completed}/{total} задач ({pct:.1f}%)\n" f"[dim]Нажмите Ctrl+C для отмены[/dim]", border_style="green", )) return layout async def on_event(event: StreamEvent): """Callback для streaming событий.""" nonlocal streaming_text, current_task_display if event.type == StreamEventType.TEXT and event.content: streaming_text += event.content elif event.type == StreamEventType.TOOL_CALL_START: streaming_text += f"\n🔧 [TOOL] {event.tool_name}" elif event.type == StreamEventType.TOOL_RESULT: streaming_text += f" ✓\n" elif event.type == StreamEventType.TOOL_ERROR: streaming_text += f"\n❌ [ERROR] {event.tool_name}: {event.error}\n" elif event.type == StreamEventType.ERROR: streaming_text += f"\n❌ [ERROR] {event.error}\n" async def on_task_start(task_id: str, role: str): """Callback начала задачи.""" nonlocal current_task_display, streaming_text task = next((t for t in plan.tasks if t.task_id == task_id), None) desc = task.description[:60] if task else "" current_task_display = f"[{role.upper()}] Task: {desc}" streaming_text += f"\n{'='*60}\n" streaming_text += f"▶️ START {task_id} ({role}): {desc}\n" streaming_text += f"{'='*60}\n" async def on_task_complete(task_result: TaskResult): """Callback завершения задачи.""" nonlocal streaming_text status_icon = "✅" if task_result.status == "completed" else "❌" streaming_text += ( f"\n{status_icon} {task_result.task_id} " f"→ {task_result.status} ({task_result.duration_ms:.0f}ms)\n" ) if task_result.error: streaming_text += f" Error: {task_result.error}\n" # Создаём executor executor = WorkerExecutor( plan=plan, smart_client=smart_client, model=model, project_root=str(project_root), on_event=on_event, on_task_start=on_task_start, on_task_complete=on_task_complete, ) tracker = executor.progress_tracker # Запускаем с Live display console.print("\n[bold green]🚀 Запуск выполнения...[/bold green]\n") try: with Live(make_layout(), refresh_per_second=4, screen=True) as live: async def update_live(): live.update(make_layout()) # Запускаем executor с периодическим обновлением async def execute_with_updates(): # Периодическое обновление import asyncio async def periodic_update(): while True: await update_live() await asyncio.sleep(0.5) # Запускаем фоновое обновление update_task = asyncio.create_task(periodic_update()) try: result, guardrails_results = await executor.execute() return result, guardrails_results finally: update_task.cancel() await update_live() result, guardrails_results = await execute_with_updates() except KeyboardInterrupt: console.print("\n[yellow]⚠️ Выполнение отменено пользователем[/yellow]") # Сохраняем частичные результаты completed_tasks = [ tr for tr in executor._task_results.values() if tr.status == "completed" ] failed_tasks = [ tr for tr in executor._task_results.values() if tr.status == "failed" ] if completed_tasks or failed_tasks: partial_file = ( project_root / ".codecontext" / f"swarm-partial-{plan.plan_id}.json" ) partial_file.parent.mkdir(parents=True, exist_ok=True) partial_data = { "plan_id": plan.plan_id, "original_query": plan.original_query, "interrupted_at": datetime.now(timezone.utc).isoformat(), "completed_tasks": [tr.to_dict() for tr in completed_tasks], "failed_tasks": [tr.to_dict() for tr in failed_tasks], "completed_count": len(completed_tasks), "failed_count": len(failed_tasks), } partial_file.write_text( json.dumps(partial_data, indent=2, ensure_ascii=False), encoding="utf-8", ) console.print( f"[dim]💾 Частичные результаты сохранены: {partial_file}[/dim]" ) console.print( f"[dim] Выполнено: {len(completed_tasks)}, " f"Провалено: {len(failed_tasks)}[/dim]" ) raise typer.Exit(130) # ======================================================================== # Feedback Loop: анализ результатов и генерация новых задач # ======================================================================== from src.swarm.feedback_loop import FeedbackLoop from src.swarm.worker_executor import ExecutionResult max_feedback_cycles = int(os.getenv("SWARM_FEEDBACK_MAX_CYCLES", "3")) console.print(f"\n[cyan]🔄 Feedback Loop: макс циклов = {max_feedback_cycles}[/cyan]") # Запускаем MCP Server для получения инструментов mcp_tools = {} try: from src.eval.mcp_server_manager import create_eval_mcp_server console.print("[dim]🔌 Подключение MCP Server для инструментов...[/dim]") mcp_manager = await create_eval_mcp_server( project_root=str(project_root), port=None, # Auto-select merge_env=True, ) # Получаем инструменты из bridge if mcp_manager.bridge and mcp_manager.bridge._state.available_tools: # Создаём callable wrappers для каждого инструмента for tool in mcp_manager.bridge._state.available_tools: tool_name = tool["name"] mcp_tools[tool_name] = lambda name=tool_name, **kwargs: ( mcp_manager.bridge.call_tool(name, **kwargs) ) console.print(f"[dim] Доступно MCP инструментов: {len(mcp_tools)}[/dim]") else: console.print("[dim] ⚠ MCP tools не обнаружены, используем fallback[/dim]") except Exception as e: console.print(f"[dim] ⚠ MCP Server недоступен: {e}[/dim]") mcp_manager = None feedback_loop = FeedbackLoop( project_root=str(project_root), smart_client=smart_client, model=model or "gpt-4o-mini", max_cycles=max_feedback_cycles, mcp_tools=mcp_tools, ) # Создаём ExecutionResult для feedback (если ещё не создан) if not isinstance(result, ExecutionResult): exec_result = ExecutionResult( plan_id=plan.plan_id, status=result.status if hasattr(result, "status") else "completed", task_results=result.task_results if hasattr(result, "task_results") else [], total_duration_ms=result.total_duration_ms if hasattr(result, "total_duration_ms") else 0, error=result.error if hasattr(result, "error") else None, ) else: exec_result = result current_plan = plan current_exec_result = exec_result current_guardrails = guardrails_results feedback_cycle = 0 while True: feedback_result = await feedback_loop.analyze_and_generate_tasks( plan=current_plan, execution_result=current_exec_result, guardrails_results=current_guardrails, ) feedback_cycle = feedback_result.cycle_count console.print(f"\n[yellow]📊 Feedback цикл {feedback_cycle}: " f"новых задач = {len(feedback_result.new_tasks)}, " f"причина = {feedback_result.stop_reason}[/yellow]") if feedback_result.cycle_history: for hist in feedback_result.cycle_history: console.print( f" Цикл {hist['cycle']}: " f"failed_retry={hist['failed_retry']}, " f"guardrails_fix={hist['guardrails_fix']}, " f"follow_up={hist['follow_up']}" ) if not feedback_result.has_new_tasks: console.print(f"\n[dim]✅ Feedback Loop завершён: {feedback_result.stop_reason}[/dim]") break # Показываем новые задачи console.print(f"\n[bold]📋 Новые задачи ({len(feedback_result.new_tasks)}):[/bold]") for new_task in feedback_result.new_tasks: console.print( f" • [{new_task.role}] {new_task.task_id}: " f"{new_task.description[:80]}... " f"(reason: {new_task.reason})" ) # Выполняем новый план console.print(f"\n[cyan]🚀 Выполнение нового плана...[/cyan]") # Создаём новый executor для обновлённого плана new_executor = WorkerExecutor( plan=feedback_result.updated_plan, smart_client=smart_client, model=model, project_root=str(project_root), on_event=on_event, on_task_start=on_task_start, on_task_complete=on_task_complete, ) try: with Live(make_layout(), refresh_per_second=4, screen=True) as live: async def execute_new_plan(): import asyncio async def periodic_update(): while True: live.update(make_layout()) await asyncio.sleep(0.5) update_task = asyncio.create_task(periodic_update()) try: new_result, new_guardrails = await new_executor.execute() return new_result, new_guardrails finally: update_task.cancel() live.update(make_layout()) current_exec_result, current_guardrails = await execute_new_plan() current_plan = feedback_result.updated_plan except KeyboardInterrupt: console.print("\n[yellow]⚠️ Выполнение нового плана отменено[/yellow]") break except Exception as e: console.print(f"\n[red]❌ Ошибка выполнения нового плана: {e}[/red]") break # Обновляем result для итогового отчёта result = current_exec_result # Итоговый отчёт console.print() if result.success: console.print(Panel( f"[green]✅ План выполнен![/green]\n\n" f"Задач выполнено: {result.completed_tasks}/{len(result.task_results)}\n" f"Время: {result.total_duration_ms/1000:.1f}с", title="Результат", border_style="green", )) else: failed_info = f"\n❌ Провалено: {result.failed_tasks}" error_info = f"\nОшибка: {result.error}" if result.error else "" console.print(Panel( f"[red]⚠️ План выполнен частично[/red]{failed_info}{error_info}\n\n" f"Выполнено: {result.completed_tasks}/{len(result.task_results)}\n" f"Время: {result.total_duration_ms/1000:.1f}с", title="Результат", border_style="red", )) # Показываем детали по задачам console.print("\n[bold]Детализация по задачам:[/bold]\n") for task_result in result.task_results: status_icon = { "completed": "✅", "failed": "❌", "skipped": "⏭️", }.get(task_result.status, "❓") task = next((t for t in plan.tasks if t.task_id == task_result.task_id), None) desc = task.description[:50] if task else "" console.print( f" {status_icon} [{task_result.role}] {task_result.task_id}: {desc} " f"({task_result.duration_ms:.0f}ms)" ) if task_result.error: console.print(f" [dim]Error: {task_result.error}[/dim]") # ============================================================================ # Swarm Chat (REPL) # ============================================================================ @app.command(name="swarm-chat") def swarm_chat( project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Директория целевого проекта (по умолчанию cwd)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Слить .env из инструмента и целевого проекта (target имеет приоритет)" ), model: Optional[str] = typer.Option( None, "--model", help="Модель LLM" ), strict: bool = typer.Option( False, "--strict", help="Всегда требовать подтверждение плана" ), ): """ Интерактивный REPL-чат с роем субагентов. Workflow для каждого запроса: 1. Classifier → роли 2. ClarificationLoop → уточняющие вопросы 3. Planner → JSON-план 4. [--strict] → подтверждение пользователя 5. WorkerExecutor + Guardrails + FeedbackLoop 6. Итоговый отчёт REPL команды: - /plan <запрос> — только планирование - /status — показать статус сессии - /clear — очистить историю - exit/quit/Ctrl+D — выход Примеры: uv run cli-agent swarm-chat uv run cli-agent swarm-chat --project-dir /path/to/project uv run cli-agent swarm-chat --model gpt-4o --strict """ asyncio.run( _swarm_chat( project_dir=project_dir, merge_env=merge_env, model=model, strict=strict, ) ) async def _swarm_chat( project_dir: Optional[str], merge_env: bool, model: Optional[str], strict: bool, ): """REPL цикл swarm-chat.""" from pathlib import Path as PathLib from rich.prompt import Prompt from src.swarm.chat_state import ChatState # Определяем корень проекта project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() # Загружаем .env env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) # Инициализируем SmartClient api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") if not api_key: console.print("[red]✗ OPENAI_API_KEY не установлен[/red]") raise typer.Exit(1) smart_client = AsyncSmartOpenAI( api_key=api_key, base_url=base_url, ) # Загружаем/создаём состояние chat_state = ChatState.load_or_create( project_root=str(project_root), model=model or "gpt-4o-mini", strict_mode=strict, ) # Приветствие console.print(Panel( f"[bold]🤖 Swarm Chat REPL[/bold]\n\n" f"Проект: {chat_state.session.project_root}\n" f"Модель: {chat_state.session.model}\n" f"Строгий режим: {'Да' if chat_state.session.strict_mode else 'Нет'}\n" f"История: {chat_state.session.total_exchanges} обменов\n\n" f"[dim]Команды: /plan, /status, /clear, exit/quit[/dim]", title="Swarm Chat", border_style="green", )) # Восстановление предыдущей сессии if chat_state.history: console.print( f"[dim]↩ Загружена предыдущая сессия " f"({chat_state.session.total_exchanges} обменов)[/dim]" ) # REPL цикл while True: try: user_input = Prompt.ask("\n[bold cyan]swarm>[/bold cyan]").strip() if not user_input: continue # Команды if user_input.lower() in ("exit", "quit"): console.print("[yellow]👋 Сохранение состояния...[/yellow]") chat_state.save() console.print("[green]✅ Состояние сохранено. До свидания![/green]") break if user_input.startswith("/plan "): query = user_input[6:].strip() if not query: console.print("[yellow]Использование: /plan <запрос>[/yellow]") continue await _repl_cmd_plan( query=query, project_root=project_root, smart_client=smart_client, chat_state=chat_state, model=model, ) continue if user_input == "/status": _repl_cmd_status(chat_state) continue if user_input == "/clear": chat_state.clear_history() console.print("[green]🗑️ История очищена[/green]") continue if user_input.startswith("/"): console.print(f"[yellow]Неизвестная команда: {user_input}[/yellow]") console.print("[dim]Доступные: /plan, /status, /clear, exit, quit[/dim]") continue # Обычный запрос — полный цикл await _repl_execute_query( query=user_input, project_root=project_root, smart_client=smart_client, chat_state=chat_state, model=model, ) except EOFError: # Ctrl+D console.print("\n[yellow]👋 Сохранение состояния...[/yellow]") chat_state.save() console.print("[green]✅ Состояние сохранено. До свидания![/green]") break except KeyboardInterrupt: console.print("\n[yellow]⚠️ Запрос отменён[/yellow]") continue except Exception as e: import logging console.print(f"[red]❌ Ошибка: {e}[/red]") logging.exception("swarm-chat error") continue async def _repl_cmd_plan( query: str, project_root: Path, smart_client, chat_state, model: Optional[str], ): """Команда /plan — только планирование.""" from src.swarm.classifier import classify_request from src.swarm.planner import plan_request from src.swarm.plan_editor import PlanEditor from src.swarm.clarification_loop import ( ClarificationLoop, ClarificationPhase, DEFAULT_MAX_QUESTIONS, ) # Создаём RepositoryContext для реального анализа кода repo_ctx = None try: from src.services.repository_context import RepositoryContext api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") if api_key: repo_ctx = RepositoryContext( repo_root=str(project_root), base_url=base_url, openai_api_key=api_key, ) except Exception as e: import logging logging.debug(f"RepositoryContext creation failed: {e}") console.print(f"\n[cyan]📋 Планирование: {query}[/cyan]") # Classifier console.print("[dim]1/3 Классификация...[/dim]") classification = await classify_request( query=query, smart_client=smart_client, model=model or chat_state.session.model, config_path=str(project_root / ".codecontext" / "classifier-config.json"), repo_ctx=repo_ctx, ) # Clarification console.print("[dim]2/3 Уточнение контекста...[/dim]") clarification = ClarificationLoop( smart_client=smart_client, model=model or chat_state.session.model, max_questions=DEFAULT_MAX_QUESTIONS, ) clarification_result = await clarification.run( classification=classification, phase=ClarificationPhase.CLASSIFICATION, ) if clarification_result.updated_context: classification.task_context = clarification_result.updated_context # Если задача требует strategist — запускаем strategist-фазу if classification.task_context and classification.task_context.requires_strategist: from src.swarm.clarification_loop import StrategistContext console.print("[dim] Задача требует Strategist — уточняем архитектуру...[/dim]") strat_ctx = StrategistContext.from_task_context(classification.task_context) strat_result = await clarification.run( classification=classification, phase=ClarificationPhase.STRATEGIST, strategist_context=strat_ctx, ) if strat_result.updated_context: classification.task_context = strat_result.updated_context # Planner console.print("[dim]3/3 Генерация плана...[/dim]") plan = await plan_request( classification=classification, project_root=str(project_root), ) # Показываем план console.print(Panel( plan.to_markdown(), title=f"План: {query}", border_style="blue", )) # Сохраняем в историю chat_state.add_exchange( query=f"/plan {query}", response_summary=f"Создан план: {plan.plan_id} ({plan.task_count} задач)", plan_id=plan.plan_id, plan_data=plan.model_dump() if hasattr(plan, "model_dump") else plan.dict(), status="completed", ) def _repl_cmd_status(chat_state): """Команда /status.""" summary = chat_state.get_status_summary() lines = [ f"Проект: {summary['project_root']}", f"Модель: {summary['model']}", f"Строгий режим: {'Да' if summary['strict_mode'] else 'Нет'}", f"Начата: {summary['started_at']}", f"Активна: {summary['last_active']}", "", f"Обменов: {summary['total_exchanges']}", f"Задач выполнено: {summary['total_tasks_executed']}", f"Задач провалено: {summary['total_tasks_failed']}", "", f"Последний запрос: {summary['last_query']}", f"Последний статус: {summary['last_status']}", ] console.print(Panel( "\n".join(lines), title="Статус сессии", border_style="cyan", )) async def _repl_execute_query( query: str, project_root: Path, smart_client, chat_state, model: Optional[str], ): """Полный цикл: classifier → planner → execute → guardrails → feedback.""" from src.swarm.classifier import classify_request from src.swarm.planner import plan_request from src.swarm.plan_editor import PlanEditor from src.swarm.clarification_loop import ( ClarificationLoop, ClarificationPhase, DEFAULT_MAX_QUESTIONS, ) from rich.prompt import Confirm # Создаём RepositoryContext для реального анализа кода repo_ctx = None try: from src.services.repository_context import RepositoryContext api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") if api_key: repo_ctx = RepositoryContext( repo_root=str(project_root), base_url=base_url, openai_api_key=api_key, ) except Exception as e: import logging logging.debug(f"RepositoryContext creation failed: {e}") console.print(f"\n[bold]💬 Запрос: {query}[/bold]") try: # 1. Classifier console.print("[dim]1/6 Классификация...[/dim]") classification = await classify_request( query=query, smart_client=smart_client, model=model or chat_state.session.model, config_path=str(project_root / ".codecontext" / "classifier-config.json"), repo_ctx=repo_ctx, ) roles_str = ", ".join(r["role"] for r in classification.roles) console.print(f"[dim] Роли: {roles_str}[/dim]") # 2. Clarification console.print("[dim]2/6 Уточнение контекста...[/dim]") clarification = ClarificationLoop( smart_client=smart_client, model=model or chat_state.session.model, max_questions=DEFAULT_MAX_QUESTIONS, ) clarification_result = await clarification.run( classification=classification, phase=ClarificationPhase.CLASSIFICATION, ) if clarification_result.updated_context: classification.task_context = clarification_result.updated_context # 2b. Strategist фаза (если требуется) if classification.task_context and classification.task_context.requires_strategist: from src.swarm.clarification_loop import StrategistContext console.print("[dim]2b/6 Strategist — архитектурный анализ...[/dim]") strat_ctx = StrategistContext.from_task_context(classification.task_context) strat_result = await clarification.run( classification=classification, phase=ClarificationPhase.STRATEGIST, strategist_context=strat_ctx, ) if strat_result.updated_context: classification.task_context = strat_result.updated_context # 3. Planner console.print("[dim]3/6 Генерация плана...[/dim]") plan = await plan_request( classification=classification, project_root=str(project_root), ) console.print( f"[dim] План: {plan.plan_id} — " f"{plan.task_count} задач, " f"{len(plan.dag.edges)} зависимостей[/dim]" ) # 4. Подтверждение (если strict mode) if chat_state.session.strict_mode: console.print(Panel( plan.to_markdown(), title="План для подтверждения", border_style="yellow", )) confirmed = Confirm.ask( "\n[bold]Выполнить план?[/bold]", default=True, ) if not confirmed: console.print("[yellow]⏭️ План отменён пользователем[/yellow]") chat_state.add_exchange( query=query, response_summary="План отменён пользователем", plan_id=plan.plan_id, status="cancelled", ) return # 5. Execute console.print("[dim]4/6 Выполнение...[/dim]") result, guardrails_results = await _swarm_execute_plan( plan=plan, smart_client=smart_client, project_root=project_root, model=model, strict_mode=chat_state.session.strict_mode, chat_state=chat_state, ) # 6. Feedback console.print("[dim]5/6 Feedback Loop...[/dim]") from src.swarm.feedback_loop import FeedbackLoop # Запускаем MCP Server для получения инструментов mcp_tools = {} try: from src.eval.mcp_server_manager import create_eval_mcp_server console.print("[dim]🔌 Подключение MCP Server для инструментов...[/dim]") mcp_manager = await create_eval_mcp_server( project_root=str(project_root), port=None, merge_env=True, ) if mcp_manager.bridge and mcp_manager.bridge._state.available_tools: for tool in mcp_manager.bridge._state.available_tools: tool_name = tool["name"] mcp_tools[tool_name] = lambda name=tool_name, **kwargs: ( mcp_manager.bridge.call_tool(name, **kwargs) ) console.print(f"[dim] Доступно MCP инструментов: {len(mcp_tools)}[/dim]") except Exception as e: console.print(f"[dim] ⚠ MCP Server недоступен: {e}[/dim]") mcp_manager = None feedback_loop = FeedbackLoop( project_root=str(project_root), smart_client=smart_client, model=model or chat_state.session.model, max_cycles=int(os.getenv("SWARM_FEEDBACK_MAX_CYCLES", "3")), mcp_tools=mcp_tools, ) feedback_result = await feedback_loop.analyze_and_generate_tasks( plan=plan, execution_result=result, guardrails_results=guardrails_results, ) console.print( f"[dim]6/6 Feedback: " f"новых задач = {len(feedback_result.new_tasks)}, " f"причина = {feedback_result.stop_reason}[/dim]" ) # Если есть новые задачи от feedback — выполняем if feedback_result.has_new_tasks and feedback_result.updated_plan: console.print( f"\n[cyan]🔄 Выполнение {len(feedback_result.new_tasks)} " f"новых задач от feedback...[/cyan]" ) new_executor = WorkerExecutor( plan=feedback_result.updated_plan, smart_client=smart_client, model=model or chat_state.session.model, project_root=str(project_root), ) try: new_result, new_guardrails = await new_executor.execute() result = new_result guardrails_results.update(new_guardrails) except Exception as e: console.print(f"[red]❌ Ошибка feedback выполнения: {e}[/red]") # Итоговый отчёт _repl_show_result(query, result, feedback_result) # Сохраняем в историю chat_state.add_exchange( query=query, response_summary=( f"Выполнено: {result.completed_tasks}/{len(result.task_results)} задач" if hasattr(result, "completed_tasks") else "Выполнено" ), plan_id=plan.plan_id, plan_data=plan.model_dump() if hasattr(plan, "model_dump") else plan.dict(), execution_result=result.to_dict() if hasattr(result, "to_dict") else None, guardrails_results={ k: v if isinstance(v, dict) else ( v.to_dict() if hasattr(v, "to_dict") else str(v) ) for k, v in guardrails_results.items() } if guardrails_results else None, feedback_result=feedback_result.to_dict(), status="completed" if result.success else "failed", ) except Exception as e: import logging console.print(f"[red]❌ Ошибка выполнения: {e}[/red]") logging.exception("swarm-chat query execution error") chat_state.add_exchange( query=query, response_summary=f"Ошибка: {str(e)[:200]}", status="failed", ) async def _run_plan_with_ui( plan, smart_client, project_root: Path, model: Optional[str], chat_state = None, ): """Выполняет план с Rich Live UI и callbacks. Общая функция для _swarm_execute и _swarm_execute_plan. """ from rich.live import Live from rich.layout import Layout from rich.text import Text from src.swarm.worker_executor import WorkerExecutor from src.swarm.guardrails import GuardrailsChecker from src.cli_agent.llm_streaming_client import StreamEvent, StreamEventType current_task_display = "" streaming_text = "" def make_layout() -> Layout: layout = Layout() layout.split_column( Layout(name="header", size=3), Layout(name="body", ratio=1), ) layout["header"].update(Panel( f"[bold]{plan.original_query}[/bold]\n" f"Plan: {plan.plan_id}", title="Swarm Execution", border_style="blue", )) body_text = Text() if current_task_display: body_text.append(f"\n{current_task_display}\n", style="bold cyan") if streaming_text: lines = streaming_text.split("\n")[-20:] body_text.append("\n".join(lines), style="dim") layout["body"].update(Panel(body_text, title="Progress", border_style="yellow")) return layout async def on_event(event: StreamEvent): nonlocal streaming_text, current_task_display if event.type == StreamEventType.TEXT and event.content: streaming_text += event.content elif event.type == StreamEventType.TOOL_CALL_START: streaming_text += f"\n🔧 [TOOL] {event.tool_name}" elif event.type == StreamEventType.TOOL_RESULT: streaming_text += f" ✓\n" elif event.type == StreamEventType.TOOL_ERROR: streaming_text += f"\n❌ [ERROR] {event.tool_name}: {event.error}\n" async def on_task_start(task_id: str, role: str): nonlocal current_task_display, streaming_text task = next((t for t in plan.tasks if t.task_id == task_id), None) desc = task.description[:60] if task else "" current_task_display = f"[{role.upper()}] Task: {desc}" streaming_text += f"\n{'='*60}\n" streaming_text += f"▶️ START {task_id} ({role}): {desc}\n" streaming_text += f"{'='*60}\n" async def on_task_complete(task_result): nonlocal streaming_text status_icon = "✅" if task_result.status == "completed" else "❌" streaming_text += ( f"\n{status_icon} {task_result.task_id} " f"→ {task_result.status} ({task_result.duration_ms:.0f}ms)\n" ) # Guardrails callback guardrails_checker = GuardrailsChecker( project_root=str(project_root), smart_client=smart_client, model=model or "gpt-4o-mini", ) async def on_guardrails_check(task_id, changed_files=None): return await guardrails_checker.check_after_task( task_id=task_id, changed_files=changed_files, ) executor = WorkerExecutor( plan=plan, smart_client=smart_client, model=model or (chat_state.session.model if chat_state else "gpt-4o-mini"), project_root=str(project_root), on_event=on_event, on_task_start=on_task_start, on_task_complete=on_task_complete, on_guardrails_check=on_guardrails_check, ) with Live(make_layout(), refresh_per_second=4, screen=True) as live: async def periodic_update(): while True: live.update(make_layout()) await asyncio.sleep(0.5) update_task = asyncio.create_task(periodic_update()) try: result, guardrails_results = await executor.execute() return result, guardrails_results, executor finally: update_task.cancel() live.update(make_layout()) async def _swarm_execute_plan( plan, smart_client, project_root: Path, model: Optional[str], strict_mode: bool = False, chat_state = None, ): """Выполняет план с стримингом и guardrails.""" result, guardrails_results, executor = await _run_plan_with_ui( plan=plan, smart_client=smart_client, project_root=project_root, model=model, chat_state=chat_state, ) return result, guardrails_results def _repl_show_result(query: str, result, feedback_result): """Показывает итоговый отчёт выполнения.""" completed = result.completed_tasks if hasattr(result, "completed_tasks") else 0 total = len(result.task_results) if hasattr(result, "task_results") else 0 if result.success: console.print(Panel( f"[green]✅ Запрос выполнен![/green]\n\n" f"Запрос: {query}\n" f"Задач выполнено: {completed}/{total}\n" f"Время: {result.total_duration_ms/1000:.1f}с\n" f"Feedback: {feedback_result.stop_reason}", title="Результат", border_style="green", )) else: console.print(Panel( f"[red]⚠️ Запрос выполнен с ошибками[/red]\n\n" f"Запрос: {query}\n" f"Выполнено: {completed}/{total}\n" f"Провалено: {result.failed_tasks}\n" f"Ошибка: {result.error or 'Неизвестно'}", title="Результат", border_style="red", )) if __name__ == "__main__": main() # ============================================================================ # Eval System CLI Commands # ============================================================================ @app.command(name="eval-run") def eval_run( tasks_file: str = typer.Argument( ..., help="Path to tasks.json file with eval tasks" ), output: Optional[str] = typer.Option( None, "--output", "-o", help="Path to save JSON report" ), llm_eval: bool = typer.Option( True, "--llm-eval/--no-llm-eval", help="Enable LLM-based quality scoring" ), model: Optional[str] = typer.Option( None, "--model", help="LLM model for code generation (default: from EVAL_MODEL env)" ), eval_model: Optional[str] = typer.Option( None, "--eval-model", help="LLM model for quality evaluation (defaults to --model)" ), lua_timeout: int = typer.Option( 30, "--lua-timeout", help="Timeout for Lua execution in seconds" ), use_mcp: bool = typer.Option( True, "--mcp/--no-mcp", help="Use MCP server with real agent pipeline (default: enabled)" ), iron_user: bool = typer.Option( True, "--iron-user/--no-iron-user", help="Enable IronUser — LLM simulating a user to answer agent questions (default: enabled)" ), iron_user_model: Optional[str] = typer.Option( None, "--iron-user-model", help="LLM model for IronUser responses (defaults to --model)" ), llm_base_url: Optional[str] = typer.Option( None, "--llm-base-url", help="LLM API base URL (default: from OPENAI_BASE_URL env, then .env)" ), eval_llm: Optional[str] = typer.Option( None, "--eval-llm", help="Single model for all: code gen, eval, IronUser (overrides --model, --eval-model, --iron-user-model)" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Project directory for .env loading and MCP server context" ), eval_dir: Optional[str] = typer.Option( None, "--eval-dir", help="Custom eval directory for MCP indexing (default: ../eval relative to project)" ), merge_env: bool = typer.Option( False, "--merge-env", help="Merge .env from tool and target project" ), use_swarm: bool = typer.Option( False, "--use-swarm/--no-swarm", help="Использовать swarm-пайплайн (strategist → executor → reviewer) вместо legacy pipeline" ), use_langgraph: bool = typer.Option( False, "--use-langgraph/--no-langgraph", help="Использовать LangGraph-пайплайн с nested subgraphs и checkpointing" ), ): """Run eval tasks and generate a report. Uses the real agent pipeline: MCP Server → ToolOrchestrator → LLMStreamingClient with multi-round tool calls. Supports IronUser — an LLM that simulates a real user to answer clarification questions from the agent. Tasks with requires_clarification flag will trigger the Q&A flow before code generation. All LLM models берутся из OPENAI_BASE_URL (по умолчанию локальный LLM). Для переопределения используйте --eval-llm или --model. Examples: # С локальной LLM (из .env OPENAI_BASE_URL) uv run cli-agent eval-run tests/tasks.json -o report.json # С конкретной моделью uv run cli-agent eval-run tests/tasks.json --eval-llm llama-3.1-8b # С кастомным LLM endpointом uv run cli-agent eval-run tests/tasks.json --llm-base-url http://localhost:11434/v1 # Без MCP (только chat_completion), но с IronUser uv run cli-agent eval-run tests/tasks.json --no-mcp # Без IronUser — агент должен решить без вопросов uv run cli-agent eval-run tests/tasks.json --no-iron-user """ asyncio.run( _eval_run_impl( tasks_file=tasks_file, output=output, llm_eval=llm_eval, model=model, eval_model=eval_model, lua_timeout=lua_timeout, use_mcp=use_mcp, iron_user=iron_user, iron_user_model=iron_user_model, llm_base_url=llm_base_url, eval_llm=eval_llm, project_dir=project_dir, eval_dir=eval_dir, merge_env=merge_env, use_swarm=use_swarm, use_langgraph=use_langgraph, ) ) @app.command(name="eval-generate-tasks") def eval_generate_tasks( topic: str = typer.Argument( ..., help="Topic/domain for task generation (e.g. 'Lua file I/O')" ), output: str = typer.Option( "tasks_generated.json", "--output", "-o", help="Path to save generated tasks JSON" ), count: int = typer.Option( 5, "--count", "-n", help="Number of tasks to generate" ), language: str = typer.Option( "Lua", "--language", "-l", help="Target programming language" ), model: Optional[str] = typer.Option( None, "--model", help="LLM model for task generation" ), project_dir: Optional[str] = typer.Option( None, "--project-dir", help="Project directory for .env loading" ), merge_env: bool = typer.Option( False, "--merge-env", help="Merge .env from tool and target project" ), ): """Generate eval tasks from a topic using LLM. Uses LLM to create programming tasks with descriptions, expected behavior, and optional reference code. Saves to tasks.json format compatible with eval-run. Examples: # Generate 5 Lua file I/O tasks uv run cli-agent eval-generate-tasks "Lua file I/O" -n 5 # Generate 3 string parsing tasks uv run cli-agent eval-generate-tasks "string parsing" -n 3 -o string_tasks.json """ asyncio.run( _eval_generate_tasks_impl( topic=topic, output=output, count=count, language=language, model=model, project_dir=project_dir, merge_env=merge_env, ) ) async def _eval_run_impl( tasks_file: str, output: Optional[str], llm_eval: bool, model: Optional[str], eval_model: Optional[str], lua_timeout: int, use_mcp: bool, iron_user: bool, iron_user_model: Optional[str], llm_base_url: Optional[str], eval_llm: Optional[str], project_dir: Optional[str], eval_dir: Optional[str], merge_env: bool, use_swarm: bool, use_langgraph: bool, ) -> None: """Implementation of eval-run command.""" from pathlib import Path as PathLib from src.eval.test_runner import EvalTestRunner from src.eval.report_generator import ReportGenerator # Determine project root and load env project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) # Prepare eval directory (clean/create) # Docker: /app/eval, Local: /tmp/eval if eval_dir: eval_path = PathLib(eval_dir).resolve() console.print(f"[cyan]📁 Using custom eval directory: {eval_path}[/cyan]") if eval_path.exists(): console.print(f"[yellow]🧹 Cleaning existing eval directory...[/yellow]") import shutil for item in eval_path.iterdir(): if item.is_dir(): shutil.rmtree(item) else: item.unlink() console.print(f" [green]✓[/green] Eval directory cleaned") else: eval_path.mkdir(parents=True, exist_ok=True) console.print(f" [green]✓[/green] {eval_path}") else: eval_path = _prepare_eval_dir(project_root) console.print(f"[cyan]📂 Eval directory: {eval_path}[/cyan]") # Load tasks console.print(f"[cyan]📋 Loading tasks from {tasks_file}[/cyan]") try: tasks = EvalTestRunner.load_tasks(tasks_file) except (FileNotFoundError, json.JSONDecodeError) as e: console.print(f"[red]✗ Error loading tasks: {e}[/red]") raise typer.Exit(1) console.print(f" Found {len(tasks)} task(s)") # Setup clients — используем OPENAI_BASE_URL из .env (локальный LLM) base_url = llm_base_url or os.getenv("OPENAI_BASE_URL", "http://localhost:9191/v1") api_key = os.getenv("OPENAI_API_KEY", "not-needed") smart_client = AsyncSmartOpenAI(api_key=api_key, base_url=base_url) # Если --eval-llm задан — одна модель для всего if eval_llm: gen_model = eval_llm eval_md = eval_llm iron_md = eval_llm else: # Иначе берём из EVAL_MODEL или дефолт env_model = os.getenv("EVAL_MODEL") gen_model = model or env_model eval_md = eval_model or gen_model or env_model iron_md = iron_user_model or gen_model or env_model console.print(f"[cyan]⚙ Configuration:[/cyan]") console.print(f" LLM base URL: {base_url}") console.print(f" Code gen model: {gen_model or 'N/A'}") console.print(f" Eval model: {eval_md or 'N/A'}") console.print(f" IronUser model: {iron_md or 'N/A'}") console.print(f" LLM eval: {llm_eval}") console.print(f" IronUser: {iron_user}") console.print(f" MCP pipeline: {use_mcp}") console.print(f" Lua timeout: {lua_timeout}s") console.print(f" Eval directory: {eval_path}") if not gen_model: console.print("[red]✗ No LLM model specified![/red]") console.print("[yellow]Установите EVAL_MODEL или используйте --eval-llm / --model[/yellow]") raise typer.Exit(1) # Create runner (with MCP support if enabled) eval_client = smart_client if llm_eval else None iron_client = smart_client if iron_user else None mcp_url = None if use_mcp: # Start MCP server subprocess — индексируем eval директорию from src.eval.mcp_server_manager import MCPServerManager console.print(f"[cyan]🚀 Starting MCP Server for eval directory: {eval_path}...[/cyan]") mcp_manager = MCPServerManager( project_root=str(eval_path), merge_env=merge_env, startup_timeout=120, # Увеличенный таймаут для пустой директории ) await mcp_manager.start() mcp_url = mcp_manager.state.url console.print(f"[green]✓ MCP Server running at {mcp_url}[/green]") else: mcp_manager = None runner = EvalTestRunner( llm_client=smart_client, llm_model=gen_model, llm_eval_client=eval_client, llm_eval_model=eval_md, use_llm_eval=llm_eval, lua_timeout=lua_timeout, mcp_url=mcp_url, iron_user_llm_client=iron_client, iron_user_model=iron_md, use_swarm=use_swarm, use_langgraph=use_langgraph, project_root=str(project_root) if project_root else None, eval_dir=str(eval_path) if eval_path else None, ) # Run tasks with progress console.print(f"\n[yellow]🚀 Running eval tasks...[/yellow]\n") completed = 0 def progress(idx: int, total: int, result) -> None: nonlocal completed completed += 1 status_icon = {"passed": "✅", "failed": "❌", "partial": "⚠️"}.get(result.status, "❓") tool_info = f" [{result.tool_call_count} tools]" if result.tool_call_count > 0 else "" console.print(f" [{completed}/{total}] {status_icon} {result.task_id}: {result.status} ({result.execution_time_ms:.0f}ms){tool_info}") results = await runner.run_tasks(tasks, progress_callback=progress) # Generate report console.print(f"\n[cyan]📊 Generating report...[/cyan]") generator = ReportGenerator() report = generator.create_report(results) generator.print_table(report) generator.print_summary(report) # Save JSON if requested if output: saved_path = generator.save_json(report, output) console.print(f"\n[blue]💾 Report saved to: {saved_path}[/blue]") # Stop MCP server if started if mcp_manager: console.print("[cyan]🛑 Stopping MCP Server...[/cyan]") await mcp_manager.stop() # Exit code based on results if report.failed > 0: raise typer.Exit(code=1) async def _eval_generate_tasks_impl( topic: str, output: str, count: int, language: str, model: Optional[str], project_dir: Optional[str], merge_env: bool, ) -> None: """Implementation of eval-generate-tasks command.""" from pathlib import Path as PathLib from src.eval.test_user import TaskGenerator from src.eval.report_generator import ReportGenerator # Determine project root and load env project_root = PathLib(project_dir).resolve() if project_dir else PathLib.cwd() env_vars = _load_project_env(project_root, merge_with_tool=merge_env) _apply_env_vars(env_vars) console.print(f"[cyan]🤖 Generating {count} tasks for topic: {topic}[/cyan]") # Setup client api_key = os.getenv("OPENAI_API_KEY", "") base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") smart_client = AsyncSmartOpenAI(api_key=api_key, base_url=base_url) gen_model = model or os.getenv("EVAL_MODEL", "gpt-4o") generator = TaskGenerator(client=smart_client, model=gen_model) tasks = await generator.generate_tasks(topic, count=count, language=language) if not tasks: console.print("[red]✗ Failed to generate tasks. Check LLM connectivity.[/red]") raise typer.Exit(1) # Convert to tasks.json format tasks_data = [] for t in tasks: tasks_data.append({ "id": t.id, "description": t.description, "expected_behavior": t.expected_behavior, "reference_code": t.reference_code, "lint_rules": t.suggested_lint_rules, "mode": "batch", }) # Save to file output_path = PathLib(output) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(tasks_data, f, indent=2, ensure_ascii=False) console.print(f"\n[green]✅ Generated {len(tasks)} task(s)[/green]") # Print summary table table = Table(title=f"Generated Tasks for: {topic}") table.add_column("ID", style="cyan", width=15) table.add_column("Description", style="green", width=50) table.add_column("Difficulty", justify="center", width=12) table.add_column("Has Ref", justify="center", width=10) for t in tasks: table.add_row( t.id, t.description[:50] + ("..." if len(t.description) > 50 else ""), t.difficulty, "✅" if t.reference_code else "—", ) console.print(table) console.print(f"\n[blue]💾 Tasks saved to: {output_path}[/blue]") console.print(f"\n[yellow]Run with: uv run cli-agent eval-run {output}[/yellow]")