diff --git a/CLAUDE.md b/CLAUDE.md index 836a7f8..6193d10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,17 @@ 1. **В prompt** — `build_system_prompt()` добавляет блок `[ИНВАРИАНТЫ — …]` с явным правилом отказа от запросов, противоречащих ограничениям. 2. **Пост-проверка** — `app/invariant_guard.py::guarded_chat()` прогоняет ответ модели через `InvariantSet.check()`. При block-нарушениях — feedback-ретрай с просьбой переделать (до `max_retries`, по умолчанию 1). warn — не блокирует, но возвращается в UI. Используется и в обычном чате (`cli/main.py`), и в стадиях задачи (`app/task_driver.py::advance_task`). В `advance_task` информация о нарушениях кладётся в `stage_obj.artifacts["invariant_violations"]` для отладки. +## Файловые тулы (fs) + +Встроенный источник инструментов, дающий агенту реальную работу с файлами проекта прямо в tool-loop — без внешнего MCP-сервера. + +- Реализация: `infra/local_fs_client.py::LocalFilesystemClient` — тот же протокол `McpClient` (`start/list_tools/call_tool/close`), но операции идут в ФС напрямую, не по JSON-RPC. За счёт этого клиент встаёт в `McpRegistry.register()`, а `ToolRouter` сам отдаёт его тулы модели и роутит вызовы `fs__*` (правок в tool-loop не потребовалось). +- Тулы: `fs__list_dir`, `fs__read_file`, `fs__search` (grep по дереву, glob/regex), `fs__write_file`. +- **Sandbox**: все пути резолвятся внутри корня (`JARVIS_FS_ROOT`, по умолчанию — текущий рабочий каталог, откуда запущен `jarvis`, поэтому агента можно подключить к любому проекту из его терминала); выход за него (`..`, симлинк) отклоняется. Служебные каталоги (`.git`, `.venv`, `__pycache__`, …) не обходятся. +- **Запись = diff + подтверждение**: `write_file` считает unified diff и вызывает инъектируемый `confirm(rel, diff) -> bool`. В CLI — `cli/fs_confirm.py::make_interactive_confirm` печатает **цветной diff** (удалённые строки красным, добавленные зелёным) и спрашивает y/n; без подтверждения запись не происходит. В тестах/демо confirm подменяется. +- Активируется только при `provider=deepseek` (tool calling). Сборка — в composition root (`cli/main.py`). +- Демо (воспроизводимо, без ключа — `JARVIS_DEMO_SCRIPTED=1`): `examples/fs_agent_demo.py` — агент по цели сам ищет использования API и генерирует ADR. + ## Конвенции - Все пользовательские данные — в `~/.jarvis/`, не в репо diff --git a/cli/ansi.py b/cli/ansi.py index 384a0eb..a8bc53c 100644 --- a/cli/ansi.py +++ b/cli/ansi.py @@ -5,6 +5,7 @@ DIM = "\033[2m" CYAN = "\033[36m" GREEN = "\033[32m" +RED = "\033[31m" YELLOW = "\033[33m" MAGENTA = "\033[35m" BLUE = "\033[34m" diff --git a/cli/fs_confirm.py b/cli/fs_confirm.py new file mode 100644 index 0000000..6385dc2 --- /dev/null +++ b/cli/fs_confirm.py @@ -0,0 +1,58 @@ +"""CLI-подтверждение записи файла с цветным diff. + +`LocalFilesystemClient` перед записью вызывает `confirm(rel, diff)`. Здесь — +интерактивная реализация этого коллбэка: печатает unified diff с подсветкой +(удалённые строки красным, добавленные зелёным) и спрашивает y/n. Логика +раскраски вынесена в чистую `colorize_diff`, чтобы её можно было переиспользовать +для показа diff, который тул вернул после успешной записи. + +Живёт в `cli/` (это ввод/вывод), в композит-рут прокидывается как обычный +callable — инфраструктурный клиент про терминал ничего не знает. +""" +from __future__ import annotations + +import sys +from typing import Callable + +from cli.ansi import BOLD, CYAN, DIM, GREEN, RED, RESET + + +def colorize_diff(diff: str) -> str: + """Раскрасить unified diff: '-' строки красным, '+' зелёным, @@ голубым.""" + out: list[str] = [] + for line in diff.splitlines(): + if line.startswith("+++") or line.startswith("---"): + out.append(f"{BOLD}{line}{RESET}") + elif line.startswith("@@"): + out.append(f"{CYAN}{line}{RESET}") + elif line.startswith("+"): + out.append(f"{GREEN}{line}{RESET}") + elif line.startswith("-"): + out.append(f"{RED}{line}{RESET}") + else: + out.append(f"{DIM}{line}{RESET}") + return "\n".join(out) + + +def make_interactive_confirm(reader: Callable[[str], str] = input, + stream=None) -> Callable[[str, str], bool]: + """Собрать confirm-коллбэк для `LocalFilesystemClient`. + + `reader` — как читать ответ пользователя (по умолчанию `input`); в тестах + подменяется. Возвращает `True`, только если пользователь явно согласился. + """ + out = stream or sys.stdout + + def confirm(rel: str, diff: str) -> bool: + print(f"\n{BOLD}Агент хочет изменить файл:{RESET} {CYAN}{rel}{RESET}", file=out) + print(colorize_diff(diff), file=out) + try: + answer = reader(f"{BOLD}Применить изменения? [y/N] {RESET}").strip().lower() + except EOFError: + answer = "" + approved = answer in ("y", "yes", "д", "да") + if not approved: + print(f"{DIM}Пропущено — файл не изменён.{RESET}", file=out) + return approved + + return confirm diff --git a/cli/main.py b/cli/main.py index 35bceec..c1a894d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -100,6 +100,8 @@ from infra.mcp_config_repository import FileMcpConfigRepository from infra.mcp_git import McpGitContextProvider from infra.mcp_registry import StdioMcpRegistry +from infra.local_fs_client import LocalFilesystemClient +from cli.fs_confirm import make_interactive_confirm from infra.pr_diff import GhDiffProvider from infra.profile_repository import FileProfileRepository from infra.query_rewriter import LLMQueryRewriter @@ -238,6 +240,24 @@ def main(): ) git_provider = McpGitContextProvider(mcp_registry, repo_path) + # Встроенный источник файловых тулов: агент читает/ищет/пишет файлы проекта + # прямо в tool-loop, без внешнего MCP-сервера. Sandbox-root по умолчанию — + # текущий рабочий каталог (откуда запустили jarvis), поэтому можно открыть + # терминал в любом проекте и подключить агента к нему. Переопределяется + # JARVIS_FS_ROOT. Запись проходит через интерактивный confirm с цветным diff. + fs_root = os.path.expanduser( + os.environ.get("JARVIS_FS_ROOT", "").strip() or os.getcwd() + ) + try: + fs_client = LocalFilesystemClient( + root = fs_root, + confirm = make_interactive_confirm(reader=read_input), + ) + mcp_registry.register(fs_client) + print(f"{DIM}Файловые тулы (fs) активны на {fs_root}.{RESET}") + except Exception as e: + print(f"{YELLOW}Файловые тулы не поднялись: {e}{RESET}") + tool_router = ToolRouter(client, mcp_registry) \ if provider == DEEPSEEK and mcp_registry.all_tools() else None if tool_router is not None: diff --git a/examples/fs_agent_demo.py b/examples/fs_agent_demo.py new file mode 100644 index 0000000..96931df --- /dev/null +++ b/examples/fs_agent_demo.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Демо файлового агента: цель → сам читает/ищет/пишет файлы проекта. + +Собирает во временном каталоге мини-проект, поднимает встроенный +`LocalFilesystemClient` (тулы fs__list_dir/read_file/search/write_file) и +даёт агенту ЦЕЛЬ уровня «найди все использования api_client и задокументируй +их в ADR» — без указания «открой файл X». Дальше tool-loop сам: + + • ищет использование по нескольким файлам (fs__search), + • читает найденное (fs__read_file), + • генерирует новый файл docs/adr/… (fs__write_file) — с показом + цветного diff (красный/зелёный) перед записью. + +Запуск: + python3 examples/fs_agent_demo.py # реальный DeepSeek, если есть ключ + JARVIS_DEMO_SCRIPTED=1 python3 examples/fs_agent_demo.py # без ключа, детерминированно + +Без DEEPSEEK_API_KEY автоматически включается скриптованный режим, поэтому +демо воспроизводимо повторно на любой машине. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from app.tool_router import ToolRouter # noqa: E402 +from cli.config import DEEPSEEK_CHAT_URL, load_env # noqa: E402 +from cli.fs_confirm import colorize_diff # noqa: E402 +from infra.deepseek_client import DeepSeekClient # noqa: E402 +from infra.local_fs_client import LocalFilesystemClient # noqa: E402 +from infra.mcp_registry import StdioMcpRegistry # noqa: E402 + + +GOAL = ( + "Ты — файловый агент над проектом. Используй ТОЛЬКО тулы fs__* (не выдумывай " + "содержимое). Цель:\n" + "1) Найди все места, где используется api_client (fs__search).\n" + "2) Прочитай хотя бы один такой файл, чтобы понять, какие методы вызываются.\n" + "3) Сгенерируй ADR-файл 'docs/adr/0001-api-client-usage.md' (fs__write_file) " + "с заголовком, статусом Accepted, списком файлов-потребителей и методов.\n" + "В финальном ответе кратко перечисли, что сделал." +) + + +class _EmptyRepo: + def list_all(self): return [] + + +def _sample_project(root: Path) -> None: + (root / "app").mkdir(parents=True) + (root / "app" / "orders.py").write_text( + "from infra import api_client\n\n" + "def place_order(o):\n return api_client.post('/orders', o)\n", + encoding="utf-8") + (root / "app" / "catalog.py").write_text( + "from infra import api_client\n\n" + "def get_item(i):\n return api_client.get(f'/items/{i}')\n", + encoding="utf-8") + (root / "README.md").write_text("# Shop\n", encoding="utf-8") + + +def _demo_confirm(rel: str, diff: str) -> bool: + """Авто-подтверждение с показом цветного diff (как увидит пользователь в чате).""" + print(f"\n Агент пишет файл: {rel}") + print(colorize_diff(diff)) + print(" → авто-подтверждено (demo)\n") + return True + + +class _ScriptedLLM: + """Детерминированная замена LLM: воспроизводит те же tool_calls, что сделал бы агент.""" + def __init__(self): + self._script = [ + {"content": None, "tool_calls": [self._call("s1", "fs__search", + {"query": "api_client", "glob": "*.py"})]}, + {"content": None, "tool_calls": [self._call("s2", "fs__read_file", + {"path": "app/orders.py"})]}, + {"content": None, "tool_calls": [self._call("s3", "fs__write_file", + {"path": "docs/adr/0001-api-client-usage.md", + "content": ("# ADR 0001: Использование api_client\n\n" + "## Status\nAccepted\n\n" + "## Context\n" + "`api_client` вызывается из нескольких модулей приложения.\n\n" + "## Consumers\n" + "- `app/orders.py` — `api_client.post`\n" + "- `app/catalog.py` — `api_client.get`\n\n" + "## Decision\n" + "Обращения к внешнему API централизуются через `api_client`.\n")})]}, + {"content": "Нашёл 2 потребителя api_client (orders.py, catalog.py) и создал ADR 0001."}, + ] + + @staticmethod + def _call(cid, name, args): + return {"id": cid, "type": "function", + "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}} + + def chat(self, messages, params, system_prompt=None): + return (self._script.pop(0).get("content") or "") + + def chat_with_tools(self, messages, params, tools, system_prompt=None): + return self._script.pop(0) + + +def _make_llm(): + api_key = os.environ.get("DEEPSEEK_API_KEY") + scripted = os.environ.get("JARVIS_DEMO_SCRIPTED") or not api_key + if scripted: + print("[режим] скриптованный LLM (детерминированно, без сети)\n") + return _ScriptedLLM(), {"model": "scripted"} + print("[режим] реальный DeepSeek\n") + return (DeepSeekClient(api_key=api_key, chat_url=DEEPSEEK_CHAT_URL), + {"model": "deepseek-chat", "temperature": 0}) + + +def main() -> int: + load_env(os.path.join(ROOT, ".env")) + with tempfile.TemporaryDirectory(prefix="jarvis-fs-demo-") as tmp: + proj = Path(tmp) + _sample_project(proj) + + registry = StdioMcpRegistry(_EmptyRepo()) + registry.start_all() + registry.register(LocalFilesystemClient(root=str(proj), confirm=_demo_confirm)) + + try: + llm, params = _make_llm() + router = ToolRouter(llm, registry, max_iterations=12) + print("Тулы для агента:", + ", ".join(t.qualified_name for t in registry.all_tools())) + print("\nЦЕЛЬ:\n" + GOAL + "\n") + + result = router.chat([{"role": "user", "content": GOAL}], params) + + print("─" * 60) + print(f"TRACE ({len(result.trace)} вызов(ов)):") + for inv in result.trace: + print(f" #{inv.iteration} {inv.server_id}.{inv.tool_name} " + f"{json.dumps(inv.arguments, ensure_ascii=False)[:80]}") + print(f" → {inv.result_text[:90].splitlines()[0] if inv.result_text else ''}") + print("─" * 60) + print("FINAL:", result.reply) + + adr = proj / "docs" / "adr" / "0001-api-client-usage.md" + print("\nСоздан файл:", adr.name, "—", "OK" if adr.exists() else "НЕ создан") + if adr.exists(): + print("─" * 60) + print(adr.read_text(encoding="utf-8")) + return 0 + finally: + registry.shutdown() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/local_fs_client.py b/infra/local_fs_client.py new file mode 100644 index 0000000..a0ab3f7 --- /dev/null +++ b/infra/local_fs_client.py @@ -0,0 +1,298 @@ +"""In-process источник файловых инструментов для агента. + +Реализует тот же протокол `McpClient` (`app.ports.McpClient`), что и +stdio/http-клиенты, но вместо JSON-RPC к подпроцессу выполняет операции с +файловой системой напрямую. За счёт этого он бесшовно встаёт в +`McpRegistry`, а `ToolRouter` отдаёт его тулы модели и маршрутизирует +вызовы `fs__` сюда — как к обычному MCP-серверу. Никаких правок в +tool-loop не требуется: «агент сам выбирает инструмент» уже работает. + +Все пути — внутри sandbox-root (`root`). Любой выход за него (через `..` +или симлинк) отклоняется. Запись (`write_file`) проходит через +инъектируемый `confirm(rel_path, unified_diff) -> bool`: CLI показывает +diff и спрашивает подтверждение, тесты подставляют фейк. Так закрывается +требование «изменения сохраняются или выводятся как diff». + +Тулы: + • list_dir — перечислить каталог + • read_file — прочитать файл + • search — искать по дереву (подстрока или regex), с glob-фильтром + • write_file— создать/перезаписать файл (diff + подтверждение) +""" +from __future__ import annotations + +import difflib +import fnmatch +import os +import re +from pathlib import Path +from typing import Callable, Optional + +from domain.mcp import McpTool, ToolResult + + +class PathEscapeError(Exception): + """Попытка обратиться к пути за пределами sandbox-root.""" + + +# Подтверждение записи: (относительный путь, unified diff) -> писать ли. +Confirmer = Callable[[str, str], bool] + +# Каталоги, которые не имеет смысла обходить при поиске/листинге. +_SKIP_DIRS = { + ".git", ".hg", ".svn", + ".venv", "venv", "env", + "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", + "node_modules", ".idea", ".vscode", "dist", "build", +} + +_DEFAULT_MAX_BYTES = 200_000 # потолок на чтение одного файла +_DEFAULT_MAX_MATCHES = 200 # потолок на число совпадений поиска +_SEARCH_MAX_FILE_BYTES = 2_000_000 # файлы крупнее этого при поиске пропускаем + + +def _always_yes(_rel: str, _diff: str) -> bool: + return True + + +class LocalFilesystemClient: + """Файловые тулы над sandbox-каталогом `root`. + + `confirm` вызывается перед каждой записью; по умолчанию — авто-«да» + (удобно для демо/скриптов). CLI прокидывает интерактивный промпт, + тесты — фейк с нужным решением. + """ + + def __init__(self, + root: str, + server_id: str = "fs", + confirm: Optional[Confirmer] = None, + max_read_bytes: int = _DEFAULT_MAX_BYTES, + max_matches: int = _DEFAULT_MAX_MATCHES): + self.server_id = server_id + self._root = Path(root).expanduser().resolve() + self._confirm = confirm or _always_yes + self._max_read = max_read_bytes + self._max_matches = max_matches + + # ── жизненный цикл (McpClient) ──────────────────────────────────────────── + + def start(self) -> None: + if not self._root.is_dir(): + raise RuntimeError(f"fs root не каталог: {self._root}") + + def close(self) -> None: + pass + + # ── описание тулов ──────────────────────────────────────────────────────── + + def list_tools(self) -> list[McpTool]: + return [ + self._tool("list_dir", + "Перечислить содержимое каталога проекта. Пути — " + "относительно корня проекта. Начни с '.' если не знаешь структуру.", + {"path": {"type": "string", + "description": "Относительный путь к каталогу (по умолчанию корень).", + "default": "."}}), + self._tool("read_file", + "Прочитать текстовый файл проекта целиком. Используй для " + "анализа содержимого перед изменением.", + {"path": {"type": "string", + "description": "Относительный путь к файлу."}}, + required=["path"]), + self._tool("search", + "Искать текст по нескольким файлам проекта сразу (grep по " + "дереву). Возвращает совпадения в формате 'путь:строка: текст'. " + "Используй, чтобы найти все места использования компонента/API.", + {"query": {"type": "string", + "description": "Что искать: подстрока или regex."}, + "glob": {"type": "string", + "description": "Фильтр по имени/пути файла, напр. '*.py' или '*.md'. По умолчанию все файлы.", + "default": "*"}, + "regex": {"type": "boolean", + "description": "Трактовать query как регулярное выражение.", + "default": False}, + "ignore_case": {"type": "boolean", + "description": "Игнорировать регистр.", + "default": False}}, + required=["query"]), + self._tool("write_file", + "Создать новый или перезаписать существующий файл. Сначала " + "показывается unified diff и запрашивается подтверждение " + "пользователя; без подтверждения запись не происходит.", + {"path": {"type": "string", + "description": "Относительный путь к файлу."}, + "content": {"type": "string", + "description": "Полное новое содержимое файла."}}, + required=["path", "content"]), + ] + + def _tool(self, name: str, description: str, properties: dict, + required: Optional[list] = None) -> McpTool: + return McpTool( + server_id = self.server_id, + name = name, + description = description, + input_schema = { + "type": "object", + "properties": properties, + "required": required or [], + }, + ) + + # ── вызов тула (McpClient) ──────────────────────────────────────────────── + + def call_tool(self, name: str, arguments: dict) -> ToolResult: + args = arguments or {} + try: + if name == "list_dir": + return self._list_dir(args.get("path", ".")) + if name == "read_file": + return self._read_file(args["path"]) + if name == "search": + return self._search( + query = args["query"], + glob = args.get("glob", "*") or "*", + regex = bool(args.get("regex", False)), + ignore_case = bool(args.get("ignore_case", False)), + ) + if name == "write_file": + return self._write_file(args["path"], args["content"]) + except KeyError as e: + return ToolResult(text=f"Отсутствует обязательный аргумент: {e}", is_error=True) + except (PathEscapeError, FileNotFoundError, IsADirectoryError, + NotADirectoryError, UnicodeDecodeError, OSError) as e: + return ToolResult(text=f"Ошибка: {e}", is_error=True) + return ToolResult(text=f"Неизвестный тул: {name}", is_error=True) + + # ── реализация тулов ────────────────────────────────────────────────────── + + def _list_dir(self, rel: str) -> ToolResult: + target = self._resolve(rel) + if not target.is_dir(): + return ToolResult(text=f"Не каталог: {rel}", is_error=True) + lines: list[str] = [] + for entry in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name)): + if entry.is_dir(): + if entry.name in _SKIP_DIRS: + continue + lines.append(f"{self._rel(entry)}/") + else: + try: + size = entry.stat().st_size + except OSError: + size = 0 + lines.append(f"{self._rel(entry)} ({size} B)") + body = "\n".join(lines) if lines else "(пусто)" + return ToolResult(text=body) + + def _read_file(self, rel: str) -> ToolResult: + target = self._resolve(rel) + if target.is_dir(): + return ToolResult(text=f"Это каталог, не файл: {rel}", is_error=True) + data = target.read_bytes() + truncated = len(data) > self._max_read + text = data[: self._max_read].decode("utf-8", errors="replace") + if truncated: + text += f"\n… (обрезано, показано {self._max_read} из {len(data)} байт)" + return ToolResult(text=text) + + def _search(self, query: str, glob: str, regex: bool, ignore_case: bool) -> ToolResult: + flags = re.IGNORECASE if ignore_case else 0 + if regex: + matcher = re.compile(query, flags) + def hit(line: str) -> bool: + return matcher.search(line) is not None + else: + needle = query.lower() if ignore_case else query + def hit(line: str) -> bool: + hay = line.lower() if ignore_case else line + return needle in hay + + results: list[str] = [] + truncated = False + for path in self._walk_files(): + rel = self._rel(path) + if not (fnmatch.fnmatch(rel, glob) or fnmatch.fnmatch(path.name, glob)): + continue + try: + if path.stat().st_size > _SEARCH_MAX_FILE_BYTES: + continue + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue # бинарь/недоступно — молча пропускаем + for lineno, line in enumerate(content.splitlines(), 1): + if hit(line): + results.append(f"{rel}:{lineno}: {line.strip()[:200]}") + if len(results) >= self._max_matches: + truncated = True + break + if truncated: + break + + if not results: + return ToolResult(text=f"Совпадений не найдено: {query!r} (glob={glob})") + header = f"Найдено совпадений: {len(results)}" + (" (обрезано)" if truncated else "") + return ToolResult(text=header + "\n" + "\n".join(results)) + + def _write_file(self, rel: str, content: str) -> ToolResult: + target = self._resolve(rel, must_exist=False) + if target.is_dir(): + return ToolResult(text=f"Это каталог, не файл: {rel}", is_error=True) + + old = "" + if target.exists(): + try: + old = target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return ToolResult(text=f"Не могу прочитать текущий файл для diff: {rel}", + is_error=True) + + if old == content: + return ToolResult(text=f"Изменений нет: {rel} уже содержит этот текст.") + + diff = self._unified_diff(rel, old, content) + + if not self._confirm(rel, diff): + return ToolResult(text=f"Запись в {rel} отклонена пользователем. Файл не изменён.") + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + verb = "создан" if not old else "обновлён" + return ToolResult(text=f"Файл {verb}: {rel}\n\n{diff}") + + # ── вспомогательное (чистое) ────────────────────────────────────────────── + + @staticmethod + def _unified_diff(rel: str, old: str, new: str) -> str: + diff = difflib.unified_diff( + old.splitlines(keepends=True), + new.splitlines(keepends=True), + fromfile=f"a/{rel}", + tofile=f"b/{rel}", + ) + text = "".join(diff) + return text or f"(новый файл {rel})" + + def _walk_files(self): + for dirpath, dirnames, filenames in os.walk(self._root): + dirnames[:] = [d for d in dirnames + if d not in _SKIP_DIRS and not d.endswith(".egg-info")] + for name in filenames: + yield Path(dirpath) / name + + def _resolve(self, rel: str, must_exist: bool = True) -> Path: + raw = (self._root / (rel or ".")).resolve() + if raw != self._root and self._root not in raw.parents: + raise PathEscapeError( + f"путь вне корня проекта запрещён: {rel}" + ) + if must_exist and not raw.exists(): + raise FileNotFoundError(f"нет такого пути: {rel}") + return raw + + def _rel(self, path: Path) -> str: + try: + return path.resolve().relative_to(self._root).as_posix() + except ValueError: + return path.name diff --git a/infra/mcp_registry.py b/infra/mcp_registry.py index 37b5e4d..78f14a4 100644 --- a/infra/mcp_registry.py +++ b/infra/mcp_registry.py @@ -74,6 +74,18 @@ def start_all(self) -> None: self._clients[cfg.server_id] = client self._tools_cache = None + def register(self, client: McpClient) -> None: + """Добавить уже готовый (in-process) клиент в реестр. + + Нужен для встроенных источников тулов вроде `LocalFilesystemClient`, + которые не поднимаются из конфига, а собираются в composition root. + Клиент стартуется здесь же; сброс кэша тулов — чтобы `all_tools()` + увидел его тулы. + """ + client.start() + self._clients[client.server_id] = client + self._tools_cache = None + def shutdown(self) -> None: for client in list(self._clients.values()): try: diff --git a/tests/app/test_fs_agent_e2e.py b/tests/app/test_fs_agent_e2e.py new file mode 100644 index 0000000..8f86859 --- /dev/null +++ b/tests/app/test_fs_agent_e2e.py @@ -0,0 +1,97 @@ +"""E2E tool-loop поверх реальных LocalFilesystemClient + StdioMcpRegistry. + +LLM замокан скриптом, файловая система настоящая (tmp_path). Проверяем +сценарий из задания целиком: агент сам ищет использование API по нескольким +файлам, читает файл, затем обновляет документацию — и всё это одним +tool-loop'ом через ToolRouter, без ручного «открой файл X». +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +import pytest + +from app.tool_router import ToolRouter +from infra.local_fs_client import LocalFilesystemClient +from infra.mcp_registry import StdioMcpRegistry + + +@dataclass +class FakeConfigRepo: + """Пустой конфиг: внешних MCP-серверов нет, есть только встроенный fs.""" + def list_all(self): + return [] + + +@dataclass +class FakeLLM: + script: list + calls: list = field(default_factory=list) + + def chat(self, messages, params, system_prompt=None): + return self._next().get("content") or "" + + def chat_with_tools(self, messages, params, tools, system_prompt=None): + self.calls.append(tools) + return self._next() + + def _next(self): + assert self.script, "FakeLLM script exhausted" + return self.script.pop(0) + + +def _call(cid, name, args): + return {"id": cid, "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}} + + +@pytest.fixture +def registry(tmp_path): + (tmp_path / "app").mkdir() + (tmp_path / "app" / "a.py").write_text("api_client.get('/x')\n", encoding="utf-8") + (tmp_path / "app" / "b.py").write_text("api_client.post('/y')\n", encoding="utf-8") + (tmp_path / "README.md").write_text("# Proj\nold docs\n", encoding="utf-8") + reg = StdioMcpRegistry(FakeConfigRepo()) + reg.start_all() + reg.register(LocalFilesystemClient(root=str(tmp_path), confirm=lambda r, d: True)) + return reg, tmp_path + + +def test_fs_tools_are_exposed_to_llm(registry): + reg, _ = registry + names = {t.qualified_name for t in reg.all_tools()} + assert {"fs__search", "fs__read_file", "fs__write_file", "fs__list_dir"} <= names + + +def test_find_usages_then_update_docs(registry): + reg, root = registry + llm = FakeLLM(script=[ + # 1) агент сам решает поискать использования API + {"content": None, "tool_calls": [_call("1", "fs__search", + {"query": "api_client"})]}, + # 2) читает один из найденных файлов + {"content": None, "tool_calls": [_call("2", "fs__read_file", + {"path": "app/a.py"})]}, + # 3) обновляет документацию на основе найденного + {"content": None, "tool_calls": [_call("3", "fs__write_file", + {"path": "README.md", + "content": "# Proj\napi_client: get, post\n"})]}, + {"content": "Готово: нашёл 2 использования, обновил README."}, + ]) + router = ToolRouter(llm, reg) + + result = router.chat( + [{"role": "user", "content": "Найди использования api_client и обнови доки"}], + {"model": "m"}) + + # агент прошёл 3 шага сам + assert result.iterations == 3 + steps = [(inv.tool_name) for inv in result.trace] + assert steps == ["search", "read_file", "write_file"] + # поиск реально нашёл оба файла + assert "app/a.py:" in result.trace[0].result_text + assert "app/b.py:" in result.trace[0].result_text + # документация действительно изменена на диске (воспроизводимо) + assert (root / "README.md").read_text() == "# Proj\napi_client: get, post\n" + assert "обновил README" in result.reply diff --git a/tests/cli/test_fs_confirm.py b/tests/cli/test_fs_confirm.py new file mode 100644 index 0000000..71544e5 --- /dev/null +++ b/tests/cli/test_fs_confirm.py @@ -0,0 +1,37 @@ +"""Тесты CLI-подтверждения записи: раскраска diff и y/n-логика.""" +from __future__ import annotations + +import io + +from cli.ansi import GREEN, RED, RESET +from cli.fs_confirm import colorize_diff, make_interactive_confirm + + +def test_colorize_marks_added_and_removed(): + diff = "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n unchanged\n" + out = colorize_diff(diff) + assert f"{RED}-old{RESET}" in out + assert f"{GREEN}+new{RESET}" in out + # +++/--- заголовки не должны попасть под +/− раскраску как обычные строки + assert out.count(RED + "-old") == 1 + + +def test_confirm_yes_variants_approve(): + for ans in ("y", "yes", "да", "Y"): + confirm = make_interactive_confirm(reader=lambda _p, a=ans: a, + stream=io.StringIO()) + assert confirm("f.py", "diff") is True + + +def test_confirm_default_is_no(): + confirm = make_interactive_confirm(reader=lambda _p: "", stream=io.StringIO()) + assert confirm("f.py", "diff") is False + + +def test_confirm_shows_colored_diff(): + buf = io.StringIO() + confirm = make_interactive_confirm(reader=lambda _p: "n", stream=buf) + confirm("f.py", "-gone\n+added\n") + printed = buf.getvalue() + assert "f.py" in printed + assert RED in printed and GREEN in printed diff --git a/tests/infra/test_local_fs_client.py b/tests/infra/test_local_fs_client.py new file mode 100644 index 0000000..435520a --- /dev/null +++ b/tests/infra/test_local_fs_client.py @@ -0,0 +1,156 @@ +"""Тесты LocalFilesystemClient — встроенного источника файловых тулов. + +Проверяем на временном каталоге (tmp_path): +- чтение/листинг/поиск по нескольким файлам; +- sandbox: выход за корень запрещён; +- запись через confirm: подтверждено пишет, отклонено — нет; +- diff в результате и в аргументах confirm. +""" +from __future__ import annotations + +import pytest + +from infra.local_fs_client import LocalFilesystemClient, PathEscapeError + + +@pytest.fixture +def project(tmp_path): + (tmp_path / "app").mkdir() + (tmp_path / "app" / "service.py").write_text( + "def call_api():\n return api_client.get('/x')\n", encoding="utf-8") + (tmp_path / "app" / "other.py").write_text( + "x = 1\napi_client.post('/y')\n", encoding="utf-8") + (tmp_path / "README.md").write_text("# Project\napi docs here\n", encoding="utf-8") + (tmp_path / ".git").mkdir() + (tmp_path / ".git" / "config").write_text("api_client secret\n", encoding="utf-8") + return tmp_path + + +def _client(root, confirm=None): + c = LocalFilesystemClient(root=str(root), confirm=confirm) + c.start() + return c + + +# ── чтение / листинг ────────────────────────────────────────────────────────── + +def test_list_dir_skips_vcs_dirs(project): + res = _client(project).call_tool("list_dir", {"path": "."}) + assert not res.is_error + assert "app/" in res.text + assert "README.md" in res.text + assert ".git" not in res.text # служебный каталог отфильтрован + + +def test_read_file_returns_content(project): + res = _client(project).call_tool("read_file", {"path": "app/service.py"}) + assert not res.is_error + assert "def call_api" in res.text + + +def test_read_missing_file_is_error(project): + res = _client(project).call_tool("read_file", {"path": "nope.py"}) + assert res.is_error + + +# ── поиск по нескольким файлам ──────────────────────────────────────────────── + +def test_search_finds_across_files(project): + res = _client(project).call_tool("search", {"query": "api_client"}) + assert not res.is_error + assert "app/service.py:" in res.text + assert "app/other.py:" in res.text + # .git отфильтрован при обходе — секрет из .git/config не всплывает + assert ".git/config" not in res.text + + +def test_search_glob_filters_by_extension(project): + res = _client(project).call_tool("search", {"query": "api", "glob": "*.md"}) + assert "README.md:" in res.text + assert "service.py" not in res.text + + +def test_search_regex(project): + res = _client(project).call_tool( + "search", {"query": r"api_client\.(get|post)", "regex": True}) + assert "service.py:" in res.text + assert "other.py:" in res.text + + +def test_search_no_match(project): + res = _client(project).call_tool("search", {"query": "zzz_not_here"}) + assert not res.is_error + assert "не найдено" in res.text.lower() + + +# ── sandbox ─────────────────────────────────────────────────────────────────── + +def test_escape_root_is_error(project): + res = _client(project).call_tool("read_file", {"path": "../../../etc/passwd"}) + assert res.is_error + assert "вне корня" in res.text or "нет такого" in res.text.lower() + + +def test_write_escape_root_is_error(project): + res = _client(project).call_tool( + "write_file", {"path": "../evil.txt", "content": "x"}) + assert res.is_error + + +# ── запись через confirm ────────────────────────────────────────────────────── + +def test_write_creates_file_when_confirmed(project): + seen = {} + def confirm(rel, diff): + seen["rel"], seen["diff"] = rel, diff + return True + res = _client(project, confirm).call_tool( + "write_file", {"path": "docs/ADR-001.md", "content": "# ADR 1\nbody\n"}) + assert not res.is_error + assert (project / "docs" / "ADR-001.md").read_text() == "# ADR 1\nbody\n" + assert seen["rel"] == "docs/ADR-001.md" + assert "+# ADR 1" in seen["diff"] # diff показан до записи + + +def test_write_declined_does_not_touch_file(project): + before = (project / "README.md").read_text() + res = _client(project, confirm=lambda rel, diff: False).call_tool( + "write_file", {"path": "README.md", "content": "# hacked\n"}) + assert not res.is_error # отказ — не ошибка, а информация модели + assert "отклон" in res.text.lower() + assert (project / "README.md").read_text() == before # файл не тронут + + +def test_write_modifies_existing_and_reports_diff(project): + res = _client(project, confirm=lambda r, d: True).call_tool( + "write_file", {"path": "README.md", "content": "# Project\nNEW LINE\n"}) + assert not res.is_error + assert "обновлён" in res.text + assert "+NEW LINE" in res.text + assert "-api docs here" in res.text + + +def test_write_no_change_is_noop(project): + same = (project / "README.md").read_text() + called = {"n": 0} + def confirm(rel, diff): + called["n"] += 1 + return True + res = _client(project, confirm).call_tool( + "write_file", {"path": "README.md", "content": same}) + assert "Изменений нет" in res.text + assert called["n"] == 0 # confirm не дёргается, если diff пуст + + +# ── протокол ────────────────────────────────────────────────────────────────── + +def test_lists_expected_tools(project): + names = {t.name for t in _client(project).list_tools()} + assert names == {"list_dir", "read_file", "search", "write_file"} + + +def test_start_rejects_non_dir(tmp_path): + f = tmp_path / "afile" + f.write_text("x") + with pytest.raises(RuntimeError): + LocalFilesystemClient(root=str(f)).start()