Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`, не в репо
Expand Down
1 change: 1 addition & 0 deletions cli/ansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
58 changes: 58 additions & 0 deletions cli/fs_confirm.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
160 changes: 160 additions & 0 deletions examples/fs_agent_demo.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading