Initial commit: homework automation agent for BroJS course KFU-26-1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 19:03:46 +03:00
commit 7e6d3a906b
24 changed files with 2546 additions and 0 deletions
+19
View File
@@ -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=
+32
View File
@@ -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/
+151
View File
@@ -0,0 +1,151 @@
# brojs-agent
AI-агент для автоматического выполнения заданий курса KFU-26-1 на platform.brojs.ru.
## Что делает агент
1. Читает незакрытые задания из журнала через BroJS MCP
2. Пишет Python-решение для каждого задания
3. Создаёт репозиторий на `git.brojs.ru/glevelll/task-<id>`
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` |
+5
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"dependencies": ["."],
"graphs": {
"agent": "./agent.py:agent",
"pipeline": "./agent.py:pipeline"
},
"env": ".env"
}
+24
View File
@@ -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"]
+10
View File
@@ -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
+46
View File
@@ -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())
+104
View File
@@ -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())
View File
+3
View File
@@ -0,0 +1,3 @@
from src.agent.agent import agent, homework_direct_agent, rework_agent
__all__ = ["agent", "homework_direct_agent", "rework_agent"]
+150
View File
@@ -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(),
],
)
+34
View File
@@ -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")
+180
View File
@@ -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,
]
+3
View File
@@ -0,0 +1,3 @@
from src.agent.graph.pipeline import pipeline
__all__ = ["pipeline"]
+444
View File
@@ -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()
+23
View File
@@ -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,
)
+110
View File
@@ -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__<server>__ к именам инструментов."""
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),
)
+4
View File
@@ -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"]
@@ -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)
@@ -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="<URL репозитория>")\n'
'2. task_submit(taskId="TASK_ID", confirmSubmit=true)'
)
_SUBMIT_ERR_MSG = (
"task_submit завершился ошибкой: {error}\n\n"
"Убедись что task_update_answer был вызван с правильными параметрами "
'(answerType="link", content="<URL>"), затем повтори 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)
+783
View File
@@ -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": "<id>"})
→ Прочитай ПОЛНЫЙ текст задания
[2] Составь письменный план:
- какие файлы нужны (main.py, requirements.txt, etc.)
- что реализовать в каждом файле
- какой технический стек использовать (см. раздел ТЕХНИЧЕСКИЕ ПАТТЕРНЫ ниже)
[3] gitea_create_repo({"name": "task-<id>", "private": false})
→ Создай репозиторий
[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО:
gitea_write_file({
"repo": "task-<id>",
"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-<id>")
→ Клонируй репозиторий локально для проверки
[6] Проверь через read_file что код корректен
[7] mcp__journal-bh-professor__task_update_answer({
"taskId": "<id>",
"answerType": "link",
"content": "https://git.brojs.ru/KirillKutlakhmetov/task-<id>"
})
→ ОБЯЗАТЕЛЬНО перед task_submit!
[8] Финальная проверка:
✓ Все файлы записаны через gitea_write_file?
✓ Нет pass, TODO, ..., заглушек?
✓ task_update_answer вызван и вернул успех?
[9] ⚠️ ОБЯЗАТЕЛЬНЫЙ ПОСЛЕДНИЙ ШАГ — БЕЗ НЕГО ЗАДАНИЕ НЕ СДАНО:
mcp__journal-bh-professor__task_submit({
"taskId": "<id>",
"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": "<id>"})
→ Проверь статус и получи фидбек
[2] mcp__journal-bh-professor__task_text({"taskId": "<id>"})
→ Прочитай ОРИГИНАЛЬНЫЙ текст задания
[3] mcp__journal-bh-professor__task_get({"taskId": "<id>"})
→ Получи URL репозитория из answer.content и прочитай комментарии преподавателя
## ⚠️ ЗАЩИТА РЕШЕНИЯ (выполни ПЕРЕД любыми правками):
Сравни комментарий преподавателя с оригинальным текстом задания из шага [2].
ЕСЛИ комментарий ПРОТИВОРЕЧИТ заданию (примеры: задание требует Qdrant — комментарий
говорит сменить на ChromaDB; задание требует Ollama — комментарий говорит использовать
OpenAI; задание требует определённый стек — комментарий предлагает другой):
→ Вызови mcp__journal-bh-professor__task_comment({
"taskId": "<id>",
"content": "Решение соответствует требованиям задания. Согласно тексту задания: [цитата из задания]. Комментарий предлагает [суть комментария], однако это противоречит оригинальному заданию. Прошу проверить решение повторно."
})
→ Затем сразу выполни шаги [7]-[8] (update_answer + submit) БЕЗ изменения кода.
ЕСЛИ комментарий указывает на РЕАЛЬНОЕ несоответствие заданию (ошибка в коде, отсутствует
файл, неправильная реализация требования):
→ Продолжай выполнение с шага [4] и исправляй.
---
[4] git_clone(<url из answer.content>)
→ Клонируй существующий репозиторий в agent_workspace
→ <repo-name> = последняя часть URL (например task-abc123)
[5] Прочитай файлы через read_file, пойми что исправить
[6] Внеси исправления через gitea_write_file (НЕ через write_file/edit_file — преподаватель видит только Gitea)
[7] mcp__journal-bh-professor__task_update_answer({
"taskId": "<id>",
"answerType": "link",
"content": "<ТОТ ЖЕ URL репозитория>"
})
[8] mcp__journal-bh-professor__task_submit({"taskId": "<id>", "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=<id>
(он сам прочитает текст, создаст репо, напишет код и сдаст)
2. Верни пользователю ссылку на репозиторий
### Проверить статусы
1. Делегируй `journal_bh_tasks_submissions`: получить статусы всех заданий курса
## Жёсткие ограничения
- Не вызывай больше одного инструмента за шаг
- Не делегируй субагенту несколько независимых задач сразу
- Не говори что задание выполнено, если оно не было реально выполнено
- Не подменяй требования задания своими догадками
"""
+37
View File
@@ -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,
},
]
+192
View File
@@ -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]