commit c95d7375cc93651df489224c8282bc77f35ae343 Author: gleb Date: Mon May 25 23:08:14 2026 +0300 feat: initial brojs-agent implementation 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 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]