From 7e6d3a906bff1ecf1a499f43a9481e3e852ec506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB?= Date: Thu, 28 May 2026 19:03:46 +0300 Subject: [PATCH] Initial commit: homework automation agent for BroJS course KFU-26-1 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 19 + .gitignore | 32 + README.md | 151 ++++ agent.py | 5 + langgraph.json | 8 + pyproject.toml | 24 + requirements.txt | 10 + run_pipeline.py | 46 + run_universal.py | 104 +++ src/__init__.py | 0 src/agent/__init__.py | 3 + src/agent/agent.py | 150 ++++ src/agent/constants.py | 34 + src/agent/gitea_tools.py | 180 ++++ src/agent/graph/__init__.py | 3 + src/agent/graph/pipeline.py | 444 ++++++++++ src/agent/llm.py | 23 + src/agent/mcp_client.py | 110 +++ src/agent/middlewares/__init__.py | 4 + src/agent/middlewares/sanitize_tool_calls.py | 40 + .../middlewares/validate_journal_workflow.py | 144 ++++ src/agent/prompts.py | 783 ++++++++++++++++++ src/agent/subagents.py | 37 + src/agent/tools.py | 192 +++++ 24 files changed, 2546 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 run_pipeline.py create mode 100644 run_universal.py 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/constants.py create mode 100644 src/agent/gitea_tools.py create mode 100644 src/agent/graph/__init__.py create mode 100644 src/agent/graph/pipeline.py create mode 100644 src/agent/llm.py create mode 100644 src/agent/mcp_client.py create mode 100644 src/agent/middlewares/__init__.py create mode 100644 src/agent/middlewares/sanitize_tool_calls.py create mode 100644 src/agent/middlewares/validate_journal_workflow.py create mode 100644 src/agent/prompts.py create mode 100644 src/agent/subagents.py create mode 100644 src/agent/tools.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1008363 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ============================================================ +# ЗАГЛУШКИ — замени значения на реальные перед запуском +# ============================================================ + +# OpenRouter API ключ (https://openrouter.ai/settings/keys) +# ⚠️ ЗАГЛУШКА #1 +OPENAI_API_KEY=YOUR_OPENROUTER_KEY_HERE + +# BroJS Journal токен (генерируется на platform.brojs.ru, протухает) +# ⚠️ ЗАГЛУШКА #2 +JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE + +# Gitea токен (git.brojs.ru → Settings → Applications → Access Tokens) +# ⚠️ ЗАГЛУШКА #3 +GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE + +# Tavily API ключ для web_search (https://tavily.com, опционально) +# ⚠️ ЗАГЛУШКА #4 (можно оставить пустым — web_search будет недоступен) +TAVILY_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f40ba02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Секреты — НИКОГДА не в репо +.env + +# Рабочая директория агента (клонированные репо, черновики) +agent_workspace/ +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/run_pipeline.py b/run_pipeline.py new file mode 100644 index 0000000..e336798 --- /dev/null +++ b/run_pipeline.py @@ -0,0 +1,46 @@ +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) + +from src.agent.graph.pipeline import pipeline + + +async def main(): + print("=" * 60) + print("Запуск pipeline оркестратора") + print("=" * 60) + + result = await pipeline.ainvoke({ + "tasks": [], + "current_index": 0, + "results": [], + "errors": [], + }) + + print("\n" + "=" * 60) + print("ИТОГ") + print("=" * 60) + + results = result.get("results", []) + errors = result.get("errors", []) + + print(f"Выполнено заданий: {len(results)}") + for r in results: + tid = r.get("task_id", "")[:8] + status = r.get("status", "?") + mode = r.get("mode", "?") + retries = r.get("retries", 0) + print(f" {tid}... [{status}] mode={mode} retries={retries}") + + if errors: + print(f"\nОшибки ({len(errors)}):") + for e in errors: + print(f" - {e}") + else: + print("\nОшибок нет.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/run_universal.py b/run_universal.py new file mode 100644 index 0000000..d88acdd --- /dev/null +++ b/run_universal.py @@ -0,0 +1,104 @@ +import asyncio, sys, time +sys.path.insert(0, '.') +print("[1/3] Импортируем агента (MCP init ~20-60с)...") +from langchain_core.messages import HumanMessage +from src.agent.agent import homework_direct_agent +from src.agent.graph.pipeline import _invoke_with_retry, _get_journal_tool, _parse_text +print("[2/3] Агент готов, запускаем задание...") + +TASK_ID = "6a1855055db1b0a5ea224b8b" +OWNER = "KirillKutlakhmetov" +REPO = f"task-{TASK_ID}" +URL = f"https://git.brojs.ru/{OWNER}/{REPO}" + +def _was_submitted(msgs: list) -> bool: + for m in msgs: + for tc in getattr(m, "tool_calls", []): + if "task_submit" in tc.get("name", ""): + return True + return False + +async def _force_submit(task_id: str): + update = _get_journal_tool("task_update_answer") + submit = _get_journal_tool("task_submit") + if not update or not submit: + print("[!] journal-инструменты недоступны") + return + for attempt in range(1, 6): + try: + print(f"[AUTO] task_update_answer (попытка {attempt})") + r1 = await update.ainvoke({"taskId": task_id, "answerType": "link", "content": URL}) + print(f"[AUTO] update: {_parse_text(r1)[:120]}") + await asyncio.sleep(3) + print(f"[AUTO] task_submit") + r2 = await submit.ainvoke({"taskId": task_id, "confirmSubmit": True}) + print(f"[AUTO] submit: {_parse_text(r2)[:120]}") + return + except BaseException as e: + full = repr(e) + if hasattr(e, 'exceptions'): + full += " ".join(repr(sub) for sub in e.exceptions) + if "429" in full and attempt < 5: + print(f"[AUTO] Rate limit, жду 70с...") + await asyncio.sleep(70) + else: + raise + +async def main(): + prompt = f"""Выполни задание из курса BroJS. + +taskId: {TASK_ID} +owner: {OWNER} +repo: {REPO} + +ШАГ 1. Прочитай задание полностью: + mcp__journal-bh-professor__task_text(taskId='{TASK_ID}') + +ШАГ 2. Создай репозиторий: + gitea_create_repo(name='{REPO}') + +ШАГ 3. Напиши и загрузи все нужные файлы через gitea_write_file. + - Пиши полный рабочий код, без заглушек и TODO + - Минимум 4 файла: main.py, requirements.txt, README.md + модули по заданию + - Читай файлы репозитория только через gitea_get_file, не read_file + +ШАГ 4. Отправь ответ: + mcp__journal-bh-professor__task_update_answer( + taskId='{TASK_ID}', + answerType='link', + content='{URL}' + ) + +ШАГ 5. Сдай задание (ОБЯЗАТЕЛЬНО): + mcp__journal-bh-professor__task_submit( + taskId='{TASK_ID}', + confirmSubmit=True + )""" + + config = {"configurable": {"thread_id": f"hw-{TASK_ID}-{int(time.time())}"}} + print(f"[3/3] Агент работает...") + result = await _invoke_with_retry( + homework_direct_agent, + {"messages": [HumanMessage(content=prompt)]}, + config, + ) + print("[ГОТОВО] Агент завершил работу.") + msgs = result.get("messages", []) + print(f"[{len(msgs)} сообщений]") + for i, m in enumerate(msgs): + role = type(m).__name__ + tcs = getattr(m, "tool_calls", []) + content_str = str(getattr(m, "content", "")) + if tcs: + for tc in tcs: + print(f" [{i}] {role} -> {tc['name']}({str(tc.get('args',''))[:120]})") + elif content_str.strip(): + print(f" [{i}] {role}: {content_str[:200]}") + + if not _was_submitted(msgs): + print("\n[!] Агент не вызвал task_submit — сдаю автоматически...") + await _force_submit(TASK_ID) + else: + print("\n[OK] Задание сдано агентом самостоятельно.") + +asyncio.run(main()) 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/constants.py b/src/agent/constants.py new file mode 100644 index 0000000..6fcd591 --- /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 = "KirillKutlakhmetov" +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..b5aaea6 --- /dev/null +++ b/src/agent/gitea_tools.py @@ -0,0 +1,180 @@ +"""Инструменты для работы с 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_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..1fe9277 --- /dev/null +++ b/src/agent/graph/pipeline.py @@ -0,0 +1,444 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import re +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", t.get("id", "")) + if not tid: + continue + status = item.get("submissionStatus", item.get("status", "todo")) or "todo" + result.append(TaskInfo( + id=tid, + title=t.get("title", t.get("name", "")), + status=status, + )) + return result + + +_CODING_KW = [ + "code", "напиши", "реализуй", "python", "langchain", "langgraph", + "агент", "agent", "граф", "graph", "файл", "функц", "программ", + "скрипт", "алгоритм", "библиотек", "api", "сервер", "модуль", "класс", + # дополнительные ключевые слова для курса KFU-26-1 + "ai", "llm", "rag", "mcp", "stream", "human", "interrupt", "middleware", + "память", "игра", "текст", "fluency", "практическ", "создай", "создайт", + "задание", "deep", "search", "поиск", +] + +# Задания, которые точно не требуют кода (теория, чтение) +_NON_CODING_TITLES = [] + + +def _is_coding(task: TaskInfo) -> bool: + title = (task.get("title") or "").lower() + if any(nc in title for nc in _NON_CODING_TITLES): + return False + # Если хотя бы одно кодинговое слово — берём задание + if any(kw in title for kw in _CODING_KW): + return True + # Для этого курса все задания — программирование, берём всё + return True + + +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) + + +# --------------------------------------------------------------------------- +# Вспомогательное: retry при rate-limit 429 +# --------------------------------------------------------------------------- + +MAX_RETRIES = 2 +RATE_LIMIT_RETRIES = 5 # сколько раз повторять при 429 +RATE_LIMIT_PAUSE = 90 # секунд ожидания перед повтором +TASK_PAUSE = 15 # пауза между заданиями (снижает давление на rate limit) + + +def _is_rate_limit(exc) -> bool: + """Проверяет, является ли исключение ошибкой rate-limit (429). + + Проверяет repr() и вложенные sub-exceptions BaseExceptionGroup, + т.к. anyio оборачивает 429 глубоко внутри группы исключений. + """ + full = repr(exc) + if "429" in full or "rate_limit" in full.lower(): + return True + if hasattr(exc, "exceptions"): + return any(_is_rate_limit(sub) for sub in exc.exceptions) + return False + + +async def _invoke_with_retry(agent, messages, config): + """Вызывает агента с автоматическим retry при 429. + + anyio (используется mcp-клиентом) оборачивает HTTP-ошибки в BaseExceptionGroup, + который в Python 3.11+ НЕ является подклассом Exception. + Ловим оба типа явно. + """ + for attempt in range(1, RATE_LIMIT_RETRIES + 1): + try: + return await agent.ainvoke(messages, config) + except Exception as e: + if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES: + wait = RATE_LIMIT_PAUSE * attempt + print(f"[pipeline] Rate limit (попытка {attempt}/{RATE_LIMIT_RETRIES}), " + f"жду {wait}с...") + await asyncio.sleep(wait) + else: + raise + except BaseException as e: + # BaseExceptionGroup от anyio — оборачивает 429 и другие HTTP-ошибки + if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES: + wait = RATE_LIMIT_PAUSE * attempt + print(f"[pipeline] Rate limit BaseExceptionGroup " + f"(попытка {attempt}/{RATE_LIMIT_RETRIES}), жду {wait}с...") + await asyncio.sleep(wait) + else: + raise + + +# --------------------------------------------------------------------------- +# Узлы графа +# --------------------------------------------------------------------------- + + +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 = None + for attempt in range(1, 6): + try: + raw = await tool.ainvoke({"courseId": COURSE_ID}) + break + except BaseException as e: + full = repr(e) + if hasattr(e, "exceptions"): + full += " ".join(repr(sub) for sub in e.exceptions) + if "429" in full and attempt < 5: + wait = 90 * attempt + print(f"[pipeline] fetch_tasks: rate limit, жду {wait}с (попытка {attempt}/5)...") + await asyncio.sleep(wait) + else: + return {"tasks": [], "current_index": 0, "results": [], "errors": [f"fetch_tasks ошибка: {full[:200]}"]} + + if raw is None: + return {"tasks": [], "current_index": 0, "results": [], "errors": ["fetch_tasks: не удалось получить задания"]} + + all_tasks = _parse_tasks(raw) + + # Статусы которые означают "надо делать/переделывать" + ACTIVE_STATUSES = { + "todo", "in_progress", "inprogress", "rework", "rejected", + "needs_rework", "revision", "in_work", "inwork", "active", + "open", "new", "assigned", "", None, + } + pending = [t for t in all_tasks if t["status"] in ACTIVE_STATUSES] + 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]}") + + # Печатаем все найденные для диагностики + for t in all_tasks: + marker = "→ БЕРЁМ" if t in coding else " пропуск" + print(f"[pipeline] {marker}: [{t['status']}] {t['title'][:60]} ({t['id'][:8]})") + + print(f"[pipeline] Найдено {len(coding)} заданий для выполнения") + return {"tasks": coding, "current_index": 0, "results": [], "errors": []} + + +def _was_submitted(msgs: list) -> bool: + for m in msgs: + for tc in getattr(m, "tool_calls", []): + if "task_submit" in tc.get("name", ""): + return True + return False + + +async def _force_submit(task_id: str, repo_url: str) -> None: + """Страховка: сдаёт задание если агент не сделал этого сам.""" + update = _get_journal_tool("task_update_answer") + submit = _get_journal_tool("task_submit") + if not update or not submit: + print(f"[pipeline] [!] journal-инструменты недоступны, сдать {task_id[:8]} не удалось") + return + for attempt in range(1, 6): + try: + print(f"[pipeline] [AUTO] task_update_answer для {task_id[:8]} (попытка {attempt})") + r1 = await update.ainvoke({"taskId": task_id, "answerType": "link", "content": repo_url}) + print(f"[pipeline] [AUTO] update: {_parse_text(r1)[:80]}") + await asyncio.sleep(3) + print(f"[pipeline] [AUTO] task_submit для {task_id[:8]}") + r2 = await submit.ainvoke({"taskId": task_id, "confirmSubmit": True}) + print(f"[pipeline] [AUTO] submit: {_parse_text(r2)[:80]}") + return + except BaseException as e: + full = repr(e) + if hasattr(e, "exceptions"): + full += " ".join(repr(sub) for sub in e.exceptions) + if "429" in full and attempt < 5: + print(f"[pipeline] [AUTO] Rate limit, жду 70с...") + await asyncio.sleep(70) + else: + print(f"[pipeline] [AUTO] Ошибка при сдаче: {full[:200]}") + return + + +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 + final_repo_url = repo_url or f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}" + + if is_rework: + prompt = ( + f"Выполни задание.\n\n" + f"taskId: {task_id}\n" + f"owner = {GITEA_OWNER}\n\n" + f"1. Сначала прочитай task_get(taskId='{task_id}') — там комментарии преподавателя (они в приоритете).\n" + f"2. Прочитай полный текст задания через task_text(taskId='{task_id}').\n" + f"3. Внеси исправления строго по комментариям преподавателя.\n" + f"4. В requirements.txt включай ТОЛЬКО пакеты из стека задания — не добавляй ничего лишнего.\n\n" + f"ВАЖНО: в конце обязательно вызови task_submit(taskId='{task_id}', confirmSubmit=True)!" + ) + agent_to_use = homework_direct_agent + else: + prompt = ( + f"Выполни задание.\n\n" + f"taskId: {task_id}\n" + f"owner = {GITEA_OWNER}\n\n" + f"1. Сначала прочитай task_get(taskId='{task_id}') — там могут быть комментарии преподавателя которые нужно учесть (они в приоритете).\n" + f"2. Прочитай полный текст задания через task_text(taskId='{task_id}').\n" + f"3. Напиши полное решение строго по требованиям задания.\n" + f"4. В requirements.txt включай ТОЛЬКО пакеты из стека задания — не добавляй ничего лишнего.\n\n" + f"ВАЖНО: в конце обязательно вызови task_submit(taskId='{task_id}', confirmSubmit=True)!" + ) + agent_to_use = homework_direct_agent + + try: + print(f"[pipeline] Задание {task_id[:8]} — {'пересдача' if is_rework else 'первая сдача'}: " + f"{task.get('title','')[:50]}") + + import time as _time + result = await _invoke_with_retry( + agent_to_use, + {"messages": [HumanMessage(content=prompt)]}, + {"configurable": {"thread_id": f"pipeline-task-{task_id}-{int(_time.time())}"}}, + ) + msgs = result.get("messages", []) + last = (msgs or [{}])[-1] + output = getattr(last, "content", str(last)) + mode = "rework" if is_rework else "first_submission" + + if not _was_submitted(msgs): + print(f"[pipeline] [!] Агент не вызвал task_submit для {task_id[:8]} — сдаю автоматически...") + await _force_submit(task_id, final_repo_url) + else: + print(f"[pipeline] [OK] Агент сдал {task_id[:8]} самостоятельно.") + + # Верификация репозитория (только для новых сдач) + 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 _invoke_with_retry( + agent_to_use, + {"messages": [HumanMessage(content=fix_msg)]}, + {"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}}, + ) + verification = await _verify_repo(repo_name) + + print(f"[pipeline] Задание {task_id[:8]} — OK (retries={retries})") + results.append({ + "task_id": task_id, + "status": "done", + "mode": mode, + "output": output[:500], + "verification": verification, + "retries": retries, + }) + + except Exception as e: + print(f"[pipeline] Задание {task_id[:8]} — ОШИБКА: {e}") + errors.append(f"Задание {task_id} ({'rework' if is_rework else 'new'}): {e}") + + # Пауза между заданиями чтобы не перегружать rate limit + print(f"[pipeline] Пауза {TASK_PAUSE}с перед следующим заданием...") + await asyncio.sleep(TASK_PAUSE) + + 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..09f0e46 --- /dev/null +++ b/src/agent/llm.py @@ -0,0 +1,23 @@ +import os + +from dotenv import load_dotenv +from langchain_openai import ChatOpenAI + +load_dotenv() + +_provider = os.getenv("LLM_PROVIDER", "openrouter").lower() + +if _provider == "brojs": + llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT", os.getenv("JOURNAL_TOKEN", "")), + temperature=0.0, + ) +else: + llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://openrouter.ai/api/v1", + api_key=os.getenv("OPENAI_API_KEY", ""), + temperature=0.0, + ) diff --git a/src/agent/mcp_client.py b/src/agent/mcp_client.py new file mode 100644 index 0000000..13fdd9f --- /dev/null +++ b/src/agent/mcp_client.py @@ -0,0 +1,110 @@ +"""Загрузка инструментов 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}__" + +_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..033b660 --- /dev/null +++ b/src/agent/middlewares/sanitize_tool_calls.py @@ -0,0 +1,40 @@ +"""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]): + """Перехватывает вызовы инструментов с неизвестными именами и возвращает + понятное сообщение об ошибке вместо падения рантайма.""" + + def __init__(self, known_tools: set[str]): + self.known_tools = known_tools + + 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..9714fc9 --- /dev/null +++ b/src/agent/middlewares/validate_journal_workflow.py @@ -0,0 +1,144 @@ +"""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, + ) + except BaseException as e: + # anyio оборачивает HTTP-ошибки (429 и др.) в BaseExceptionGroup, + # который не является подклассом Exception — ловим отдельно. + tool_name = request.tool_call.get("name", "") + err_str = repr(e) + if hasattr(e, "exceptions"): + err_str += " ".join(repr(sub) for sub in e.exceptions) + if "429" in err_str or "rate" in err_str.lower(): + return ToolMessage( + content=( + f"Ошибка rate limit (429) от Journal API при вызове {tool_name}.\n" + "Подожди ~60 секунд и повтори этот же вызов." + ), + tool_call_id=request.tool_call["id"], + name=tool_name, + ) + raise # остальные BaseException (KeyboardInterrupt и т.п.) пробрасываем + + 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..6fd6bae --- /dev/null +++ b/src/agent/prompts.py @@ -0,0 +1,783 @@ +"""Системные промпты для всех агентов.""" + +# --------------------------------------------------------------------------- +# Субагент: исследование в интернете +# --------------------------------------------------------------------------- + +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 = "KirillKutlakhmetov" + +ВАЖНО: 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/KirillKutlakhmetov/task-") + → Клонируй репозиторий локально для проверки + +[6] Проверь через read_file что код корректен + +[7] mcp__journal-bh-professor__task_update_answer({ + "taskId": "", + "answerType": "link", + "content": "https://git.brojs.ru/KirillKutlakhmetov/task-" + }) + → ОБЯЗАТЕЛЬНО перед task_submit! + +[8] Финальная проверка: + ✓ Все файлы записаны через gitea_write_file? + ✓ Нет pass, TODO, ..., заглушек? + ✓ task_update_answer вызван и вернул успех? + +[9] ⚠️ ОБЯЗАТЕЛЬНЫЙ ПОСЛЕДНИЙ ШАГ — БЕЗ НЕГО ЗАДАНИЕ НЕ СДАНО: + mcp__journal-bh-professor__task_submit({ + "taskId": "", + "confirmSubmit": true + }) + → task_update_answer БЕЗ task_submit = задание НЕ сдано, только сохранено. + → task_submit ВСЕГДА должен быть вызван после task_update_answer. + +## ТРЕБОВАНИЯ К КОДУ (обязательно для каждого задания): + +### Файлы — минимум 4 файла: +- `main.py` — точка входа, примеры использования, if __name__ == "__main__" +- `requirements.txt` — все зависимости с версиями +- `README.md` — подробное описание (структура, установка, примеры запуска, описание архитектуры) +- Дополнительные модули по смыслу задания: `models.py`, `agent.py`, `tools.py`, `parser.py`, `server.py` и т.д. + +### Объём — в каждом .py файле минимум 80 строк: +- Полные реализации всех классов и функций +- Никаких `pass`, `TODO`, `...`, заглушек +- Docstrings для классов и методов +- Несколько примеров использования в main.py (минимум 3 разных примера) + +### README.md обязан содержать: +- Описание что делает проект +- Таблицу или список всех файлов с их назначением +- Инструкцию по установке (pip install -r requirements.txt) +- Примеры использования с реальным кодом +- Описание архитектуры / как это работает + +### LLM в коде — ТОЛЬКО BroJS (LLM препода): +```python +llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT"), + temperature=0.0, +) +``` +НЕ используй OpenRouter, НЕ хардкодь ключи. +ИСКЛЮЧЕНИЕ: если задание ЯВНО требует Ollama (`ollama pull llama3` в условии) — используй `ChatOllama` и `OllamaEmbeddings` из `langchain-ollama`. В этом случае НЕ добавляй `langchain-openai` в requirements.txt. + +### requirements.txt — ПРАВИЛА: +⚠️ ВКЛЮЧАЙ ТОЛЬКО пакеты которые РЕАЛЬНО ИСПОЛЬЗУЮТСЯ в коде задания (import есть в коде). +⚠️ НЕ копируй весь список — это СПРАВОЧНИК версий, не список для копирования! + +Справочник допустимых версий (бери версию отсюда только если пакет нужен): +``` +langchain-core>=0.3.0 +langchain-openai>=0.3.0 +langgraph>=0.2.0 +langchain-community>=0.3.0 +pydantic>=2.0 +python-dotenv>=1.0.0 +rich>=13.0.0 +duckduckgo-search>=6.0.0 +fastmcp>=0.1.0 +qdrant-client>=1.7.0 +langchain-qdrant>=0.1.0 +langchain-ollama>=0.1.0 +langchain-text-splitters>=0.1.0 +``` +НЕ придумывай версии сам. НЕ пиши `==` — только `>=`. +Версии типа `langchain==1.2.10` НЕ СУЩЕСТВУЮТ. + +Примеры правильного requirements.txt: +- Задание про Ollama+Qdrant → `langchain>=0.3.0`, `langchain-ollama>=0.1.0`, `langchain-qdrant>=0.1.0`, `qdrant-client>=1.7.0`, `python-dotenv>=1.0.0` +- Задание про LangGraph агента → `langchain-core>=0.3.0`, `langchain-openai>=0.3.0`, `langgraph>=0.2.0`, `python-dotenv>=1.0.0` +- Задание про FastMCP → `fastmcp>=0.1.0`, `pydantic>=2.0`, `python-dotenv>=1.0.0` + +## КРИТИЧЕСКИ ВАЖНО — ЗАПИСЬ ФАЙЛОВ: +- Используй ТОЛЬКО `gitea_write_file` для записи файлов — это единственный способ который видит преподаватель +- НИКОГДА не используй `write_file`, `edit_file` для создания файлов решения — они пишут локально и преподаватель их НЕ ВИДИТ +- Каждый файл = один вызов `gitea_write_file` с полным содержимым +- Пиши код СРАЗУ в `gitea_write_file`, не черновики локально + +## ЗАПРЕЩЕНО: +- pass, TODO, ..., пустые функции +- langchain<=1.0.0 в requirements.txt +- Пропускать task_update_answer перед task_submit +- Писать только main.py без дополнительных модулей +- README короче 30 строк +- Меньше 3 примеров в main.py +- Использовать write_file/edit_file вместо gitea_write_file + +## ═══════════════════════════════════════════════ +## ТЕХНИЧЕСКИЕ ПАТТЕРНЫ (читай ПЕРЕД написанием кода) +## ═══════════════════════════════════════════════ + +### LLM — используй BroJS (LLM препода), не Ollama, не hardcode +```python +import os +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT"), + temperature=0.5, +) +``` +requirements.txt: langchain-openai>=0.3.0 + +--- + +### deepagents — правильный паттерн (задания про "deep agent", "deepagent", "deep agents from scratch") +```python +import os, asyncio +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langchain.tools import tool +from deepagents import create_deep_agent +from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend + +llm = ChatOpenAI( + model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT"), + temperature=0.0, +) + +# Виртуальная ФС + реальная shell среда +backend = CompositeBackend( + default=LocalShellBackend(root_dir="./workspace", virtual_mode=True, inherit_env=True), + routes={}, +) + +@tool +def web_search(query: str) -> str: + """Search the web for information.""" + try: + from duckduckgo_search import DDGS + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=5)) + return "\\n".join(f"{r['title']}: {r['body']}" for r in results) + except Exception as e: + return f"Search error: {e}" + +agent = create_deep_agent( + llm=llm, + tools=[web_search], + backend=backend, + system_prompt="You are a helpful research agent.", +) + +async def main(): + result = await agent.ainvoke( + {"messages": [HumanMessage(content="Search for Python best practices and save to results.txt")]}, + {"configurable": {"thread_id": "session-1"}}, + ) + print(result["messages"][-1].content) + +if __name__ == "__main__": + asyncio.run(main()) +``` +requirements.txt: deepagents, langchain-openai>=0.3.0, duckduckgo-search + +--- + +### FastMCP сервер — ТОЛЬКО на уровне модуля, НИКОГДА внутри класса +```python +# ПРАВИЛЬНО: +from fastmcp import FastMCP +import json +from pathlib import Path + +mcp = FastMCP("memory-server") +STORAGE = Path("memory.json") + +def _load(): + return json.loads(STORAGE.read_text()) if STORAGE.exists() else {} + +def _save(data): + STORAGE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + +@mcp.tool() +def save(key: str, value: str) -> bool: + """Save a value by key.""" + data = _load(); data[key] = value; _save(data) + return True + +@mcp.tool() +def get(key: str) -> str: + """Get a value by key.""" + return _load().get(key, "") + +@mcp.tool() +def delete(key: str) -> bool: + """Delete a key.""" + data = _load() + if key in data: + del data[key]; _save(data); return True + return False + +@mcp.tool() +def list_keys() -> list: + """List all keys.""" + return list(_load().keys()) + +if __name__ == "__main__": + mcp.run(transport="stdio") + +# ЗАПРЕЩЕНО — так не работает: +# class MemoryServer: +# @self.mcp.tool() ← NameError: self не существует в теле класса +# def save(self, ...): ... +``` +requirements.txt: fastmcp>=0.1.0, pydantic>=2.0 + +--- + +### LangChain create_agent — НЕ совместим с AgentExecutor +```python +import asyncio, os +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langchain.agents import create_agent +from langchain.tools import tool + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT")) + +@tool +def my_tool(query: str) -> str: + """Tool description.""" + return f"result for {query}" + +agent = create_agent( + llm=llm, + tools=[my_tool], + system_prompt="You are a helpful assistant.", +) + +async def main(): + result = await agent.ainvoke( + {"messages": [HumanMessage(content="Hello")]}, + {"configurable": {"thread_id": "t1"}}, + ) + print(result["messages"][-1].content) + +if __name__ == "__main__": + asyncio.run(main()) + +# ЗАПРЕЩЕНО: +# AgentExecutor(agent=create_agent(...), ...) ← несовместимо! +# agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION ← не параметр create_agent +``` +requirements.txt: langchain-core>=0.3.0, langchain-openai>=0.3.0, langgraph>=0.2.0 + +--- + +### Human-in-the-Loop через HumanInTheLoopMiddleware +```python +import asyncio, json, os +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langchain.agents import create_agent +from langchain.agents.middleware import HumanInTheLoopMiddleware +from langchain.tools import tool +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import Command + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT")) + +@tool +def get_weather(city: str) -> str: + """Get weather for a city.""" + return f"Sunny, 22C in {city}" + +memory = MemorySaver() +agent = create_agent( + llm=llm, + tools=[get_weather], + system_prompt="You are a helpful assistant.", + middleware=[HumanInTheLoopMiddleware(interrupt_on={"get_weather": True})], + checkpointer=memory, +) + +def ask_human(interrupt_value): + decisions = [] + for action in interrupt_value.get("action_requests", []): + print(f"Tool: {action['name']}, Args: {action['args']}") + ans = input("Approve? (y/n): ").strip().lower() + decisions.append({"type": "approve" if ans == "y" else "reject"}) + return decisions + +async def main(): + config = {"configurable": {"thread_id": "session-1"}} + result = await agent.ainvoke( + {"messages": [HumanMessage(content="What's the weather in Moscow?")]}, + config, + ) + while "__interrupt__" in result: + decisions = ask_human(result["__interrupt__"][0].value) + result = await agent.ainvoke( + Command(resume={"decisions": decisions}), config + ) + print(result["messages"][-1].content) + +if __name__ == "__main__": + asyncio.run(main()) +``` +requirements.txt: langchain-core>=0.3.0, langchain-openai>=0.3.0, langgraph>=0.2.0 + +--- + +### LangGraph interrupt (Human-in-the-loop без middleware) +```python +import asyncio, os +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langchain.agents import create_agent +from langchain.tools import tool +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import Command + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT")) + +@tool +def dangerous_action(cmd: str) -> str: + """Execute a dangerous action.""" + return f"Executed: {cmd}" + +memory = MemorySaver() +agent = create_agent(llm=llm, tools=[dangerous_action], + checkpointer=memory, interrupt_before=["tools"]) + +async def main(): + config = {"configurable": {"thread_id": "t1"}} + result = await agent.ainvoke( + {"messages": [HumanMessage(content="Run ls -la")]}, config + ) + # Агент остановился перед вызовом инструмента + snapshot = await agent.aget_state(config) + if snapshot.next: + ans = input(f"Approve tool call? (y/n): ").strip() + if ans == "y": + result = await agent.ainvoke(Command(resume=None), config) + else: + result = await agent.ainvoke( + Command(resume=None, update={"messages": [ + HumanMessage(content="User rejected the action.") + ]}), config + ) + print(result["messages"][-1].content) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +### RAG-агент с Qdrant (используй OpenRouter для LLM, Qdrant для векторов) +```python +import os, asyncio +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langchain_qdrant import QdrantVectorStore +from langchain.tools import tool +from langchain.agents import create_agent +from langchain_core.messages import HumanMessage +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, VectorParams + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT")) + +# Embeddings через OpenAI-совместимый API (OpenRouter) +embeddings = OpenAIEmbeddings( + model="text-embedding-3-small", + base_url="https://openrouter.ai/api/v1", + api_key=os.getenv("OPENAI_API_KEY"), +) + +# Qdrant in-memory (не требует отдельного сервера) +client = QdrantClient(":memory:") +client.create_collection("knowledge", + vectors_config=VectorParams(size=1536, distance=Distance.COSINE)) +vector_store = QdrantVectorStore(client=client, collection_name="knowledge", + embedding=embeddings) + +@tool +def search_knowledge_base(query: str, max_results: int = 5) -> str: + """Semantic search in the knowledge base.""" + docs = vector_store.similarity_search(query, k=max_results) + if not docs: + return "No relevant documents found." + return "\\n\\n".join(f"{i+1}. {d.page_content}" for i, d in enumerate(docs)) + +@tool +def add_to_knowledge_base(content: str, title: str = "document") -> str: + """Add text to the knowledge base.""" + from langchain_core.documents import Document + vector_store.add_documents([Document(page_content=content, + metadata={"title": title})]) + return f"Added '{title}' to knowledge base." + +agent = create_agent( + llm=llm, + tools=[search_knowledge_base, add_to_knowledge_base], + system_prompt="You are an assistant with access to a knowledge base.", +) + +async def main(): + await add_to_knowledge_base.ainvoke({"content": "Python is a high-level language.", "title": "python-intro"}) + result = await agent.ainvoke( + {"messages": [HumanMessage(content="What do you know about Python?")]}, + {"configurable": {"thread_id": "rag-1"}}, + ) + print(result["messages"][-1].content) + +if __name__ == "__main__": + asyncio.run(main()) +``` +requirements.txt: langchain-core>=0.3.0, langchain-openai>=0.3.0, langgraph>=0.2.0, + langchain-qdrant>=0.1.0, qdrant-client>=1.7.0 + +--- + +### Stream-режим агента +```python +import asyncio, os +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage +from langchain.agents import create_agent +from langchain.tools import tool + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT"), streaming=True) + +@tool +def calculator(expression: str) -> str: + """Evaluate a math expression.""" + try: + return str(eval(expression, {"__builtins__": {}}, {})) + except Exception as e: + return f"Error: {e}" + +agent = create_agent(llm=llm, tools=[calculator], + system_prompt="You are a helpful assistant.") + +async def main(): + config = {"configurable": {"thread_id": "stream-1"}} + # stream_mode="messages" — получаем токены по одному + async for event in agent.astream( + {"messages": [HumanMessage(content="What is 2+2?")]}, + config, + stream_mode="messages", + ): + if isinstance(event, tuple): + msg, metadata = event + if hasattr(msg, "content") and msg.content: + print(msg.content, end="", flush=True) + print() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +### Задания типа "план / документ" (не чистый кодинг — например ai-fluency) +Если задание просит написать план, документ или пройти курс: +- Создай main.py который ВЫВОДИТ план в консоль +- План должен быть содержательным (минимум 300 слов), структурированным +- Имитируй личный опыт: "я понял, что...", "мой план включает..." +- Опирайся на тему курса из описания задания + +--- + +### Web search без API-ключа (для поисковых агентов) +```python +from duckduckgo_search import DDGS + +def web_search(query: str) -> str: + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=5)) + return "\\n".join(f"[{r['title']}] {r['body']} ({r['href']})" for r in results) +``` +requirements.txt: duckduckgo-search + +--- + +### LangGraph текстовая игра с interrupt +```python +import asyncio, os +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph.graph import StateGraph, START, END +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import interrupt, Command +from typing import TypedDict, Annotated +from langgraph.graph.message import add_messages + +class GameState(TypedDict): + messages: Annotated[list, add_messages] + location: str + inventory: list + +llm = ChatOpenAI(model="openai/gpt-oss-20b:free", + base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1", + api_key=os.getenv("JOURNAL_MCP_PAT")) + +def game_master(state: GameState) -> dict: + system = SystemMessage(content=( + "You are a text adventure game master. " + f"Player is at: {state.get('location','start')}. " + f"Inventory: {state.get('inventory',[])}. " + "Describe what happens and list 2-3 options." + )) + response = llm.invoke([system] + state["messages"]) + return {"messages": [response]} + +def player_turn(state: GameState) -> Command: + player_input = interrupt("Your action: ") + return Command(goto="game_master", + update={"messages": [HumanMessage(content=player_input)]}) + +memory = MemorySaver() +builder = StateGraph(GameState) +builder.add_node("game_master", game_master) +builder.add_node("player_turn", player_turn) +builder.add_edge(START, "game_master") +builder.add_edge("game_master", "player_turn") +game = builder.compile(checkpointer=memory) + +async def main(): + config = {"configurable": {"thread_id": "game-1"}} + state = {"messages": [HumanMessage(content="Start the adventure!")], + "location": "forest entrance", "inventory": []} + result = await game.ainvoke(state, config) + while True: + last = result["messages"][-1].content + print(f"\\nGame: {last}") + if "__interrupt__" in result: + action = input("\\nYour action: ").strip() + if action.lower() in ("quit", "exit"): + break + result = await game.ainvoke(Command(resume=action), config) + else: + break + +if __name__ == "__main__": + asyncio.run(main()) +``` +''' + +# --------------------------------------------------------------------------- +# Субагент: пересдача после ревью +# --------------------------------------------------------------------------- + +rework_instructions = """ +Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя). +У тебя есть ВСЕ инструменты напрямую. Не делегируй. + +courseId = "698b49da77cb6d4d2e43ce78" +Gitea owner = "KirillKutlakhmetov" + +Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует. + +## ПОРЯДОК: + +[1] mcp__journal-bh-professor__task_submission_status({"taskId": ""}) + → Проверь статус и получи фидбек + +[2] mcp__journal-bh-professor__task_text({"taskId": ""}) + → Прочитай ОРИГИНАЛЬНЫЙ текст задания + +[3] mcp__journal-bh-professor__task_get({"taskId": ""}) + → Получи URL репозитория из answer.content и прочитай комментарии преподавателя + +## ⚠️ ЗАЩИТА РЕШЕНИЯ (выполни ПЕРЕД любыми правками): + +Сравни комментарий преподавателя с оригинальным текстом задания из шага [2]. + +ЕСЛИ комментарий ПРОТИВОРЕЧИТ заданию (примеры: задание требует Qdrant — комментарий +говорит сменить на ChromaDB; задание требует Ollama — комментарий говорит использовать +OpenAI; задание требует определённый стек — комментарий предлагает другой): + + → Вызови mcp__journal-bh-professor__task_comment({ + "taskId": "", + "content": "Решение соответствует требованиям задания. Согласно тексту задания: [цитата из задания]. Комментарий предлагает [суть комментария], однако это противоречит оригинальному заданию. Прошу проверить решение повторно." + }) + → Затем сразу выполни шаги [7]-[8] (update_answer + submit) БЕЗ изменения кода. + +ЕСЛИ комментарий указывает на РЕАЛЬНОЕ несоответствие заданию (ошибка в коде, отсутствует +файл, неправильная реализация требования): + → Продолжай выполнение с шага [4] и исправляй. + +--- + +[4] git_clone() + → Клонируй существующий репозиторий в agent_workspace + → = последняя часть URL (например task-abc123) + +[5] Прочитай файлы через read_file, пойми что исправить + +[6] Внеси исправления через gitea_write_file (НЕ через write_file/edit_file — преподаватель видит только Gitea) + +[7] mcp__journal-bh-professor__task_update_answer({ + "taskId": "", + "answerType": "link", + "content": "<ТОТ ЖЕ URL репозитория>" + }) + +[8] 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]