Files
agents/start.py
T
2026-09-19 13:18:49 +00:00

515 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Multi-Agent Development Workflow
- Planner: анализирует проект и создаёт план подзадач
- Coder: выполняет подзадачи, пишет код
- Tester: открывает браузер, проверяет ошибки в консоли
Поддерживает несколько проектов, состояние хранится в папке проекта.
"""
import json
import tiktoken
import os
import pathlib
import re
import sys
import time
from pathlib import Path
from typing import List, Dict, Any, Optional
import requests
from playwright.sync_api import sync_playwright
from analyzer import CodeAnalyzer
# ---------- Конфигурация ----------
CONFIG_FILE = "config.json"
def load_config():
"""Загружает конфигурацию из файла."""
if not os.path.exists(CONFIG_FILE):
default_config = {
"ollama_base_url": "http://10.6.3.2:11434",
"default_model": "qwen3:4b-instruct-2507-q4_K_M",
"projects": [
{
"name": "example_project",
"path": "./projects/example",
"url": "http://localhost:3000",
"model": "qwen3:4b-instruct-2507-q4_K_M"
}
]
}
with open(CONFIG_FILE, "w") as f:
json.dump(default_config, f, indent=2)
print(f"Создан файл {CONFIG_FILE}. Отредактируйте его и запустите снова.")
sys.exit(0)
with open(CONFIG_FILE, "r") as f:
return json.load(f)
# ---------- Клиент для Ollama ----------
class OllamaClient:
def __init__(self, base_url: str, model: str):
self.base_url = base_url.rstrip('/')
self.model = model
self.api_url = f"{self.base_url}/api/chat"
def chat(self, messages: List[Dict[str, str]], temperature: float = 0.2) -> str:
"""Отправляет запрос к Ollama и возвращает ответ."""
payload = {
"model": self.model,
"messages": messages,
"stream": False,
"temperature": temperature,
"options": {
"num_ctx": 16000
}
}
try:
response = requests.post(self.api_url, json=payload, timeout=600)
response.raise_for_status()
data = response.json()
return data.get("message", {}).get("content", "")
except Exception as e:
print(f"Ошибка при запросе к Ollama: {e}")
return ""
# ---------- Управление проектами ----------
class Project:
def __init__(self, name: str, path: str, url: str, model: str, file_masks, ollama_base: str):
self.name = name
self.path = Path(path)
self.url = url
self.model = model
self.file_masks = file_masks
self.ollama_client = OllamaClient(ollama_base, model)
self.state_file = self.path / ".workflow_state.json"
self.state = self.load_state()
self.tokenizer = tiktoken.get_encoding("gpt2")
self.max_summary_tokens = 3500
self.analyzer = CodeAnalyzer()
def load_state(self) -> Dict[str, Any]:
"""Загружает состояние проекта (план, выполненные подзадачи, история ошибок)."""
if self.state_file.exists():
with open(self.state_file, "r") as f:
return json.load(f)
return {
"plan": [],
"current_step": 0,
"completed": [],
"errors": [],
"history": []
}
def save_state(self):
"""Сохраняет состояние проекта."""
self.state_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.state_file, "w") as f:
json.dump(self.state, f, indent=2)
def get_project_files(self) -> List[Path]:
"""Возвращает список файлов проекта (рекурсивно все файлы, кроме системных)."""
exclude = {".git", "__pycache__", "node_modules", ".idea", ".vscode", "venv"}
files = []
for root, dirs, filenames in os.walk(self.path):
dirs[:] = [d for d in dirs if d not in exclude]
for fname in filenames:
if fname.endswith(('.py', '.js', '.html', '.css', '.json', '.txt')):
files.append(Path(root) / fname)
return files
def read_file_content(self, file_path: Path) -> str:
"""Читает содержимое файла (с обработкой кодировок)."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
except Exception:
return ""
def write_file(self, file_path: Path, content: str):
"""Записывает содержимое в файл (создавая папки при необходимости)."""
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
def get_project_structure(self) -> Dict[str, Any]:
"""
Возвращает дерево папок и файлов, отфильтрованных по маскам.
Возвращает список путей и текстовое представление дерева.
"""
masks = self.file_masks
include_masks = masks.get("include", [])
exclude_masks = masks.get("exclude", [])
"""Возвращает дерево папок и файлов, отфильтрованных по маскам."""
base = self.path.resolve()
all_paths = []
for root, dirs, files in os.walk(base):
rel_root = Path(root).relative_to(base)
for f in files:
rel_path = str(rel_root / f).replace('\\', '/')
# Пропускаем файлы, которые не проходят фильтр
if not self._matches_masks(rel_path, include_masks, exclude_masks):
continue
all_paths.append(rel_path)
all_paths.sort()
tree_lines = self._build_tree(all_paths)
return {
"paths": all_paths,
"tree": "\n".join(tree_lines)
}
def _build_tree(self, paths: List[str]) -> List[str]:
"""Строит ASCII-дерево из списка путей."""
if not paths:
return ["(пусто)"]
# Простая реализация: разбиваем по "/" и строим вложенный словарь
tree = {}
for p in paths:
parts = p.split('/')
node = tree
for part in parts:
if part not in node:
node[part] = {}
node = node[part]
lines = []
def walk(node, prefix=""):
items = sorted(node.items())
for i, (name, subnode) in enumerate(items):
is_last = (i == len(items) - 1)
lines.append(f"{prefix}{'└── ' if is_last else '├── '}{name}")
if subnode:
walk(subnode, prefix + (" " if is_last else "│ "))
walk(tree)
return lines
@staticmethod
def _mask_to_regex(mask: str) -> str:
"""
Преобразует glob-подобную маску в регулярное выражение.
Поддерживает:
- ** : любое количество любых символов (включая /)
- * : любое количество любых символов, кроме /
- {a,b,c} : выбор из нескольких вариантов
"""
# Убираем ведущий /, если он есть (пути передаются без него)
if mask.startswith('/'):
mask = mask[1:]
parts = []
i = 0
n = len(mask)
while i < n:
ch = mask[i]
if ch == '*':
if i + 1 < n and mask[i + 1] == '*':
# **
parts.append('.*')
i += 2
else:
# *
parts.append('[^/]*')
i += 1
elif ch == '{':
# Ищем закрывающую }
j = mask.find('}', i + 1)
if j == -1:
# Нет закрывающей – экранируем как обычный символ
parts.append(re.escape(ch))
i += 1
else:
inner = mask[i + 1:j]
# Разбиваем по запятым, экранируем каждую опцию
options = [re.escape(opt.strip()) for opt in inner.split(',')]
parts.append('(?:' + '|'.join(options) + ')')
i = j + 1
else:
# Экранируем все остальные спецсимволы
parts.append(re.escape(ch))
i += 1
regex = ''.join(parts)
return '^' + regex + '$'
def _matches_masks(self, rel_path: str, include_masks: List[str], exclude_masks: List[str]) -> bool:
"""Проверяет, подходит ли файл под маски включения/исключения."""
# Если нет include – считаем, что включено
included = False
if not include_masks:
included = True
else:
for mask in include_masks:
regex = self._mask_to_regex(mask)
if re.match(regex, rel_path):
included = True
break
if not included:
return False
# Проверяем исключения
for mask in exclude_masks:
regex = self._mask_to_regex(mask)
if re.match(regex, rel_path):
return False
return True
def _count_tokens(self, text: str) -> int:
return len(self.tokenizer.encode(text))
def generate_summaries(self, file_paths: List[str]) -> Dict[str, str]:
"""
Генерирует описания для файлов, пока общее количество токенов не превысит лимит.
Сохраняет результат в state и возвращает словарь {путь: описание}.
"""
# Если уже есть в состоянии, возвращаем его (можно добавить проверку актуальности позже)
if "file_summaries" in self.state and self.state["file_summaries"]:
return self.state["file_summaries"]
file_paths = sorted(file_paths, key=lambda p: (self.path / p).stat().st_size, reverse=True)
summaries = {}
total_tokens = 0
for rel_path in file_paths:
full_path = self.path / rel_path
content = self.read_file_content(full_path)
if not content:
continue
# Получаем структуру через tree-sitter (как раньше)
ext = full_path.suffix
signature = self.analyzer.analyze_file(content, ext)
signature_str = json.dumps(signature, ensure_ascii=False, indent=2)
# Генерируем описание
prompt = f"""
Файл: {rel_path}
Структура:
{signature_str}
Опиши кратко (1–2 предложения), что делает этот файл, перечисли все компоненты (классы, функции).
Не пиши код, только текст.
"""
response = self.ollama_client.chat([{"role": "user", "content": prompt}])
if not response:
response = "(описание не получено)"
# Считаем токены для этого описания (плюс небольшой запас на разделители)
desc_tokens = self._count_tokens(response)
summaries[rel_path] = response
total_tokens += desc_tokens
print(f" ✅ {rel_path} – {desc_tokens} токенов, всего {total_tokens}")
# Сохраняем в состоянии
self.state["file_summaries"] = summaries
self.save_state()
return summaries
# ---------- Основной цикл ----------
def run_workflow(project: Project, user_task: str, max_iterations: int = 5):
print(f"\n🚀 Начинаем работу над проектом '{project.name}'")
# Получаем структуру и суммари
file_paths = project.state.get('paths')
print(f"📁 Найдено {len(file_paths)} файлов по маскам.")
summaries = project.generate_summaries(file_paths)
if not summaries:
print("❌ Не удалось получить описания файлов.")
return
# Инициализация состояния (если нет)
if "completed" not in project.state:
project.state["completed"] = []
if "errors_history" not in project.state:
project.state["errors_history"] = []
if "last_errors" not in project.state:
project.state["last_errors"] = []
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f"\n--- Итерация {iteration} ---")
# Планировщик решает, что делать дальше
task = PlannerAgent.next_task(project, user_task)
print(json.dumps(task))
if not task or task.get("type") == "finish":
print("✅ Планировщик завершил работу.")
break
task_type = task["type"]
description = task["description"]
files = task.get("files", [])
print(f"📌 Следующая задача: {task_type.upper()} — {description}")
if files:
print(f" Файлы: {', '.join(files)}")
if task_type == "test":
errors = TesterAgent.test(project)
if errors:
print(f" ❌ Обнаружены ошибки ({len(errors)}):")
for err in errors[:3]:
print(f" {err}")
project.state["last_errors"] = errors
project.state["errors_history"].extend(errors)
else:
print(" ✅ Ошибок не обнаружено.")
project.state["last_errors"] = []
project.save_state()
elif task_type == "code":
error_feedback = ""
if project.state.get("last_errors"):
# Преобразуем структурированные ошибки в текст
error_lines = []
for err in project.state["last_errors"]:
msg = err.get("message", "")
loc = err.get("location")
if loc:
file_name = loc.get("url", "неизвестный файл")
line = loc.get("line", "?")
col = loc.get("column", "?")
error_lines.append(f"{msg} (в {file_name}:{line}:{col})")
else:
error_lines.append(msg)
error_feedback = "\n".join(error_lines)
success = CoderAgent.execute(project, description, files, error_feedback)
if success:
print(" ✅ Код применён.")
# После кода сбрасываем last_errors, так как они могут быть исправлены
project.state["last_errors"] = []
else:
print(" ⚠️ Кодер не смог применить изменения.")
project.save_state()
elif task_type == "browser":
print(" 🌐 Открываем браузер для визуальной проверки...")
# Можно реализовать отдельный метод
else:
print(f" ⚠️ Неизвестный тип задачи: {task_type}")
# Запоминаем выполненную задачу
project.state["completed"].append(task)
project.save_state()
if iteration >= max_iterations:
print(f"⛔ Достигнут лимит итераций ({max_iterations}).")
else:
print("\n✅ Работа завершена успешно.")
def replan(project: Project, user_task: str, errors: List[str]) -> bool:
"""Перепланирование на основе ошибок."""
print("🔄 Перепланирование с учетом ошибок...")
new_plan = PlannerAgent.plan(project, user_task)
if not new_plan or (len(new_plan) == 1 and new_plan[0].get("type") == "error"):
print("❌ Не удалось создать новый план.")
return False
project.state["plan"] = new_plan
project.state["current_step"] = 0
project.state["completed"] = []
project.state["errors_history"] = []
project.save_state()
print("📋 Новый план:")
for i, task in enumerate(new_plan, 1):
files_str = f" files: {task.get('files', [])}" if task.get("type") == "code" else ""
print(f"{i}. [{task['type']}] {task['description']}{files_str}")
return True
# ---------- Точка входа - ---------
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--auto", action="store_true",
help="Автоматический режим без вопросов (выбирает первый проект)")
parser.add_argument("--task", type=str,
help="Задача для выполнения (в автоматическом режиме или при повторном запуске)")
parser.add_argument("--repeat", action="store_true",
help="Перезапустить выполнение последней задачи (сброс состояния)")
args = parser.parse_args()
config = load_config()
ollama_base = config.get("ollama_base_url", "http://10.6.3.2:11434")
projects = config.get("projects", [])
if not projects:
print("Нет проектов в конфигурации. Добавьте их в config.json.")
sys.exit(1)
if args.auto:
# Берём первый проект
selected = projects[0]
else:
print("Доступные проекты:")
for idx, proj in enumerate(projects, 1):
print(f" {idx}. {proj['name']} (путь: {proj['path']})")
try:
choice = int(input("Выберите номер проекта: ")) - 1
if choice < 0 or choice >= len(projects):
raise ValueError
except ValueError:
print("Некорректный ввод.")
sys.exit(1)
selected = projects[choice]
project = Project(
name=selected["name"],
path=selected["path"],
url=selected.get("url", "http://localhost:3000"),
model=selected.get("model", config.get("default_model", "llama3.1")),
file_masks=selected['file_masks'],
ollama_base=ollama_base,
)
if not project.path.exists():
print(f"Папка проекта {project.path} не существует. Создаю.")
project.path.mkdir(parents=True, exist_ok=True)
# Задача из аргументов или дефолтная
task_arg = args.task
if not task_arg:
# Если не задана, пробуем взять из состояния проекта (последняя задача)
# Но у нас нет такого поля, поэтому используем заглушку
task_arg = input("Введите описание задачи для этого проекта: ").strip()
if not task_arg and not project.state['plan']:
print("Задача не введена. Завершение.")
sys.exit(1)
if not args.auto:
structure = project.get_project_structure()
print("\n📁 Структура проекта (используемые файлы):")
print(structure["tree"])
if input("Ok? [n/Y]: ").strip() == "n":
sys.exit(1)
project.state['tree'] = structure["tree"]
project.state['paths'] = structure["paths"]
project.save_state()
# Если repeat, сбрасываем состояние выполнения
if args.repeat:
print("🔄 Состояние сброшено для перезапуска задачи.")
try:
run_workflow(project, task_arg)
except KeyboardInterrupt:
print("\nПрервано пользователем.")
except Exception as e:
print(f"Критическая ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()