Skip to content

feat(fs): файловые тулы для агента — читает/ищет/пишет файлы проекта - #17

Merged
Sermage merged 2 commits into
masterfrom
feat/fs-file-tools
Jul 19, 2026
Merged

feat(fs): файловые тулы для агента — читает/ищет/пишет файлы проекта#17
Sermage merged 2 commits into
masterfrom
feat/fs-file-tools

Conversation

@Sermage

@Sermage Sermage commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Что это

Прокачивает jarvis-cli до агента, который реально работает с файлами проекта, а не просто отвечает текстом. Ключевая идея: в проекте уже был весь tool-calling каркас (ToolRouter, McpRegistry, протокол McpClient, live-прогресс) — не хватало только источника файловых инструментов. Он и добавлен, бесшовно встраиваясь в существующий tool-loop.

Как работает

infra/local_fs_client.py::LocalFilesystemClient реализует тот же протокол McpClient, но операции идут в ФС напрямую (не по 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, по умолчанию — cwd, откуда запущен jarvis, так что агента можно подключить к любому проекту из его терминала). Выход за корень (.., симлинк) отклоняется; служебные каталоги (.git, .venv, …) не обходятся.
  • Запись = diff + подтверждение: write_file считает unified diff и вызывает инъектируемый confirm(rel, diff)->bool. В CLI (cli/fs_confirm.py) печатается цветной diff (удалённые строки красным, добавленные зелёным) и запрашивается y/N — без подтверждения на диск ничего не пишется.
  • Активно при provider=deepseek (tool calling).

Сценарии (агент сам инициирует, цель уровня «что», а не «открой файл X»)

  1. Найти использования APIfs__search по нескольким файлам + чтение.
  2. Сгенерировать файл (ADR/changelog) — анализ + fs__write_file.
  3. Обновить документацию — правка существующего файла с показом diff.

Воспроизводимость

```bash
JARVIS_DEMO_SCRIPTED=1 python3 examples/fs_agent_demo.py # без ключа, детерминированно
python3 examples/fs_agent_demo.py # реальный DeepSeek
```

Проверено реальным DeepSeek: агент сам поискал использования, прочитал файлы, обработал ошибку несуществующего каталога и сгенерировал ADR с таблицей потребителей.

Тесты

  • tests/infra/test_local_fs_client.py — чтение/поиск/sandbox/запись через confirm (tmp_path)
  • tests/app/test_fs_agent_e2e.py — полный tool-loop через реальный ToolRouter + LocalFilesystemClient, LLM замокан
  • tests/cli/test_fs_confirm.py — раскраска diff и y/n-логика

pytest — 513 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WPCiJi7DVBdV2fjN2x3HMd

…в tool-loop

Добавляет встроенный источник файловых инструментов, дающий агенту реальную
работу с файлами без внешнего MCP-сервера.

- infra/local_fs_client.py: LocalFilesystemClient реализует протокол McpClient
  напрямую над ФС. Тулы fs__list_dir/read_file/search (grep, glob/regex)/write_file.
  Sandbox-root (по умолчанию cwd, override JARVIS_FS_ROOT); выход за корень запрещён.
- Запись = unified diff + инъектируемый confirm(rel, diff)->bool.
- cli/fs_confirm.py: интерактивное подтверждение с цветным diff (красный/зелёный).
- infra/mcp_registry.py: register() для in-process клиента; ToolRouter сам роутит fs__*.
- cli/main.py: сборка в composition root (provider=deepseek).
- examples/fs_agent_demo.py: воспроизводимое демо (JARVIS_DEMO_SCRIPTED=1 без ключа).
- Тесты: infra (tmp_path), e2e через ToolRouter, cli confirm. pytest — зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPCiJi7DVBdV2fjN2x3HMd
@github-actions

Copy link
Copy Markdown

🤖 AI-ревью

🐞 Потенциальные баги

  1. cli/main.py:245 — Отсутствует проверка, что read_input определена в области видимости. В diff импорт read_input не показан, но используется в make_interactive_confirm(reader=read_input). Если read_input не импортирована в main.py, возникнет NameError.

  2. cli/fs_confirm.py:38 — Использование import sys внутри функции make_interactive_confirm. Это не баг, но необычный стиль: sys импортируется при каждом вызове make_interactive_confirm, хотя нужен только один раз для создания дефолтного stream=None.

  3. examples/fs_agent_demo.py:128-129 — При скриптованном LLM (_ScriptedLLM.chat) возвращается пустая строка для сообщений без tool_calls. Если скрипт вызовется с другим порядком шагов, метод вернёт пустую строку, что может нарушить логику tool-loop.

🏛 Архитектурные проблемы

  1. cli/main.py:240-250 — Создание LocalFilesystemClient и регистрация в mcp_registry происходит в main(), что соответствует композит-рут. Однако печать f"{DIM}Файловые тулы (fs) активны на {fs_root}.{RESET}" — это UI-логика, смешанная с инфраструктурной инициализацией. Лучше вынести в отдельную функцию или сделать более явным.

  2. cli/main.py:248mcp_registry.register(fs_client) вызывается без предварительной проверки, поддерживает ли StdioMcpRegistry регистрацию не-stdio клиентов. Если register ожидает только Stdio-клиентов, это может сломаться. Из diff не видно изменений в mcp_registry.py, что настораживает.

  3. cli/main.py:245make_interactive_confirm(reader=read_input) предполагает, что read_input — callable с одной строкой. В cli/main.py read_input может быть определён по-разному. Если он имеет другую сигнатуру (например, принимает prompt), вызов упадёт.

💡 Рекомендации

  1. cli/main.py:245 — Явно импортировать read_input или передавать input напрямую: make_interactive_confirm(reader=input).

  2. cli/fs_confirm.py:38 — Перенести import sys наверх модуля и использовать глобальный sys.stdout по умолчанию: stream=Nonestream=sys.stdout в аргументах.

  3. cli/main.py:248 — Проверить совместимость LocalFilesystemClient с методом register в StdioMcpRegistry. Возможно, нужно адаптировать register для приёма не-stdio клиентов.

  4. cli/main.py:245-251 — Выделить инициализацию fs-тулов в отдельную функцию (например, _init_fs_tools(mcp_registry, read_input)), чтобы main() был чище и легче тестировался.

  5. examples/fs_agent_demo.py — Для скриптованного режима сделать возврат из chat более надёжным: возвращать {"content": "", "tool_calls": []} вместо пустой строки при отсутствии tool_calls.


Контекст (RAG): cli/main.py · CLAUDE.md — Jarvis CLI > Конвенции · domain/mcp.py — не накладывает, поэтому при склейке server__tool обе части санитизируем. · infra/mcp_stdio_client.py

Сгенерировано jarvis-cli · review_pr.py

По замечанию AI-ревью: sys импортировался внутри make_interactive_confirm
при каждом вызове. Перенесён на уровень модуля.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPCiJi7DVBdV2fjN2x3HMd
@github-actions

Copy link
Copy Markdown

🤖 AI-ревью

🐞 Потенциальные баги

— замечаний нет (в представленном diff багов не обнаружено).

🏛 Архитектурные проблемы

  1. cli/main.py:248fs_root вычисляется до обработки JARVIS_FS_ROOT без склеивания с HOME
    Если переменная окружения JARVIS_FS_ROOT пустая или не задана, берётся os.getcwd(). Но если задана относительным путём (~/myproject), то os.path.expanduser применяется после выбора ветки — к уже абсолютному getcwd() ничего не изменит, а относительный путь из переменной не будет расширен. На практике os.getcwd() всегда абсолютный, так что проблема возникает только если пользователь передал что-то вроде JARVIS_FS_ROOT=~/project.

  2. cli/main.py:258-260 — исключение при регистрации fs_client перехватывается молча, но mcp_registry уже мог частично измениться
    Если register() выбросит исключение, mcp_registry останется в неконсистентном состоянии (часть тулов могла быть зарегистрирована до ошибки). Ретрансляция не предусмотрена, и пользователь получит только жёлтое предупреждение, хотя registry может быть сломан.

💡 Рекомендации

  1. cli/main.py:248 — добавить expanduser до выбора корня
    Заменить:

    fs_root = os.path.expanduser(
        os.environ.get("JARVIS_FS_ROOT", "").strip() or os.getcwd()
    )

    На:

    raw_root = os.environ.get("JARVIS_FS_ROOT", "").strip()
    fs_root = os.path.expanduser(raw_root) if raw_root else os.getcwd()
  2. cli/main.py:258-260 — обернуть регистрацию fs_client в отдельный try/except с откатом
    Рекомендуется либо регистрировать fs_client до других тулов (чтобы при ошибке registry пересоздавался), либо добавить fallback-логику:

    try:
        fs_client = LocalFilesystemClient(...)
        mcp_registry.register(fs_client)
        print(...)
    except Exception as e:
        print(f"{YELLOW}Файловые тулы не поднялись: {e}{RESET}")
        # Если нужно — удалить частично зарегистрированные тулы
  3. cli/main.py:248 — документировать поведение JARVIS_FS_ROOT в help или комментарии
    Сейчас корень описан только в CLAUDE.md. В комментарии в main.py стоит явно указать, что по умолчанию берётся CWD, и что можно переопределить через JARVIS_FS_ROOT (с поддержкой ~).


Контекст (RAG): cli/main.py · CLAUDE.md — Jarvis CLI > Конвенции · domain/mcp.py — не накладывает, поэтому при склейке server__tool обе части санитизируем.

Сгенерировано jarvis-cli · review_pr.py

@Sermage
Sermage merged commit f3ec15f into master Jul 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant