From c02268a180b6e7ad53f24991d4095d1f5750f307 Mon Sep 17 00:00:00 2001 From: RomanKurlygin Date: Tue, 26 May 2026 00:16:45 +0300 Subject: [PATCH] Initial commit: KFU Course Agent Co-authored-by: Cursor --- .env.example | 13 + .gitignore | 38 +++ README.md | 129 ++++++++ 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 | 138 +++++++++ src/agent/agent_workspace/.gitkeep | 0 src/agent/console.py | 16 + src/agent/constants.py | 33 ++ src/agent/gitea_tools.py | 181 +++++++++++ src/agent/graph/__init__.py | 3 + src/agent/graph/pipeline.py | 282 ++++++++++++++++++ src/agent/llm.py | 15 + src/agent/mcp_client.py | 113 +++++++ src/agent/middlewares/__init__.py | 4 + src/agent/middlewares/sanitize_tool_calls.py | 39 +++ .../middlewares/validate_journal_workflow.py | 127 ++++++++ src/agent/prompts.py | 219 ++++++++++++++ src/agent/subagents.py | 37 +++ src/agent/tools.py | 188 ++++++++++++ 24 files changed, 1625 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/console.py 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..9f0ee48 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# --- KFU Course Agent: скопируй в .env и подставь свои значения --- + +# LLM (OpenRouter) — https://openrouter.ai/settings/keys +OPENAI_API_KEY=YOUR_OPENROUTER_KEY_HERE + +# Журнал BroJS — platform.brojs.ru, раздел API-токенов +JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE + +# Gitea — git.brojs.ru → Settings → Applications → Generate Token +GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE + +# Web search (необязательно) — https://tavily.com +TAVILY_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d257cd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Секреты — НИКОГДА не в репо +.env +.env.* +!.env.example +*.pem +*.key +credentials.json +secrets.json +*.secret + +# Рабочая директория агента (клонированные репо, черновики) +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..de390f3 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# KFU Course Agent + +Автоматизация домашних заданий курса **KFU-26-1** на [platform.brojs.ru](https://platform.brojs.ru): агент сам находит открытые задачи, пишет решение, публикует репозиторий и сдаёт работу. + +## Как это устроено + +```mermaid +flowchart LR + A[Журнал BroJS] --> B[Агент] + B --> C[Gitea git.brojs.ru] + B --> D[Ответ в журнал] + D --> E[Проверка] +``` + +| Этап | Действие | +|------|----------| +| 1 | Список незакрытых заданий через Journal MCP | +| 2 | Генерация Python-решения | +| 3 | Репозиторий `git.brojs.ru/glevelll/task-` | +| 4 | Коммит через Gitea API | +| 5 | Ссылка в ответ + `task_submit` | +| 6 | При отклонении — клон, правки, повторная отправка | + +## Требования + +- Python **3.11+** +- Ключи в `.env` (см. ниже) + +**Зависимости:** [deepagents](https://github.com/langchain-ai/deepagents), [LangGraph](https://github.com/langchain-ai/langgraph), [LangChain](https://python.langchain.com/), OpenRouter (`gpt-oss-20b:free`), Gitea API, BroJS Journal MCP. + +## Быстрый старт + +```bash +git clone https://github.com/Glevelll/brojs-agent.git +cd brojs-agent +pip install -r requirements.txt +cp .env.example .env +# заполните .env реальными ключами +``` + +### Переменные окружения + +| Переменная | Назначение | Где взять | +|------------|------------|-----------| +| `OPENAI_API_KEY` | LLM через OpenRouter | [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) | +| `JOURNAL_TOKEN` | API журнала | platform.brojs.ru → профиль → токены | +| `GITEA_TOKEN` | git.brojs.ru | Settings → Applications → Access Tokens | +| `TAVILY_API_KEY` | Поиск в сети (необязательно) | [tavily.com](https://tavily.com) | + +Файл `.env` не коммитится — он в `.gitignore`. + +## Запуск + +### Пайплайн (все открытые coding-задания) + +```bash +python -c " +import asyncio +from src.agent.graph.pipeline import pipeline + +async def main(): + state = {'tasks': [], 'current_index': 0, 'results': [], 'errors': []} + cfg = {'configurable': {'thread_id': 'run-1'}} + out = await pipeline.ainvoke(state, cfg) + print('Готово:', len(out['results'])) + if out['errors']: + print('Сбои:', out['errors']) + +asyncio.run(main()) +" +``` + +### LangGraph Studio + +```bash +pip install "langgraph-cli[inmem]" +langgraph dev --allow-blocking --port 2024 +``` + +UI: [LangSmith Studio](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(): + cfg = {'configurable': {'thread_id': 'chat-1'}} + while True: + q = input('> ').strip() + if q in ('exit', 'quit', 'q'): + break + r = await agent.ainvoke({'messages': [HumanMessage(content=q)]}, cfg) + print(r['messages'][-1].content) + +asyncio.run(main()) +" +``` + +## Карта репозитория + +``` +. +├── agent.py # экспорт для langgraph dev +├── langgraph.json +├── requirements.txt +├── .env.example +└── src/agent/ + ├── agent.py # оркестратор, homework, rework + ├── graph/pipeline.py # пакетная обработка заданий + ├── prompts.py + ├── gitea_tools.py + ├── mcp_client.py + ├── middlewares/ + └── agent_workspace/ # клоны репозиториев +``` + +## Секреты в коде + +Плейсхолдеры подхватываются из `.env`: + +| Модуль | Переменная | +|--------|------------| +| `llm.py` | `OPENAI_API_KEY` | +| `mcp_client.py` | `JOURNAL_TOKEN` | +| `gitea_tools.py` | `GITEA_TOKEN` | diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..bb05cd1 --- /dev/null +++ b/agent.py @@ -0,0 +1,5 @@ +"""Экспорт графов для LangGraph CLI (dev / deploy).""" +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..1584c64 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "brojs-agent" +version = "0.1.0" +description = "Автоматический агент для заданий KFU-26-1 (BroJS + Gitea)" +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..7c8d475 --- /dev/null +++ b/src/agent/agent.py @@ -0,0 +1,138 @@ +"""Создание агентов: главный оркестратор, исполнитель ДЗ, агент пересдачи.""" +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.console import log +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) + +log( + f"инструменты: journal={len(_journal_tools)}, " + f"gitea={len(GITEA_TOOLS)}, git={len(GIT_TOOLS)}" +) + +# · Бэкенды (VFS агента) + +_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 + + +# · Субагенты + +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/console.py b/src/agent/console.py new file mode 100644 index 0000000..cb4668d --- /dev/null +++ b/src/agent/console.py @@ -0,0 +1,16 @@ +"""Единый стиль сообщений в терминале.""" + +_TAG = "kfu-agent" + + +def log(msg: str) -> None: + print(f"[{_TAG}] {msg}") + + +def log_detail(msg: str) -> None: + print(f" · {msg}") + + +def log_block(title: str) -> None: + line = "─" * max(24, len(title) + 4) + print(f"\n{line}\n {title}\n{line}") diff --git a/src/agent/constants.py b/src/agent/constants.py new file mode 100644 index 0000000..d63b38c --- /dev/null +++ b/src/agent/constants.py @@ -0,0 +1,33 @@ +"""Константы проекта: пути, 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..454b492 --- /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() + +# токен Gitea — см. .env → GITEA_TOKEN +_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..b41986a --- /dev/null +++ b/src/agent/graph/pipeline.py @@ -0,0 +1,282 @@ +"""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.console import log +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: + log(f"пропуск (не coding): {', '.join(t['title'] for t in skipped)}") + + log(f"в очереди: {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..bfc357f --- /dev/null +++ b/src/agent/llm.py @@ -0,0 +1,15 @@ +"""Инициализация LLM через OpenRouter.""" +import os + +from dotenv import load_dotenv +from langchain_openai import ChatOpenAI + +load_dotenv() + +# ключ OpenRouter — см. .env.example → OPENAI_API_KEY +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..b0ccb38 --- /dev/null +++ b/src/agent/mcp_client.py @@ -0,0 +1,113 @@ +"""Загрузка инструментов 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 + +from src.agent.console import log, log_block, log_detail + +load_dotenv() + +JOURNAL_SERVER_NAME = "journal-bh-professor" +JOURNAL_PREFIX = f"mcp__{JOURNAL_SERVER_NAME}__" + +# см. .env: JOURNAL_TOKEN +_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: + log(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: + """Загружает и возвращает разбитые на группы инструменты журнала.""" + log_block("Journal MCP") + tools_by_server = _load_tools_sync() + _rename_tools(tools_by_server) + + journal_tools = tools_by_server.get(JOURNAL_SERVER_NAME, []) + + log(f"{JOURNAL_SERVER_NAME}: {len(journal_tools)} tool(s)") + for t in journal_tools: + log_detail(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..9ef8118 --- /dev/null +++ b/src/agent/prompts.py @@ -0,0 +1,219 @@ +"""Системные промпты для всех агентов.""" + +# · Субагент: веб-исследование + +research_instructions = """ +Ты — субагент интернет-исследования. Твоя задача — найти релевантные источники, +открыть нужные страницы и вернуть аккуратную выжимку по реально прочитанным материалам. + +## Доступные инструменты +- `web_search`: ищет кандидатов в интернете и возвращает сниппеты с URL. +- `get_page_content`: открывает конкретную страницу по URL и возвращает её содержимое. + +## Один инструмент за шаг +За одно сообщение — **только один** вызов. Дождись ответа, затем при необходимости сделай следующий вызов. + +## Правила +1. Считай результат `web_search` только черновой наводкой. Сниппеты не являются доказательством. +2. Любой факт подтверждён только после успешного `get_page_content`. +3. Не придумывай URL, цитаты, даты или факты. +4. Если страницу не удалось открыть, честно скажи об этом. + +## Формат ответа +- `Короткий вывод:` 2-5 предложений. +- `Подтверждено по страницам:` список фактов с URL. +- `Не подтверждено:` что осталось на уровне сниппетов. +- `Открытые источники:` список URL с успешно загруженным контентом. +""" + +# · Субагент: журнал (задания и сдачи) + +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..aebf765 --- /dev/null +++ b/src/agent/tools.py @@ -0,0 +1,188 @@ +"""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 + +_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 (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]