From c95d7375cc93651df489224c8282bc77f35ae343 Mon Sep 17 00:00:00 2001 From: gleb Date: Mon, 25 May 2026 23:08:14 +0300 Subject: [PATCH] feat: initial brojs-agent implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-агент для автоматического выполнения заданий курса KFU-26-1. Стек: deepagents + LangGraph + LangChain + OpenRouter (gpt-oss-20b:free). - Главный агент-оркестратор с субагентами - homework_direct_agent и rework_agent для сдачи/пересдачи - Gitea REST API инструменты (git.brojs.ru/glevelll) - BroJS Journal MCP клиент (platform.brojs.ru) - LangGraph pipeline: последовательное выполнение всех заданий - Middleware: SanitizeToolCalls + ValidateJournalWorkflow Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 19 ++ .gitignore | 31 ++ README.md | 151 +++++++++ agent.py | 5 + langgraph.json | 8 + pyproject.toml | 24 ++ requirements.txt | 10 + src/__init__.py | 0 src/agent/__init__.py | 3 + src/agent/agent.py | 150 +++++++++ src/agent/agent_workspace/.gitkeep | 0 src/agent/constants.py | 34 +++ src/agent/gitea_tools.py | 181 +++++++++++ src/agent/graph/__init__.py | 3 + src/agent/graph/pipeline.py | 289 ++++++++++++++++++ src/agent/llm.py | 16 + src/agent/mcp_client.py | 111 +++++++ src/agent/middlewares/__init__.py | 4 + src/agent/middlewares/sanitize_tool_calls.py | 39 +++ .../middlewares/validate_journal_workflow.py | 127 ++++++++ src/agent/prompts.py | 229 ++++++++++++++ src/agent/subagents.py | 37 +++ src/agent/tools.py | 192 ++++++++++++ 23 files changed, 1663 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent.py create mode 100644 langgraph.json create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/agent/__init__.py create mode 100644 src/agent/agent.py create mode 100644 src/agent/agent_workspace/.gitkeep create mode 100644 src/agent/constants.py create mode 100644 src/agent/gitea_tools.py create mode 100644 src/agent/graph/__init__.py create mode 100644 src/agent/graph/pipeline.py create mode 100644 src/agent/llm.py create mode 100644 src/agent/mcp_client.py create mode 100644 src/agent/middlewares/__init__.py create mode 100644 src/agent/middlewares/sanitize_tool_calls.py create mode 100644 src/agent/middlewares/validate_journal_workflow.py create mode 100644 src/agent/prompts.py create mode 100644 src/agent/subagents.py create mode 100644 src/agent/tools.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1008363 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ============================================================ +# ЗАГЛУШКИ — замени значения на реальные перед запуском +# ============================================================ + +# OpenRouter API ключ (https://openrouter.ai/settings/keys) +# ⚠️ ЗАГЛУШКА #1 +OPENAI_API_KEY=YOUR_OPENROUTER_KEY_HERE + +# BroJS Journal токен (генерируется на platform.brojs.ru, протухает) +# ⚠️ ЗАГЛУШКА #2 +JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE + +# Gitea токен (git.brojs.ru → Settings → Applications → Access Tokens) +# ⚠️ ЗАГЛУШКА #3 +GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE + +# Tavily API ключ для web_search (https://tavily.com, опционально) +# ⚠️ ЗАГЛУШКА #4 (можно оставить пустым — web_search будет недоступен) +TAVILY_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..023171c --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Секреты — НИКОГДА не в репо +.env + +# Рабочая директория агента (клонированные репо, черновики) +src/agent/agent_workspace/* +!src/agent/agent_workspace/.gitkeep + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +env/ +*.egg-info/ +dist/ +build/ +.eggs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# LangGraph / LangSmith +.langgraph_api/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ccff4d --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# brojs-agent + +AI-агент для автоматического выполнения заданий курса KFU-26-1 на platform.brojs.ru. + +## Что делает агент + +1. Читает незакрытые задания из журнала через BroJS MCP +2. Пишет Python-решение для каждого задания +3. Создаёт репозиторий на `git.brojs.ru/glevelll/task-` +4. Коммитит решение через Gitea API +5. Отправляет ссылку на репозиторий как ответ +6. Сдаёт задание на проверку +7. При пересдаче — клонирует, исправляет, пушит снова + +## Стек + +- Python 3.11+ +- [deepagents](https://github.com/langchain-ai/deepagents) — мультиагентный фреймворк +- [LangGraph](https://github.com/langchain-ai/langgraph) — граф пайплайна +- [LangChain](https://python.langchain.com/) — инструменты и MCP-адаптеры +- OpenRouter → `gpt-oss-20b:free` +- Gitea REST API (git.brojs.ru) +- BroJS Journal MCP (platform.brojs.ru) + +## Установка + +```bash +# Клонировать репозиторий +git clone https://github.com/Glevelll/brojs-agent.git +cd brojs-agent + +# Установить зависимости +pip install -r requirements.txt +# или через uv: +# uv sync + +# Создать .env из шаблона +cp .env.example .env +# Отредактировать .env — заменить заглушки на реальные ключи +``` + +## Настройка `.env` + +```env +OPENAI_API_KEY=sk-or-... # OpenRouter ключ +JOURNAL_TOKEN=jrnl_... # BroJS токен (генерируется на platform.brojs.ru) +GITEA_TOKEN=... # Токен на git.brojs.ru +TAVILY_API_KEY=... # Опционально, для web_search +``` + +### Где взять токены + +| Токен | Где получить | +|-------|-------------| +| `OPENAI_API_KEY` | https://openrouter.ai/settings/keys | +| `JOURNAL_TOKEN` | platform.brojs.ru → профиль → API токены | +| `GITEA_TOKEN` | git.brojs.ru → Settings → Applications → Access Tokens | +| `TAVILY_API_KEY` | https://tavily.com (опционально) | + +> ⚠️ **ВАЖНО**: Никогда не коммить `.env` в репозиторий! Он добавлен в `.gitignore`. + +## Запуск + +### Запуск пайплайна (выполнить все незакрытые задания) + +```bash +python -c " +import asyncio +from src.agent.graph.pipeline import pipeline +from langchain_core.messages import HumanMessage + +async def main(): + result = await pipeline.ainvoke( + {'tasks': [], 'current_index': 0, 'results': [], 'errors': []}, + {'configurable': {'thread_id': 'pipeline-run-1'}} + ) + print(f'Выполнено: {len(result[\"results\"])} заданий') + print(f'Ошибки: {result[\"errors\"]}') + +asyncio.run(main()) +" +``` + +### LangGraph dev server (UI в браузере) + +```bash +# Установить langgraph-cli +pip install "langgraph-cli[inmem]" + +# Запустить dev server +langgraph dev --allow-blocking --port 2024 +# Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 +``` + +### Интерактивный чат с главным агентом + +```bash +python -c " +import asyncio +from src.agent.agent import agent +from langchain_core.messages import HumanMessage + +async def main(): + config = {'configurable': {'thread_id': 'chat-1'}} + while True: + user = input('Вы: ') + if user == 'exit': break + result = await agent.ainvoke( + {'messages': [HumanMessage(content=user)]}, config + ) + print('Агент:', result['messages'][-1].content) + +asyncio.run(main()) +" +``` + +## Структура проекта + +``` +brojs-agent/ +├── .env.example # шаблон переменных окружения +├── .gitignore +├── requirements.txt +├── pyproject.toml +├── langgraph.json +├── agent.py # точка входа для langgraph dev +└── src/ + └── agent/ + ├── agent.py # создание агентов (main, homework, rework) + ├── constants.py # COURSE_ID, GITEA_OWNER, пути + ├── llm.py # LLM через OpenRouter + ├── gitea_tools.py # Gitea REST API инструменты + ├── mcp_client.py # BroJS Journal MCP клиент + ├── tools.py # git + web инструменты + ├── prompts.py # системные промпты + ├── subagents.py # спецификации субагентов + ├── middlewares/ + │ ├── sanitize_tool_calls.py # блокировка несуществующих инструментов + │ └── validate_journal_workflow.py # порядок task_update_answer → task_submit + ├── graph/ + │ └── pipeline.py # LangGraph пайплайн по всем заданиям + └── agent_workspace/ # рабочая директория агента (клонированные репо) +``` + +## Заглушки в коде + +| Файл | Переменная | Что заменить | +|------|------------|-------------| +| `src/agent/llm.py` | `OPENAI_API_KEY` | OpenRouter ключ в `.env` | +| `src/agent/mcp_client.py` | `JOURNAL_TOKEN` | BroJS токен в `.env` | +| `src/agent/gitea_tools.py` | `GITEA_TOKEN` | Gitea токен в `.env` | diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..22f87d8 --- /dev/null +++ b/agent.py @@ -0,0 +1,5 @@ +"""Точки входа для langgraph.json и langgraph dev.""" +from src.agent.agent import agent, homework_direct_agent, rework_agent +from src.agent.graph.pipeline import pipeline + +__all__ = ["agent", "pipeline", "homework_direct_agent", "rework_agent"] diff --git a/langgraph.json b/langgraph.json new file mode 100644 index 0000000..87f705e --- /dev/null +++ b/langgraph.json @@ -0,0 +1,8 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./agent.py:agent", + "pipeline": "./agent.py:pipeline" + }, + "env": ".env" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5c35957 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "brojs-agent" +version = "0.1.0" +description = "AI-агент для автоматического выполнения заданий курса BroJS" +requires-python = ">=3.11" +dependencies = [ + "deepagents>=0.6.3", + "langchain>=1.3.0", + "langchain-openai>=0.3.0", + "langchain-mcp-adapters>=0.1.0", + "langgraph>=1.0.8", + "pydantic-settings>=2.0.0", + "python-dotenv>=1.0.0", + "httpx>=0.27.0", + "markdownify>=0.13.0", + "tavily-python>=0.3.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bbbed46 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +deepagents>=0.6.3 +langchain>=1.3.0 +langchain-openai>=0.3.0 +langchain-mcp-adapters>=0.1.0 +langgraph>=1.0.8 +pydantic-settings>=2.0.0 +python-dotenv>=1.0.0 +httpx>=0.27.0 +markdownify>=0.13.0 +tavily-python>=0.3.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 0000000..5fb1392 --- /dev/null +++ b/src/agent/__init__.py @@ -0,0 +1,3 @@ +from src.agent.agent import agent, homework_direct_agent, rework_agent + +__all__ = ["agent", "homework_direct_agent", "rework_agent"] diff --git a/src/agent/agent.py b/src/agent/agent.py new file mode 100644 index 0000000..7cfa312 --- /dev/null +++ b/src/agent/agent.py @@ -0,0 +1,150 @@ +"""Создание агентов: главный оркестратор, исполнитель ДЗ, агент пересдачи.""" +from deepagents import create_deep_agent +from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend + +from src.agent.constants import ( + AGENT_WORKSPACE_DIR, + AGENTS_MD_VFS_PATH, + BUNDLED_SKILLS_DIR, + SKILLS_VFS_MOUNT, + ensure_agents_md_file, +) +from src.agent.gitea_tools import GITEA_TOOLS +from src.agent.llm import llm +from src.agent.mcp_client import load_journal_toolsets +from src.agent.middlewares import SanitizeToolCallsMiddleware, ValidateJournalWorkflowMiddleware +from src.agent.prompts import ( + homework_doing_instructions, + main_agent_instructions, + rework_instructions, +) +from src.agent.subagents import subagent_specs_without_tools +from src.agent.tools import GIT_TOOLS, WEB_TOOLS + +# --------------------------------------------------------------------------- +# Инициализация +# --------------------------------------------------------------------------- + +ensure_agents_md_file() + +journal = load_journal_toolsets() +_journal_tools = list(journal.courses_lessons_tools) + list(journal.tasks_submissions_tools) + +print(f"=== Загружено: journal={len(_journal_tools)}, gitea={len(GITEA_TOOLS)}, git={len(GIT_TOOLS)} ===") + +# --------------------------------------------------------------------------- +# Бэкенды (виртуальная файловая система агента) +# --------------------------------------------------------------------------- + +_workspace_backend = LocalShellBackend( + root_dir=str(AGENT_WORKSPACE_DIR), + virtual_mode=True, + inherit_env=True, +) +_skills_backend = FilesystemBackend( + root_dir=str(BUNDLED_SKILLS_DIR), + virtual_mode=True, +) +_composite_backend = CompositeBackend( + default=_workspace_backend, + routes={SKILLS_VFS_MOUNT: _skills_backend}, +) + +# --------------------------------------------------------------------------- +# Наборы инструментов +# --------------------------------------------------------------------------- + +_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools] +_web_tools = WEB_TOOLS + +_subagent_tool_map = { + "web_search": _web_tools, + "homework_doing": _homework_tools, + "journal_bh_tasks_submissions": _journal_tools, +} + +# Имена всех инструментов для SanitizeToolCallsMiddleware +_BUILTIN = { + "write_todos", "ls", "read_file", "write_file", "edit_file", + "glob", "grep", "execute", "task", +} +_gitea_names = {t.name for t in GITEA_TOOLS} +_journal_names = {t.name for t in _journal_tools} +_git_names = {t.name for t in GIT_TOOLS} +_web_names = {t.name for t in WEB_TOOLS} + +_main_tool_names = _BUILTIN | _gitea_names + +_subagent_tool_names: dict[str, set[str]] = { + "web_search": _BUILTIN | _web_names, + "homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names, + "journal_bh_tasks_submissions": _BUILTIN | _journal_names, +} + +# --------------------------------------------------------------------------- +# Middleware +# --------------------------------------------------------------------------- + +def _make_subagent_middleware(name: str) -> list: + mw = [SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names[name])] + if name in ("homework_doing", "journal_bh_tasks_submissions"): + mw.append(ValidateJournalWorkflowMiddleware()) + return mw + + +# --------------------------------------------------------------------------- +# Субагенты с инструментами и middleware +# --------------------------------------------------------------------------- + +subagents = [ + { + **spec, + "tools": _subagent_tool_map[spec["name"]], + "middleware": _make_subagent_middleware(spec["name"]), + } + for spec in subagent_specs_without_tools +] + +# --------------------------------------------------------------------------- +# Главный агент +# --------------------------------------------------------------------------- + +agent = create_deep_agent( + model=llm, + tools=list(GITEA_TOOLS), + system_prompt=main_agent_instructions, + backend=_composite_backend, + memory=[AGENTS_MD_VFS_PATH], + subagents=subagents, + middleware=[SanitizeToolCallsMiddleware(known_tools=_main_tool_names)], +) + +# --------------------------------------------------------------------------- +# Агент прямого выполнения ДЗ (без субагентов, все инструменты сразу) +# --------------------------------------------------------------------------- + +homework_direct_agent = create_deep_agent( + model=llm, + tools=_homework_tools, + system_prompt=homework_doing_instructions, + backend=_composite_backend, + middleware=[ + SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]), + ValidateJournalWorkflowMiddleware(), + ], +) + +# --------------------------------------------------------------------------- +# Агент пересдачи +# --------------------------------------------------------------------------- + +rework_agent = create_deep_agent( + model=llm, + tools=_homework_tools, + system_prompt=rework_instructions, + backend=_composite_backend, + middleware=[ + SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]), + ValidateJournalWorkflowMiddleware(), + ], +) diff --git a/src/agent/agent_workspace/.gitkeep b/src/agent/agent_workspace/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/constants.py b/src/agent/constants.py new file mode 100644 index 0000000..eac8e5b --- /dev/null +++ b/src/agent/constants.py @@ -0,0 +1,34 @@ +"""Константы проекта: пути, ID курса, Gitea-настройки.""" +from pathlib import Path + +PACKAGE_DIR = Path(__file__).resolve().parent + +AGENT_WORKSPACE_DIR = PACKAGE_DIR / "agent_workspace" +BUNDLED_SKILLS_DIR = PACKAGE_DIR / "skills" + +AGENTS_MD_FILENAME = "AGENTS.md" +AGENTS_MD_VFS_PATH = "/AGENTS.md" +SKILLS_VFS_MOUNT = "/skills/" + +# ID курса KFU-26-1 на platform.brojs.ru +COURSE_ID = "698b49da77cb6d4d2e43ce78" + +# Gitea +GITEA_OWNER = "glevelll" +GITEA_BASE_URL = "https://git.brojs.ru" + +AGENTS_MD_SEED = """\ +# Память агента + +Здесь хранятся заметки, которые стоит помнить между сообщениями +(предпочтения, договорённости, факты о задании). +Обновляй этот файл через `edit_file`, когда нужно что-то зафиксировать. +""" + + +def ensure_agents_md_file() -> None: + """Создаёт workspace и начальный AGENTS.md, если файла ещё нет.""" + AGENT_WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) + host_path = AGENT_WORKSPACE_DIR / AGENTS_MD_FILENAME + if not host_path.exists(): + host_path.write_text(AGENTS_MD_SEED, encoding="utf-8") diff --git a/src/agent/gitea_tools.py b/src/agent/gitea_tools.py new file mode 100644 index 0000000..6d87aa1 --- /dev/null +++ b/src/agent/gitea_tools.py @@ -0,0 +1,181 @@ +"""Инструменты для работы с Gitea (git.brojs.ru) через REST API.""" +import base64 +import json +import os + +import httpx +from dotenv import load_dotenv +from langchain.tools import tool + +from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER + +load_dotenv() + +# ⚠️ ЗАГЛУШКА #3 — замени в .env: GITEA_TOKEN=ваш_токен_на_git.brojs.ru +_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE") + + +def _headers() -> dict: + return { + "Authorization": f"token {_GITEA_TOKEN}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def _get(path: str, **params) -> dict | list: + resp = httpx.get( + f"{GITEA_BASE_URL}{path}", + headers=_headers(), + params=params, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def _post(path: str, data: dict) -> dict: + resp = httpx.post( + f"{GITEA_BASE_URL}{path}", + headers=_headers(), + json=data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def _put(path: str, data: dict) -> dict: + resp = httpx.put( + f"{GITEA_BASE_URL}{path}", + headers=_headers(), + json=data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +@tool() +def gitea_list_repos() -> str: + """Получить список репозиториев текущего пользователя на git.brojs.ru. + Также возвращает username — используй его как owner во всех операциях. + """ + try: + result = _get("/api/v1/repos/search", limit=50, token=_GITEA_TOKEN) + repos = result.get("data", result) if isinstance(result, dict) else result + names = [r.get("name", "") for r in repos if isinstance(r, dict)] + return ( + f"username (owner): {GITEA_OWNER}\n" + f"Репозитории ({len(names)}): {', '.join(names) or 'нет'}" + ) + except Exception as e: + return f"Ошибка получения репозиториев: {e}" + + +@tool() +def gitea_create_repo(name: str, private: bool = False, description: str = "") -> str: + """Создать новый репозиторий на git.brojs.ru. + + Args: + name: имя репозитория (например task-abc123) + private: сделать приватным (по умолчанию False) + description: описание репозитория + """ + try: + result = _post( + "/api/v1/user/repos", + { + "name": name, + "private": private, + "description": description, + "auto_init": True, + "default_branch": "main", + }, + ) + url = result.get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}") + return f"Репозиторий создан: {url}" + except httpx.HTTPStatusError as e: + if e.response.status_code == 409: + return f"Репозиторий уже существует: {GITEA_BASE_URL}/{GITEA_OWNER}/{name}" + return f"Ошибка создания репозитория: {e.response.text}" + except Exception as e: + return f"Ошибка: {e}" + + +@tool() +def gitea_write_file( + repo: str, + path: str, + content: str, + message: str, + owner: str = GITEA_OWNER, +) -> str: + """Создать или обновить файл в репозитории на git.brojs.ru. + Автоматически коммитит изменения на сервере — git push НЕ нужен. + + Args: + repo: имя репозитория (например task-abc123) + path: путь к файлу (например main.py или src/agent.py) + content: содержимое файла в виде plain text (НЕ base64) + message: сообщение коммита + owner: владелец репозитория (по умолчанию glevelll) + """ + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}" + + try: + # Проверяем существование файла для получения sha + existing = _get(endpoint) + sha = existing.get("sha", "") + result = _put(endpoint, {"message": message, "content": encoded, "sha": sha}) + action = "обновлён" + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # Файл не существует — создаём + try: + result = _post(endpoint, {"message": message, "content": encoded}) + action = "создан" + except Exception as create_err: + return f"Ошибка создания файла {path}: {create_err}" + else: + return f"Ошибка записи файла {path}: {e.response.text}" + except Exception as e: + return f"Ошибка: {e}" + + commit_sha = (result.get("commit") or {}).get("sha", "")[:8] + return f"Файл {path} {action} в {owner}/{repo} (commit: {commit_sha})\nURL: {GITEA_BASE_URL}/{owner}/{repo}/src/branch/main/{path}" + + +@tool() +def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str: + """Получить содержимое файла из репозитория на git.brojs.ru. + + Args: + repo: имя репозитория + path: путь к файлу + owner: владелец репозитория (по умолчанию glevelll) + + Returns: + JSON-строка с полями content (текст), sha, path + """ + try: + result = _get(f"/api/v1/repos/{owner}/{repo}/contents/{path}") + raw = result.get("content", "") + # Gitea возвращает base64 с переносами строк + content = base64.b64decode(raw.replace("\n", "")).decode("utf-8") + sha = result.get("sha", "") + return json.dumps({"content": content, "sha": sha, "path": path}, ensure_ascii=False) + except httpx.HTTPStatusError as e: + return f"Ошибка получения файла {path}: {e.response.text}" + except Exception as e: + return f"Ошибка: {e}" + + +# Список всех gitea-инструментов для удобного импорта +GITEA_TOOLS = [ + gitea_list_repos, + gitea_create_repo, + gitea_write_file, + gitea_get_file, +] diff --git a/src/agent/graph/__init__.py b/src/agent/graph/__init__.py new file mode 100644 index 0000000..f3a9d82 --- /dev/null +++ b/src/agent/graph/__init__.py @@ -0,0 +1,3 @@ +from src.agent.graph.pipeline import pipeline + +__all__ = ["pipeline"] diff --git a/src/agent/graph/pipeline.py b/src/agent/graph/pipeline.py new file mode 100644 index 0000000..49ee65d --- /dev/null +++ b/src/agent/graph/pipeline.py @@ -0,0 +1,289 @@ +"""LangGraph pipeline: последовательно выполняет все незакрытые задания курса.""" +from __future__ import annotations + +import base64 +import json +from typing import TypedDict + +from langchain_core.messages import HumanMessage +from langgraph.graph import START, StateGraph + +from src.agent.agent import homework_direct_agent, rework_agent +from src.agent.constants import COURSE_ID, GITEA_OWNER +from src.agent.gitea_tools import _get as gitea_get +from src.agent.mcp_client import JOURNAL_PREFIX, load_journal_toolsets + +# --------------------------------------------------------------------------- +# Типы состояния +# --------------------------------------------------------------------------- + +class TaskInfo(TypedDict): + id: str + title: str + status: str + + +class PipelineState(TypedDict): + tasks: list[TaskInfo] + current_index: int + results: list[dict] + errors: list[str] + + +# --------------------------------------------------------------------------- +# Вспомогательные функции +# --------------------------------------------------------------------------- + +_journal = load_journal_toolsets() + + +def _get_journal_tool(suffix: str): + all_tools = _journal.courses_lessons_tools + _journal.tasks_submissions_tools + target = f"{JOURNAL_PREFIX}{suffix}" + for t in all_tools: + if t.name == target: + return t + return None + + +def _parse_text(raw) -> str: + if isinstance(raw, list): + for item in raw: + if isinstance(item, dict) and item.get("type") == "text": + return item.get("text", "") + return str(raw) + + +def _parse_tasks(raw) -> list[TaskInfo]: + text = _parse_text(raw) if not isinstance(raw, str) else raw + try: + data = json.loads(text) + except (json.JSONDecodeError, TypeError): + return [] + items = data.get("tasks", data) if isinstance(data, dict) else data + if not isinstance(items, list): + return [] + result = [] + for item in items: + t = item.get("task", item) if isinstance(item, dict) else {} + tid = t.get("id", "") + if tid: + result.append(TaskInfo( + id=tid, + title=t.get("title", t.get("name", "")), + status=item.get("status", "") if isinstance(item, dict) else "", + )) + return result + + +_CODING_KW = [ + "code", "напиши", "реализуй", "python", "langchain", "langgraph", + "агент", "agent", "граф", "graph", "файл", "функц", "программ", + "скрипт", "алгоритм", "библиотек", "api", "сервер", "модуль", "класс", +] + + +def _is_coding(task: TaskInfo) -> bool: + title = (task.get("title") or "").lower() + return any(kw in title for kw in _CODING_KW) + + +async def _task_text(task_id: str) -> str: + tool = _get_journal_tool("task_text") + if not tool: + return "" + try: + return _parse_text(await tool.ainvoke({"taskId": task_id})) + except Exception: + return "" + + +async def _task_json(task_id: str) -> dict: + tool = _get_journal_tool("task_get") + if not tool: + return {} + try: + raw = _parse_text(await tool.ainvoke({"taskId": task_id})) + return json.loads(raw) + except Exception: + return {} + + +async def _existing_repo_url(task_id: str) -> str | None: + data = await _task_json(task_id) + url = (data.get("answer") or {}).get("content", "") + if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"): + return url + return None + + +async def _verify_repo(repo_name: str) -> dict: + """Проверяет наличие ключевых файлов в репозитории через Gitea API.""" + verification: dict = {"files_found": [], "files_missing": [], "issues": []} + for fname in ["main.py", "requirements.txt", "src/main.py", "app.py"]: + try: + result = gitea_get(f"/api/v1/repos/{GITEA_OWNER}/{repo_name}/contents/{fname}") + raw = result.get("content", "") + content = base64.b64decode(raw.replace("\n", "")).decode("utf-8") + verification["files_found"].append(fname) + if len(content.strip()) < 50: + verification["issues"].append(f"{fname}: слишком короткий ({len(content)} символов)") + if fname == "requirements.txt" and "langchain" not in content: + verification["issues"].append("requirements.txt: нет зависимости langchain") + except Exception: + if fname in ("main.py", "requirements.txt"): + verification["files_missing"].append(fname) + return verification + + +def _needs_retry(v: dict) -> bool: + has_code = any(f in v["files_found"] for f in ["main.py", "src/main.py", "app.py"]) + has_reqs = "requirements.txt" in v["files_found"] + return not has_code or not has_reqs or bool(v["issues"]) + + +def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str: + lines = [f"Решение задания {task['id']} нуждается в доработке.", ""] + if v["files_missing"]: + lines.append(f"Отсутствуют файлы: {', '.join(v['files_missing'])}") + for issue in v["issues"]: + lines.append(f"Проблема: {issue}") + lines += [ + "", + f"Репозиторий: https://git.brojs.ru/{GITEA_OWNER}/{repo_name}", + "", + "Требуется:", + "- Напиши ПОЛНЫЙ код в main.py (не только requirements.txt)", + "- Добавь requirements.txt с зависимостями (langchain>1.0.0)", + "- Используй gitea_write_file для исправления файлов", + "- Вызови task_update_answer и task_submit", + ] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Узлы графа +# --------------------------------------------------------------------------- + +MAX_RETRIES = 2 + + +async def fetch_tasks(state: PipelineState) -> dict: + """Загружает все незакрытые задания курса.""" + tool = _get_journal_tool("tasks_list") + if not tool: + return {"tasks": [], "current_index": 0, "results": [], "errors": ["tasks_list не найден"]} + + raw = await tool.ainvoke({"courseId": COURSE_ID}) + all_tasks = _parse_tasks(raw) + + pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)] + coding = [t for t in pending if _is_coding(t)] + skipped = [t for t in pending if not _is_coding(t)] + + if skipped: + print(f"[pipeline] Пропущены не-кодинговые задания: {[t['title'] for t in skipped]}") + + print(f"[pipeline] Найдено {len(coding)} заданий для выполнения") + return {"tasks": coding, "current_index": 0, "results": [], "errors": []} + + +async def process_one_task(state: PipelineState) -> dict: + """Выполняет одно задание.""" + if state["current_index"] >= len(state["tasks"]): + return state + + task = state["tasks"][state["current_index"]] + task_id = task["id"] + results = list(state.get("results", [])) + errors = list(state.get("errors", [])) + + repo_url = await _existing_repo_url(task_id) + is_rework = repo_url is not None + + if is_rework: + data = await _task_json(task_id) + comments = data.get("comments") or data.get("feedback", "") + prompt = ( + f"Пересдача задания.\n\n" + f"ID: {task_id}\n" + f"Название: {task.get('title', '')}\n" + f"Репозиторий: {repo_url}\n" + f"Комментарии преподавателя: {comments}\n\n" + "Внеси исправления и отправь снова." + ) + agent_to_use = rework_agent + else: + task_text = await _task_text(task_id) + prompt = ( + f"Выполни задание.\n\n" + f"ID: {task_id}\n" + f"Название: {task.get('title', '')}\n\n" + f"Текст задания:\n{task_text}\n\n" + "Первая сдача. Напиши код с нуля." + ) + agent_to_use = homework_direct_agent + + try: + result = await agent_to_use.ainvoke( + {"messages": [HumanMessage(content=prompt)]}, + {"configurable": {"thread_id": f"pipeline-task-{task_id}"}}, + ) + last = (result.get("messages") or [{}])[-1] + output = getattr(last, "content", str(last)) + mode = "rework" if is_rework else "first_submission" + + # Верификация репозитория (только для новых сдач) + repo_name = f"task-{task_id}" + verification = {} + retries = 0 + + if not is_rework: + verification = await _verify_repo(repo_name) + while _needs_retry(verification) and retries < MAX_RETRIES: + retries += 1 + fix_msg = _fix_prompt(task, repo_name, verification) + result = await agent_to_use.ainvoke( + {"messages": [HumanMessage(content=fix_msg)]}, + {"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}}, + ) + verification = await _verify_repo(repo_name) + + results.append({ + "task_id": task_id, + "status": "done", + "mode": mode, + "output": output[:500], + "verification": verification, + "retries": retries, + }) + + except Exception as e: + errors.append(f"Задание {task_id} ({'rework' if is_rework else 'new'}): {e}") + + return { + "results": results, + "current_index": state["current_index"] + 1, + "errors": errors, + } + + +def route(state: PipelineState) -> str: + if state["current_index"] < len(state["tasks"]): + return "process_one_task" + return "__end__" + + +# --------------------------------------------------------------------------- +# Сборка графа +# --------------------------------------------------------------------------- + +_builder = StateGraph(PipelineState) +_builder.add_node("fetch_tasks", fetch_tasks) +_builder.add_node("process_one_task", process_one_task) + +_builder.add_edge(START, "fetch_tasks") +_builder.add_conditional_edges("fetch_tasks", route, {"process_one_task": "process_one_task", "__end__": "__end__"}) +_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"}) + +pipeline = _builder.compile() diff --git a/src/agent/llm.py b/src/agent/llm.py new file mode 100644 index 0000000..e1f997e --- /dev/null +++ b/src/agent/llm.py @@ -0,0 +1,16 @@ +"""Инициализация LLM через OpenRouter.""" +import os + +from dotenv import load_dotenv +from langchain_openai import ChatOpenAI + +load_dotenv() + +# ⚠️ ЗАГЛУШКА #1 — замени в .env: OPENAI_API_KEY=sk-or-xxxxxxxx +# Файл .env.example показывает все нужные переменные. +llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://openrouter.ai/api/v1", + api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"), + temperature=0.0, +) diff --git a/src/agent/mcp_client.py b/src/agent/mcp_client.py new file mode 100644 index 0000000..33777e4 --- /dev/null +++ b/src/agent/mcp_client.py @@ -0,0 +1,111 @@ +"""Загрузка инструментов BroJS Journal через MCP (HTTP transport).""" +import asyncio +import os +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +from dotenv import load_dotenv +from langchain_mcp_adapters.client import MultiServerMCPClient + +load_dotenv() + +JOURNAL_SERVER_NAME = "journal-bh-professor" +JOURNAL_PREFIX = f"mcp__{JOURNAL_SERVER_NAME}__" + +# ⚠️ ЗАГЛУШКА #2 — замени в .env: JOURNAL_TOKEN=jrnl_xxxxxxxx +_JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "YOUR_JOURNAL_TOKEN_HERE") + +JOURNAL_MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp" + +# Инструменты для работы с курсами и уроками +JOURNAL_COURSES_LESSONS = frozenset({ + "courses_list", + "lessons_list", +}) + +# Инструменты для работы с заданиями и сдачами +JOURNAL_TASKS_SUBMISSIONS = frozenset({ + "tasks_list", + "task_text", + "task_get", + "task_update_answer", + "task_submit", + "task_comment", + "task_submission_status", +}) + + +@dataclass(frozen=True) +class JournalToolsets: + courses_lessons_tools: list + tasks_submissions_tools: list + + +def _build_mcp_config() -> dict: + return { + JOURNAL_SERVER_NAME: { + "transport": "http", + "url": JOURNAL_MCP_URL, + "headers": { + "Authorization": f"Bearer {_JOURNAL_TOKEN}", + }, + } + } + + +async def _fetch_tools() -> dict[str, list]: + config = _build_mcp_config() + client = MultiServerMCPClient(config) + out: dict[str, list] = {} + for name in config: + try: + out[name] = await client.get_tools(server_name=name) + except Exception as exc: + print(f"MCP '{name}': не удалось загрузить инструменты — {type(exc).__name__}: {exc}") + out[name] = [] + return out + + +def _load_tools_sync() -> dict[str, list]: + """Загружает MCP-инструменты синхронно, корректно обрабатывая уже запущенный event loop.""" + try: + asyncio.get_running_loop() + # Event loop уже запущен (ноутбук / LangGraph dev) — запускаем в отдельном потоке + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(lambda: asyncio.run(_fetch_tools())).result() + except RuntimeError: + # Event loop не запущен — просто asyncio.run + return asyncio.run(_fetch_tools()) + + +def _rename_tools(tools_by_server: dict[str, list]) -> None: + """Добавляет префикс mcp____ к именам инструментов.""" + for server_name, tools in tools_by_server.items(): + for tool in tools: + tool.name = f"mcp__{server_name}__{tool.name}" + + +def _subset(all_tools: list, suffixes: frozenset[str]) -> list: + return [ + t for t in all_tools + if t.name.startswith(JOURNAL_PREFIX) + and t.name.removeprefix(JOURNAL_PREFIX) in suffixes + ] + + +def load_journal_toolsets() -> JournalToolsets: + """Загружает и возвращает разбитые на группы инструменты журнала.""" + print("Загружаем MCP-инструменты журнала...") + tools_by_server = _load_tools_sync() + _rename_tools(tools_by_server) + + journal_tools = tools_by_server.get(JOURNAL_SERVER_NAME, []) + + print(f"=== MCP {JOURNAL_SERVER_NAME}: загружено {len(journal_tools)} инструментов ===") + for t in journal_tools: + print(f" - {t.name}") + + return JournalToolsets( + courses_lessons_tools=_subset(journal_tools, JOURNAL_COURSES_LESSONS), + tasks_submissions_tools=_subset(journal_tools, JOURNAL_TASKS_SUBMISSIONS), + ) diff --git a/src/agent/middlewares/__init__.py b/src/agent/middlewares/__init__.py new file mode 100644 index 0000000..382af07 --- /dev/null +++ b/src/agent/middlewares/__init__.py @@ -0,0 +1,4 @@ +from src.agent.middlewares.sanitize_tool_calls import SanitizeToolCallsMiddleware +from src.agent.middlewares.validate_journal_workflow import ValidateJournalWorkflowMiddleware + +__all__ = ["SanitizeToolCallsMiddleware", "ValidateJournalWorkflowMiddleware"] diff --git a/src/agent/middlewares/sanitize_tool_calls.py b/src/agent/middlewares/sanitize_tool_calls.py new file mode 100644 index 0000000..a11a754 --- /dev/null +++ b/src/agent/middlewares/sanitize_tool_calls.py @@ -0,0 +1,39 @@ +"""Middleware: блокирует вызовы несуществующих инструментов.""" +from __future__ import annotations + +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, AgentState +from langchain_core.messages import ToolMessage + + +class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]): + """Перехватывает вызовы инструментов с неизвестными именами и возвращает + понятное сообщение об ошибке вместо падения рантайма.""" + + known_tools: set[str] + + def _reject(self, request) -> ToolMessage: + name = request.tool_call.get("name", "") + available = sorted(self.known_tools) + return ToolMessage( + content=( + f"Инструмент '{name}' не существует. " + f"Доступные инструменты: {available}. " + "Исправь имя инструмента и попробуй снова." + ), + tool_call_id=request.tool_call["id"], + name=name, + ) + + def wrap_tool_call(self, request, handler): + name = request.tool_call.get("name", "") + if name not in self.known_tools: + return self._reject(request) + return handler(request) + + async def awrap_tool_call(self, request, handler): + name = request.tool_call.get("name", "") + if name not in self.known_tools: + return self._reject(request) + return await handler(request) diff --git a/src/agent/middlewares/validate_journal_workflow.py b/src/agent/middlewares/validate_journal_workflow.py new file mode 100644 index 0000000..6bbf74f --- /dev/null +++ b/src/agent/middlewares/validate_journal_workflow.py @@ -0,0 +1,127 @@ +"""Middleware: валидация порядка вызовов journal-инструментов. + +Проблема: модель иногда вызывает task_submit без предварительного +task_update_answer, что приводит к ошибке 'answer is empty'. +Этот middleware перехватывает task_submit и проверяет историю сообщений. +""" +from __future__ import annotations + +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, AgentState +from langchain_core.messages import ToolMessage +from langchain_core.tools.base import ToolException + +_NO_UPDATE_MSG = ( + "Нельзя вызывать task_submit без предварительного task_update_answer.\n\n" + "Правильный порядок:\n" + '1. task_update_answer(taskId="TASK_ID", answerType="link", content="")\n' + '2. task_submit(taskId="TASK_ID", confirmSubmit=true)' +) + +_SUBMIT_ERR_MSG = ( + "task_submit завершился ошибкой: {error}\n\n" + "Убедись что task_update_answer был вызван с правильными параметрами " + '(answerType="link", content=""), затем повтори task_submit.' +) + +_JOURNAL_ERR_TEMPLATE = "Ошибка journal-инструмента ({tool}): {error}" + + +def _find_prior_update_answer(messages: list, task_id: str) -> bool: + """Проверяет, был ли task_update_answer вызван для данного taskId.""" + for msg in reversed(messages): + if not hasattr(msg, "tool_calls") or not msg.tool_calls: + continue + for tc in msg.tool_calls: + name = tc.get("name", "") + args = tc.get("args", {}) + if "task_update_answer" in name and args.get("taskId") == task_id: + return True + return False + + +class ValidateJournalWorkflowMiddleware(AgentMiddleware[AgentState[Any], Any]): + """Предотвращает task_submit без предварительного task_update_answer.""" + + # ---- sync ---- + + def _safe_submit(self, request, handler) -> ToolMessage: + task_id = request.tool_call.get("args", {}).get("taskId", "") + tool_name = request.tool_call.get("name", "") + messages = request.state.get("messages", []) + + if not _find_prior_update_answer(messages, task_id): + return ToolMessage( + content=_NO_UPDATE_MSG.replace("TASK_ID", task_id), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + try: + return handler(request) + except ToolException as e: + return ToolMessage( + content=_SUBMIT_ERR_MSG.format(error=e), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + + def _safe_journal_call(self, request, handler): + try: + return handler(request) + except ToolException as e: + tool_name = request.tool_call.get("name", "") + return ToolMessage( + content=_JOURNAL_ERR_TEMPLATE.format(tool=tool_name, error=e), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + + def wrap_tool_call(self, request, handler): + name = request.tool_call.get("name", "") + if "task_submit" in name: + return self._safe_submit(request, handler) + if "mcp__journal-bh-professor__" in name: + return self._safe_journal_call(request, handler) + return handler(request) + + # ---- async ---- + + async def _async_safe_submit(self, request, handler) -> ToolMessage: + task_id = request.tool_call.get("args", {}).get("taskId", "") + tool_name = request.tool_call.get("name", "") + messages = request.state.get("messages", []) + + if not _find_prior_update_answer(messages, task_id): + return ToolMessage( + content=_NO_UPDATE_MSG.replace("TASK_ID", task_id), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + try: + return await handler(request) + except ToolException as e: + return ToolMessage( + content=_SUBMIT_ERR_MSG.format(error=e), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + + async def _async_safe_journal_call(self, request, handler): + try: + return await handler(request) + except ToolException as e: + tool_name = request.tool_call.get("name", "") + return ToolMessage( + content=_JOURNAL_ERR_TEMPLATE.format(tool=tool_name, error=e), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + + async def awrap_tool_call(self, request, handler): + name = request.tool_call.get("name", "") + if "task_submit" in name: + return await self._async_safe_submit(request, handler) + if "mcp__journal-bh-professor__" in name: + return await self._async_safe_journal_call(request, handler) + return await handler(request) diff --git a/src/agent/prompts.py b/src/agent/prompts.py new file mode 100644 index 0000000..501e755 --- /dev/null +++ b/src/agent/prompts.py @@ -0,0 +1,229 @@ +"""Системные промпты для всех агентов.""" + +# --------------------------------------------------------------------------- +# Субагент: исследование в интернете +# --------------------------------------------------------------------------- + +research_instructions = """ +Ты — субагент интернет-исследования. Твоя задача — найти релевантные источники, +открыть нужные страницы и вернуть аккуратную выжимку по реально прочитанным материалам. + +## Доступные инструменты +- `web_search`: ищет кандидатов в интернете и возвращает сниппеты с URL. +- `get_page_content`: открывает конкретную страницу по URL и возвращает её содержимое. + +## Один инструмент за шаг +За одно сообщение — **только один** вызов. Дождись ответа, затем при необходимости сделай следующий вызов. + +## Правила +1. Считай результат `web_search` только черновой наводкой. Сниппеты не являются доказательством. +2. Любой факт подтверждён только после успешного `get_page_content`. +3. Не придумывай URL, цитаты, даты или факты. +4. Если страницу не удалось открыть, честно скажи об этом. + +## Формат ответа +- `Короткий вывод:` 2-5 предложений. +- `Подтверждено по страницам:` список фактов с URL. +- `Не подтверждено:` что осталось на уровне сниппетов. +- `Открытые источники:` список URL с успешно загруженным контентом. +""" + +# --------------------------------------------------------------------------- +# Субагент: journal (задания и сдачи) +# --------------------------------------------------------------------------- + +journal_tasks_submissions_instructions = """ +Ты — субагент BroJS Journal: задания (Task) и сдачи. + +## Известные курсы +- KFU-26-1 = `698b49da77cb6d4d2e43ce78` + +## Доступные инструменты (с префиксом mcp__journal-bh-professor__) +- `mcp__journal-bh-professor__courses_list` — список курсов +- `mcp__journal-bh-professor__lessons_list` — уроки курса (нужен courseId) +- `mcp__journal-bh-professor__tasks_list` — задания (нужен courseId) +- `mcp__journal-bh-professor__task_text` — полный текст задания +- `mcp__journal-bh-professor__task_get` — детали задания (включая answer, комментарии) +- `mcp__journal-bh-professor__task_update_answer` — установить ответ (answerType, content) +- `mcp__journal-bh-professor__task_submit` — отправить задание на проверку +- `mcp__journal-bh-professor__task_comment` — оставить комментарий +- `mcp__journal-bh-professor__task_submission_status` — статус сдачи + +## Один инструмент за шаг +За одно сообщение — **только один** вызов. Параллельные вызовы запрещены. + +## Правила +1. Никогда не угадывай ID — бери их только из ответов API. +2. Если курс назван по имени — используй courses_list для получения courseId. +3. task_update_answer **обязателен** перед task_submit. +""" + +# --------------------------------------------------------------------------- +# Субагент: выполнение домашних заданий (первая сдача) +# --------------------------------------------------------------------------- + +homework_doing_instructions = """ +Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА). +У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам. + +courseId = "698b49da77cb6d4d2e43ce78" +Gitea owner = "glevelll" + +ВАЖНО: Journal-инструменты имеют префикс mcp__journal-bh-professor__ + Gitea-инструменты: gitea_create_repo, gitea_write_file, gitea_get_file, gitea_list_repos + Git-инструменты: git_clone, git_pull, git_status, git_add_and_commit, git_push + +## ПОРЯДОК ВЫПОЛНЕНИЯ: + +[1] mcp__journal-bh-professor__task_text({"taskId": ""}) + → Прочитай ПОЛНЫЙ текст задания + +[2] Составь письменный план: + - какие файлы нужны (main.py, requirements.txt, etc.) + - что реализовать в каждом файле + +[3] gitea_create_repo({"name": "task-", "private": false}) + → Создай репозиторий + +[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО: + gitea_write_file({ + "repo": "task-", + "path": "main.py", + "content": "ПОЛНЫЙ КОД ФАЙЛА", + "message": "add main.py" + }) + - gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен + - content — это plain text, НЕ base64 + - ВСЕГДА указывай message + - Один вызов = один файл + +[5] git_clone("https://git.brojs.ru/glevelll/task-") + → Клонируй репозиторий локально для проверки + +[6] Проверь через read_file что код корректен + +[7] mcp__journal-bh-professor__task_update_answer({ + "taskId": "", + "answerType": "link", + "content": "https://git.brojs.ru/glevelll/task-" + }) + → ОБЯЗАТЕЛЬНО перед task_submit! + +[8] Финальная проверка: + ✓ Все файлы записаны? + ✓ Нет pass, TODO, ..., заглушек? + ✓ langchain>1.0.0 в requirements.txt? + ✓ task_update_answer вызван? + +[9] mcp__journal-bh-professor__task_submit({ + "taskId": "", + "confirmSubmit": true + }) + +## ТРЕБОВАНИЯ К КОДУ: +- ПОЛНЫЙ рабочий код, без pass, TODO, ... +- requirements.txt с реальными зависимостями и langchain>1.0.0 +- Соответствие всем требованиям из текста задания +- Используй langchain>=1.2.10 / langgraph>=0.2.0 согласно заданию + +## ЗАПРЕЩЕНО: +- pass, TODO, ..., пустые функции +- langchain<=1.0.0 в requirements.txt +- Пропускать task_update_answer перед task_submit +- Писать код только в requirements.txt без main.py +""" + +# --------------------------------------------------------------------------- +# Субагент: пересдача после ревью +# --------------------------------------------------------------------------- + +rework_instructions = """ +Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя). +У тебя есть ВСЕ инструменты напрямую. Не делегируй. + +courseId = "698b49da77cb6d4d2e43ce78" +Gitea owner = "glevelll" + +Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует. + +## ПОРЯДОК: + +[1] mcp__journal-bh-professor__task_submission_status({"taskId": ""}) + → Проверь статус и получи фидбек + +[2] mcp__journal-bh-professor__task_get({"taskId": ""}) + → Получи URL репозитория из answer.content и прочитай комментарии + +[3] git_clone() + → Клонируй существующий репозиторий в agent_workspace + → = последняя часть URL (например task-abc123) + +[4] Прочитай файлы через read_file, пойми что исправить + +[5] Внеси исправления через edit_file или write_file + +[6] git_add_and_commit("fix: <описание исправлений>", "") + +[7] git_push("") + +[8] mcp__journal-bh-professor__task_update_answer({ + "taskId": "", + "answerType": "link", + "content": "<ТОТ ЖЕ URL репозитория>" + }) + +[9] mcp__journal-bh-professor__task_submit({"taskId": "", "confirmSubmit": true}) + +## ПРАВИЛА: +- Клонируй существующий репозиторий, НЕ создавай новый +- Исправляй ТОЛЬКО то, что указано в комментариях +- task_update_answer обязателен (даже если URL тот же) +- Запрещено: pass, TODO, пустые функции +""" + +# --------------------------------------------------------------------------- +# Главный агент-оркестратор +# --------------------------------------------------------------------------- + +main_agent_instructions = """\ +Ты — главный агент-исполнитель домашних заданий курса KFU-26-1 на platform.brojs.ru. +Твоя роль — получать задания из журнала и выполнять их качественно. + +## Известные курсы +- KFU-26-1 = courseId `698b49da77cb6d4d2e43ce78` + +## Доступные субагенты (вызывай через инструмент `task`) +- `journal_bh_tasks_submissions`: читает задания, проверяет статусы, отправляет ответы +- `homework_doing`: ВЫПОЛНЯЕТ задание (пишет код, создаёт репо, сдаёт) +- `web_search`: ищет информацию в интернете (только если нужно) + +## Прямые Gitea-инструменты (доступны напрямую без субагента) +- `gitea_list_repos` — список репозиториев, также возвращает username +- `gitea_create_repo` — создать репозиторий +- `gitea_write_file` — создать/обновить файл (автокоммит) +- `gitea_get_file` — получить файл + +## Один инструмент за шаг (ОБЯЗАТЕЛЬНО) +В одном сообщении — **только один** вызов любого инструмента (`task`, `ls`, `read_file`, +`write_file`, `edit_file`, `glob`, `grep`, `execute` и т.д.). +Сначала дождись результата, затем следующий вызов. + +## Типовые маршруты + +### Получить список заданий +1. Делегируй `journal_bh_tasks_submissions`: получить tasks_list для courseId + +### Выполнить задание +1. Делегируй `homework_doing`: выполни задание с taskId= + (он сам прочитает текст, создаст репо, напишет код и сдаст) +2. Верни пользователю ссылку на репозиторий + +### Проверить статусы +1. Делегируй `journal_bh_tasks_submissions`: получить статусы всех заданий курса + +## Жёсткие ограничения +- Не вызывай больше одного инструмента за шаг +- Не делегируй субагенту несколько независимых задач сразу +- Не говори что задание выполнено, если оно не было реально выполнено +- Не подменяй требования задания своими догадками +""" diff --git a/src/agent/subagents.py b/src/agent/subagents.py new file mode 100644 index 0000000..d8970d3 --- /dev/null +++ b/src/agent/subagents.py @@ -0,0 +1,37 @@ +"""Спецификации субагентов (без инструментов — добавляются в agent.py).""" +from src.agent.llm import llm +from src.agent.prompts import ( + homework_doing_instructions, + journal_tasks_submissions_instructions, + research_instructions, +) + +subagent_specs_without_tools: list[dict] = [ + { + "name": "web_search", + "description": ( + "Ищет информацию в интернете, находит URL и открывает страницы, " + "чтобы извлекать факты только из реально прочитанного контента" + ), + "model": llm, + "system_prompt": research_instructions, + }, + { + "name": "homework_doing", + "description": ( + "Выполняет домашние задания: читает условие, пишет код, " + "создаёт репозиторий на git.brojs.ru, тестирует решение и сдаёт на проверку" + ), + "model": llm, + "system_prompt": homework_doing_instructions, + }, + { + "name": "journal_bh_tasks_submissions", + "description": ( + "BroJS Journal: задания и сдачи — список заданий, чтение задания, " + "проверка статуса, установка ответа, отправка на проверку" + ), + "model": llm, + "system_prompt": journal_tasks_submissions_instructions, + }, +] diff --git a/src/agent/tools.py b/src/agent/tools.py new file mode 100644 index 0000000..904e8e8 --- /dev/null +++ b/src/agent/tools.py @@ -0,0 +1,192 @@ +"""Git-инструменты и веб-поиск для агента.""" +import os +import subprocess +from pathlib import Path + +import httpx +from dotenv import load_dotenv +from langchain.tools import tool +from markdownify import markdownify + +from src.agent.constants import AGENT_WORKSPACE_DIR, GITEA_BASE_URL + +load_dotenv() + +# --------------------------------------------------------------------------- +# Web tools +# --------------------------------------------------------------------------- + +_BROWSER_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + "Accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,image/apng,*/*;q=0.8" + ), + "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", +} + +_HTTP_CLIENT = httpx.Client(timeout=30.0, follow_redirects=True, headers=_BROWSER_HEADERS) + + +@tool() +def get_page_content(url: str) -> str: + """Получить текстовое содержимое веб-страницы по URL.""" + try: + resp = _HTTP_CLIENT.get(url) + if resp.status_code != 200: + return f"Ошибка {resp.status_code} при загрузке {url}" + return markdownify(resp.text) + except Exception as e: + return f"Не удалось загрузить страницу: {e}" + + +@tool() +def web_search(query: str, max_results: int = 5) -> str: + """Поиск информации в интернете через Tavily. + + Args: + query: поисковый запрос + max_results: максимальное количество результатов + """ + tavily_key = os.getenv("TAVILY_API_KEY", "") + if not tavily_key: + return ( + "Поиск недоступен: TAVILY_API_KEY не задан. " + "Добавь ключ в .env для использования web_search." + ) + try: + from tavily import TavilyClient + client = TavilyClient(api_key=tavily_key) + resp = client.search(query, max_results=max_results) + parts = [] + for r in resp.get("results", []): + parts.append( + f"**{r.get('title', '')}**\n" + f"url: {r.get('url', '')}\n" + f"content: {r.get('content', '')}\n" + ) + return "\n".join(parts) if parts else "Ничего не найдено" + except Exception as e: + return f"Ошибка поиска: {e}" + + +# --------------------------------------------------------------------------- +# Git tools (работают с agent_workspace) +# --------------------------------------------------------------------------- + +def _cwd(subdir: str | None = None) -> str: + if subdir: + return str(AGENT_WORKSPACE_DIR / subdir) + return str(AGENT_WORKSPACE_DIR) + + +def _inject_token(url: str) -> str: + """Вставляет GITEA_TOKEN в URL для аутентификации при git push/pull.""" + token = os.getenv("GITEA_TOKEN", "") + if token and GITEA_BASE_URL.replace("https://", "") in url and "@" not in url: + return url.replace("https://", f"https://oauth2:{token}@") + return url + + +@tool() +def git_clone(url: str, depth: int = 1) -> str: + """Клонировать Git-репозиторий в agent_workspace. + + Args: + url: URL репозитория (например https://git.brojs.ru/glevelll/task-abc) + depth: глубина клонирования (по умолчанию 1 — только последний коммит) + """ + auth_url = _inject_token(url) + result = subprocess.run( + ["git", "clone", "--depth", str(depth), auth_url], + capture_output=True, + text=True, + cwd=str(AGENT_WORKSPACE_DIR), + ) + if result.returncode != 0: + return f"Ошибка клонирования: {result.stderr}" + return f"Репозиторий клонирован в agent_workspace: {result.stdout or 'OK'}" + + +@tool() +def git_pull(path: str | None = None) -> str: + """Обновить репозиторий (git pull). + + Args: + path: подпапка в agent_workspace (например task-abc) + """ + result = subprocess.run( + ["git", "pull"], + capture_output=True, + text=True, + cwd=_cwd(path), + ) + if result.returncode != 0: + return f"Ошибка pull: {result.stderr}" + return result.stdout or "Уже актуально" + + +@tool() +def git_status(path: str | None = None) -> str: + """Показать статус репозитория. + + Args: + path: подпапка в agent_workspace + """ + result = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + cwd=_cwd(path), + ) + return result.stdout or "Нет изменений" + + +@tool() +def git_add_and_commit(message: str, path: str | None = None) -> str: + """Добавить все изменения и создать коммит. + + Args: + message: сообщение коммита + path: подпапка в agent_workspace + """ + cwd = _cwd(path) + add = subprocess.run(["git", "add", "."], capture_output=True, text=True, cwd=cwd) + if add.returncode != 0: + return f"Ошибка git add: {add.stderr}" + + commit = subprocess.run( + ["git", "commit", "-m", message], + capture_output=True, + text=True, + cwd=cwd, + ) + if commit.returncode != 0: + return f"Ошибка git commit: {commit.stderr}" + return commit.stdout + + +@tool() +def git_push(path: str | None = None) -> str: + """Отправить коммиты в удалённый репозиторий (git push). + + Args: + path: подпапка в agent_workspace + """ + result = subprocess.run( + ["git", "push"], + capture_output=True, + text=True, + cwd=_cwd(path), + ) + if result.returncode != 0: + return f"Ошибка push: {result.stderr}" + return result.stdout or "Push выполнен успешно" + + +# Удобный список всех git-инструментов +GIT_TOOLS = [git_clone, git_pull, git_status, git_add_and_commit, git_push] +WEB_TOOLS = [web_search, get_page_content]