feat: initial brojs-agent implementation

AI-агент для автоматического выполнения заданий курса KFU-26-1.
Стек: deepagents + LangGraph + LangChain + OpenRouter (gpt-oss-20b:free).

- Главный агент-оркестратор с субагентами
- homework_direct_agent и rework_agent для сдачи/пересдачи
- Gitea REST API инструменты (git.brojs.ru/glevelll)
- BroJS Journal MCP клиент (platform.brojs.ru)
- LangGraph pipeline: последовательное выполнение всех заданий
- Middleware: SanitizeToolCalls + ValidateJournalWorkflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:08:14 +03:00
commit c95d7375cc
23 changed files with 1663 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=
+31
View File
@@ -0,0 +1,31 @@
# Секреты — НИКОГДА не в репо
.env
# Рабочая директория агента (клонированные репо, черновики)
src/agent/agent_workspace/*
!src/agent/agent_workspace/.gitkeep
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
env/
*.egg-info/
dist/
build/
.eggs/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# LangGraph / LangSmith
.langgraph_api/
+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
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(),
],
)
View File
+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 = "glevelll"
GITEA_BASE_URL = "https://git.brojs.ru"
AGENTS_MD_SEED = """\
# Память агента
Здесь хранятся заметки, которые стоит помнить между сообщениями
(предпочтения, договорённости, факты о задании).
Обновляй этот файл через `edit_file`, когда нужно что-то зафиксировать.
"""
def ensure_agents_md_file() -> None:
"""Создаёт workspace и начальный AGENTS.md, если файла ещё нет."""
AGENT_WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
host_path = AGENT_WORKSPACE_DIR / AGENTS_MD_FILENAME
if not host_path.exists():
host_path.write_text(AGENTS_MD_SEED, encoding="utf-8")
+181
View File
@@ -0,0 +1,181 @@
"""Инструменты для работы с Gitea (git.brojs.ru) через REST API."""
import base64
import json
import os
import httpx
from dotenv import load_dotenv
from langchain.tools import tool
from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
load_dotenv()
# ⚠️ ЗАГЛУШКА #3 — замени в .env: GITEA_TOKEN=ваш_токен_на_git.brojs.ru
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
def _headers() -> dict:
return {
"Authorization": f"token {_GITEA_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _get(path: str, **params) -> dict | list:
resp = httpx.get(
f"{GITEA_BASE_URL}{path}",
headers=_headers(),
params=params,
timeout=30,
)
resp.raise_for_status()
return resp.json()
def _post(path: str, data: dict) -> dict:
resp = httpx.post(
f"{GITEA_BASE_URL}{path}",
headers=_headers(),
json=data,
timeout=30,
)
resp.raise_for_status()
return resp.json()
def _put(path: str, data: dict) -> dict:
resp = httpx.put(
f"{GITEA_BASE_URL}{path}",
headers=_headers(),
json=data,
timeout=30,
)
resp.raise_for_status()
return resp.json()
@tool()
def gitea_list_repos() -> str:
"""Получить список репозиториев текущего пользователя на git.brojs.ru.
Также возвращает username — используй его как owner во всех операциях.
"""
try:
result = _get("/api/v1/repos/search", limit=50, token=_GITEA_TOKEN)
repos = result.get("data", result) if isinstance(result, dict) else result
names = [r.get("name", "") for r in repos if isinstance(r, dict)]
return (
f"username (owner): {GITEA_OWNER}\n"
f"Репозитории ({len(names)}): {', '.join(names) or 'нет'}"
)
except Exception as e:
return f"Ошибка получения репозиториев: {e}"
@tool()
def gitea_create_repo(name: str, private: bool = False, description: str = "") -> str:
"""Создать новый репозиторий на git.brojs.ru.
Args:
name: имя репозитория (например task-abc123)
private: сделать приватным (по умолчанию False)
description: описание репозитория
"""
try:
result = _post(
"/api/v1/user/repos",
{
"name": name,
"private": private,
"description": description,
"auto_init": True,
"default_branch": "main",
},
)
url = result.get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}")
return f"Репозиторий создан: {url}"
except httpx.HTTPStatusError as e:
if e.response.status_code == 409:
return f"Репозиторий уже существует: {GITEA_BASE_URL}/{GITEA_OWNER}/{name}"
return f"Ошибка создания репозитория: {e.response.text}"
except Exception as e:
return f"Ошибка: {e}"
@tool()
def gitea_write_file(
repo: str,
path: str,
content: str,
message: str,
owner: str = GITEA_OWNER,
) -> str:
"""Создать или обновить файл в репозитории на git.brojs.ru.
Автоматически коммитит изменения на сервере — git push НЕ нужен.
Args:
repo: имя репозитория (например task-abc123)
path: путь к файлу (например main.py или src/agent.py)
content: содержимое файла в виде plain text (НЕ base64)
message: сообщение коммита
owner: владелец репозитория (по умолчанию glevelll)
"""
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
try:
# Проверяем существование файла для получения sha
existing = _get(endpoint)
sha = existing.get("sha", "")
result = _put(endpoint, {"message": message, "content": encoded, "sha": sha})
action = "обновлён"
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# Файл не существует — создаём
try:
result = _post(endpoint, {"message": message, "content": encoded})
action = "создан"
except Exception as create_err:
return f"Ошибка создания файла {path}: {create_err}"
else:
return f"Ошибка записи файла {path}: {e.response.text}"
except Exception as e:
return f"Ошибка: {e}"
commit_sha = (result.get("commit") or {}).get("sha", "")[:8]
return f"Файл {path} {action} в {owner}/{repo} (commit: {commit_sha})\nURL: {GITEA_BASE_URL}/{owner}/{repo}/src/branch/main/{path}"
@tool()
def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str:
"""Получить содержимое файла из репозитория на git.brojs.ru.
Args:
repo: имя репозитория
path: путь к файлу
owner: владелец репозитория (по умолчанию glevelll)
Returns:
JSON-строка с полями content (текст), sha, path
"""
try:
result = _get(f"/api/v1/repos/{owner}/{repo}/contents/{path}")
raw = result.get("content", "")
# Gitea возвращает base64 с переносами строк
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
sha = result.get("sha", "")
return json.dumps({"content": content, "sha": sha, "path": path}, ensure_ascii=False)
except httpx.HTTPStatusError as e:
return f"Ошибка получения файла {path}: {e.response.text}"
except Exception as e:
return f"Ошибка: {e}"
# Список всех gitea-инструментов для удобного импорта
GITEA_TOOLS = [
gitea_list_repos,
gitea_create_repo,
gitea_write_file,
gitea_get_file,
]
+3
View File
@@ -0,0 +1,3 @@
from src.agent.graph.pipeline import pipeline
__all__ = ["pipeline"]
+289
View File
@@ -0,0 +1,289 @@
"""LangGraph pipeline: последовательно выполняет все незакрытые задания курса."""
from __future__ import annotations
import base64
import json
from typing import TypedDict
from langchain_core.messages import HumanMessage
from langgraph.graph import START, StateGraph
from src.agent.agent import homework_direct_agent, rework_agent
from src.agent.constants import COURSE_ID, GITEA_OWNER
from src.agent.gitea_tools import _get as gitea_get
from src.agent.mcp_client import JOURNAL_PREFIX, load_journal_toolsets
# ---------------------------------------------------------------------------
# Типы состояния
# ---------------------------------------------------------------------------
class TaskInfo(TypedDict):
id: str
title: str
status: str
class PipelineState(TypedDict):
tasks: list[TaskInfo]
current_index: int
results: list[dict]
errors: list[str]
# ---------------------------------------------------------------------------
# Вспомогательные функции
# ---------------------------------------------------------------------------
_journal = load_journal_toolsets()
def _get_journal_tool(suffix: str):
all_tools = _journal.courses_lessons_tools + _journal.tasks_submissions_tools
target = f"{JOURNAL_PREFIX}{suffix}"
for t in all_tools:
if t.name == target:
return t
return None
def _parse_text(raw) -> str:
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and item.get("type") == "text":
return item.get("text", "")
return str(raw)
def _parse_tasks(raw) -> list[TaskInfo]:
text = _parse_text(raw) if not isinstance(raw, str) else raw
try:
data = json.loads(text)
except (json.JSONDecodeError, TypeError):
return []
items = data.get("tasks", data) if isinstance(data, dict) else data
if not isinstance(items, list):
return []
result = []
for item in items:
t = item.get("task", item) if isinstance(item, dict) else {}
tid = t.get("id", "")
if tid:
result.append(TaskInfo(
id=tid,
title=t.get("title", t.get("name", "")),
status=item.get("status", "") if isinstance(item, dict) else "",
))
return result
_CODING_KW = [
"code", "напиши", "реализуй", "python", "langchain", "langgraph",
"агент", "agent", "граф", "graph", "файл", "функц", "программ",
"скрипт", "алгоритм", "библиотек", "api", "сервер", "модуль", "класс",
]
def _is_coding(task: TaskInfo) -> bool:
title = (task.get("title") or "").lower()
return any(kw in title for kw in _CODING_KW)
async def _task_text(task_id: str) -> str:
tool = _get_journal_tool("task_text")
if not tool:
return ""
try:
return _parse_text(await tool.ainvoke({"taskId": task_id}))
except Exception:
return ""
async def _task_json(task_id: str) -> dict:
tool = _get_journal_tool("task_get")
if not tool:
return {}
try:
raw = _parse_text(await tool.ainvoke({"taskId": task_id}))
return json.loads(raw)
except Exception:
return {}
async def _existing_repo_url(task_id: str) -> str | None:
data = await _task_json(task_id)
url = (data.get("answer") or {}).get("content", "")
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
return url
return None
async def _verify_repo(repo_name: str) -> dict:
"""Проверяет наличие ключевых файлов в репозитории через Gitea API."""
verification: dict = {"files_found": [], "files_missing": [], "issues": []}
for fname in ["main.py", "requirements.txt", "src/main.py", "app.py"]:
try:
result = gitea_get(f"/api/v1/repos/{GITEA_OWNER}/{repo_name}/contents/{fname}")
raw = result.get("content", "")
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
verification["files_found"].append(fname)
if len(content.strip()) < 50:
verification["issues"].append(f"{fname}: слишком короткий ({len(content)} символов)")
if fname == "requirements.txt" and "langchain" not in content:
verification["issues"].append("requirements.txt: нет зависимости langchain")
except Exception:
if fname in ("main.py", "requirements.txt"):
verification["files_missing"].append(fname)
return verification
def _needs_retry(v: dict) -> bool:
has_code = any(f in v["files_found"] for f in ["main.py", "src/main.py", "app.py"])
has_reqs = "requirements.txt" in v["files_found"]
return not has_code or not has_reqs or bool(v["issues"])
def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
lines = [f"Решение задания {task['id']} нуждается в доработке.", ""]
if v["files_missing"]:
lines.append(f"Отсутствуют файлы: {', '.join(v['files_missing'])}")
for issue in v["issues"]:
lines.append(f"Проблема: {issue}")
lines += [
"",
f"Репозиторий: https://git.brojs.ru/{GITEA_OWNER}/{repo_name}",
"",
"Требуется:",
"- Напиши ПОЛНЫЙ код в main.py (не только requirements.txt)",
"- Добавь requirements.txt с зависимостями (langchain>1.0.0)",
"- Используй gitea_write_file для исправления файлов",
"- Вызови task_update_answer и task_submit",
]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Узлы графа
# ---------------------------------------------------------------------------
MAX_RETRIES = 2
async def fetch_tasks(state: PipelineState) -> dict:
"""Загружает все незакрытые задания курса."""
tool = _get_journal_tool("tasks_list")
if not tool:
return {"tasks": [], "current_index": 0, "results": [], "errors": ["tasks_list не найден"]}
raw = await tool.ainvoke({"courseId": COURSE_ID})
all_tasks = _parse_tasks(raw)
pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)]
coding = [t for t in pending if _is_coding(t)]
skipped = [t for t in pending if not _is_coding(t)]
if skipped:
print(f"[pipeline] Пропущены не-кодинговые задания: {[t['title'] for t in skipped]}")
print(f"[pipeline] Найдено {len(coding)} заданий для выполнения")
return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
async def process_one_task(state: PipelineState) -> dict:
"""Выполняет одно задание."""
if state["current_index"] >= len(state["tasks"]):
return state
task = state["tasks"][state["current_index"]]
task_id = task["id"]
results = list(state.get("results", []))
errors = list(state.get("errors", []))
repo_url = await _existing_repo_url(task_id)
is_rework = repo_url is not None
if is_rework:
data = await _task_json(task_id)
comments = data.get("comments") or data.get("feedback", "")
prompt = (
f"Пересдача задания.\n\n"
f"ID: {task_id}\n"
f"Название: {task.get('title', '')}\n"
f"Репозиторий: {repo_url}\n"
f"Комментарии преподавателя: {comments}\n\n"
"Внеси исправления и отправь снова."
)
agent_to_use = rework_agent
else:
task_text = await _task_text(task_id)
prompt = (
f"Выполни задание.\n\n"
f"ID: {task_id}\n"
f"Название: {task.get('title', '')}\n\n"
f"Текст задания:\n{task_text}\n\n"
"Первая сдача. Напиши код с нуля."
)
agent_to_use = homework_direct_agent
try:
result = await agent_to_use.ainvoke(
{"messages": [HumanMessage(content=prompt)]},
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
)
last = (result.get("messages") or [{}])[-1]
output = getattr(last, "content", str(last))
mode = "rework" if is_rework else "first_submission"
# Верификация репозитория (только для новых сдач)
repo_name = f"task-{task_id}"
verification = {}
retries = 0
if not is_rework:
verification = await _verify_repo(repo_name)
while _needs_retry(verification) and retries < MAX_RETRIES:
retries += 1
fix_msg = _fix_prompt(task, repo_name, verification)
result = await agent_to_use.ainvoke(
{"messages": [HumanMessage(content=fix_msg)]},
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
)
verification = await _verify_repo(repo_name)
results.append({
"task_id": task_id,
"status": "done",
"mode": mode,
"output": output[:500],
"verification": verification,
"retries": retries,
})
except Exception as e:
errors.append(f"Задание {task_id} ({'rework' if is_rework else 'new'}): {e}")
return {
"results": results,
"current_index": state["current_index"] + 1,
"errors": errors,
}
def route(state: PipelineState) -> str:
if state["current_index"] < len(state["tasks"]):
return "process_one_task"
return "__end__"
# ---------------------------------------------------------------------------
# Сборка графа
# ---------------------------------------------------------------------------
_builder = StateGraph(PipelineState)
_builder.add_node("fetch_tasks", fetch_tasks)
_builder.add_node("process_one_task", process_one_task)
_builder.add_edge(START, "fetch_tasks")
_builder.add_conditional_edges("fetch_tasks", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
pipeline = _builder.compile()
+16
View File
@@ -0,0 +1,16 @@
"""Инициализация LLM через OpenRouter."""
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv()
# ⚠️ ЗАГЛУШКА #1 — замени в .env: OPENAI_API_KEY=sk-or-xxxxxxxx
# Файл .env.example показывает все нужные переменные.
llm = ChatOpenAI(
model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
temperature=0.0,
)
+111
View File
@@ -0,0 +1,111 @@
"""Загрузка инструментов BroJS Journal через MCP (HTTP transport)."""
import asyncio
import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from dotenv import load_dotenv
from langchain_mcp_adapters.client import MultiServerMCPClient
load_dotenv()
JOURNAL_SERVER_NAME = "journal-bh-professor"
JOURNAL_PREFIX = f"mcp__{JOURNAL_SERVER_NAME}__"
# ⚠️ ЗАГЛУШКА #2 — замени в .env: JOURNAL_TOKEN=jrnl_xxxxxxxx
_JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "YOUR_JOURNAL_TOKEN_HERE")
JOURNAL_MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp"
# Инструменты для работы с курсами и уроками
JOURNAL_COURSES_LESSONS = frozenset({
"courses_list",
"lessons_list",
})
# Инструменты для работы с заданиями и сдачами
JOURNAL_TASKS_SUBMISSIONS = frozenset({
"tasks_list",
"task_text",
"task_get",
"task_update_answer",
"task_submit",
"task_comment",
"task_submission_status",
})
@dataclass(frozen=True)
class JournalToolsets:
courses_lessons_tools: list
tasks_submissions_tools: list
def _build_mcp_config() -> dict:
return {
JOURNAL_SERVER_NAME: {
"transport": "http",
"url": JOURNAL_MCP_URL,
"headers": {
"Authorization": f"Bearer {_JOURNAL_TOKEN}",
},
}
}
async def _fetch_tools() -> dict[str, list]:
config = _build_mcp_config()
client = MultiServerMCPClient(config)
out: dict[str, list] = {}
for name in config:
try:
out[name] = await client.get_tools(server_name=name)
except Exception as exc:
print(f"MCP '{name}': не удалось загрузить инструменты — {type(exc).__name__}: {exc}")
out[name] = []
return out
def _load_tools_sync() -> dict[str, list]:
"""Загружает MCP-инструменты синхронно, корректно обрабатывая уже запущенный event loop."""
try:
asyncio.get_running_loop()
# Event loop уже запущен (ноутбук / LangGraph dev) — запускаем в отдельном потоке
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(lambda: asyncio.run(_fetch_tools())).result()
except RuntimeError:
# Event loop не запущен — просто asyncio.run
return asyncio.run(_fetch_tools())
def _rename_tools(tools_by_server: dict[str, list]) -> None:
"""Добавляет префикс mcp__<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,39 @@
"""Middleware: блокирует вызовы несуществующих инструментов."""
from __future__ import annotations
from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import ToolMessage
class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Перехватывает вызовы инструментов с неизвестными именами и возвращает
понятное сообщение об ошибке вместо падения рантайма."""
known_tools: set[str]
def _reject(self, request) -> ToolMessage:
name = request.tool_call.get("name", "")
available = sorted(self.known_tools)
return ToolMessage(
content=(
f"Инструмент '{name}' не существует. "
f"Доступные инструменты: {available}. "
"Исправь имя инструмента и попробуй снова."
),
tool_call_id=request.tool_call["id"],
name=name,
)
def wrap_tool_call(self, request, handler):
name = request.tool_call.get("name", "")
if name not in self.known_tools:
return self._reject(request)
return handler(request)
async def awrap_tool_call(self, request, handler):
name = request.tool_call.get("name", "")
if name not in self.known_tools:
return self._reject(request)
return await handler(request)
@@ -0,0 +1,127 @@
"""Middleware: валидация порядка вызовов journal-инструментов.
Проблема: модель иногда вызывает task_submit без предварительного
task_update_answer, что приводит к ошибке 'answer is empty'.
Этот middleware перехватывает task_submit и проверяет историю сообщений.
"""
from __future__ import annotations
from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import ToolMessage
from langchain_core.tools.base import ToolException
_NO_UPDATE_MSG = (
"Нельзя вызывать task_submit без предварительного task_update_answer.\n\n"
"Правильный порядок:\n"
'1. task_update_answer(taskId="TASK_ID", answerType="link", content="<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,
)
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)
+229
View File
@@ -0,0 +1,229 @@
"""Системные промпты для всех агентов."""
# ---------------------------------------------------------------------------
# Субагент: исследование в интернете
# ---------------------------------------------------------------------------
research_instructions = """
Ты — субагент интернет-исследования. Твоя задача — найти релевантные источники,
открыть нужные страницы и вернуть аккуратную выжимку по реально прочитанным материалам.
## Доступные инструменты
- `web_search`: ищет кандидатов в интернете и возвращает сниппеты с URL.
- `get_page_content`: открывает конкретную страницу по URL и возвращает её содержимое.
## Один инструмент за шаг
За одно сообщение — **только один** вызов. Дождись ответа, затем при необходимости сделай следующий вызов.
## Правила
1. Считай результат `web_search` только черновой наводкой. Сниппеты не являются доказательством.
2. Любой факт подтверждён только после успешного `get_page_content`.
3. Не придумывай URL, цитаты, даты или факты.
4. Если страницу не удалось открыть, честно скажи об этом.
## Формат ответа
- `Короткий вывод:` 2-5 предложений.
- `Подтверждено по страницам:` список фактов с URL.
- `Не подтверждено:` что осталось на уровне сниппетов.
- `Открытые источники:` список URL с успешно загруженным контентом.
"""
# ---------------------------------------------------------------------------
# Субагент: journal (задания и сдачи)
# ---------------------------------------------------------------------------
journal_tasks_submissions_instructions = """
Ты — субагент BroJS Journal: задания (Task) и сдачи.
## Известные курсы
- KFU-26-1 = `698b49da77cb6d4d2e43ce78`
## Доступные инструменты (с префиксом mcp__journal-bh-professor__)
- `mcp__journal-bh-professor__courses_list` — список курсов
- `mcp__journal-bh-professor__lessons_list` — уроки курса (нужен courseId)
- `mcp__journal-bh-professor__tasks_list` — задания (нужен courseId)
- `mcp__journal-bh-professor__task_text` — полный текст задания
- `mcp__journal-bh-professor__task_get` — детали задания (включая answer, комментарии)
- `mcp__journal-bh-professor__task_update_answer` — установить ответ (answerType, content)
- `mcp__journal-bh-professor__task_submit` — отправить задание на проверку
- `mcp__journal-bh-professor__task_comment` — оставить комментарий
- `mcp__journal-bh-professor__task_submission_status` — статус сдачи
## Один инструмент за шаг
За одно сообщение — **только один** вызов. Параллельные вызовы запрещены.
## Правила
1. Никогда не угадывай ID — бери их только из ответов API.
2. Если курс назван по имени — используй courses_list для получения courseId.
3. task_update_answer **обязателен** перед task_submit.
"""
# ---------------------------------------------------------------------------
# Субагент: выполнение домашних заданий (первая сдача)
# ---------------------------------------------------------------------------
homework_doing_instructions = """
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА).
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
courseId = "698b49da77cb6d4d2e43ce78"
Gitea owner = "glevelll"
ВАЖНО: Journal-инструменты имеют префикс mcp__journal-bh-professor__
Gitea-инструменты: gitea_create_repo, gitea_write_file, gitea_get_file, gitea_list_repos
Git-инструменты: git_clone, git_pull, git_status, git_add_and_commit, git_push
## ПОРЯДОК ВЫПОЛНЕНИЯ:
[1] mcp__journal-bh-professor__task_text({"taskId": "<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/glevelll/task-<id>")
→ Клонируй репозиторий локально для проверки
[6] Проверь через read_file что код корректен
[7] mcp__journal-bh-professor__task_update_answer({
"taskId": "<id>",
"answerType": "link",
"content": "https://git.brojs.ru/glevelll/task-<id>"
})
→ ОБЯЗАТЕЛЬНО перед task_submit!
[8] Финальная проверка:
✓ Все файлы записаны?
✓ Нет pass, TODO, ..., заглушек?
✓ langchain>1.0.0 в requirements.txt?
✓ task_update_answer вызван?
[9] mcp__journal-bh-professor__task_submit({
"taskId": "<id>",
"confirmSubmit": true
})
## ТРЕБОВАНИЯ К КОДУ:
- ПОЛНЫЙ рабочий код, без pass, TODO, ...
- requirements.txt с реальными зависимостями и langchain>1.0.0
- Соответствие всем требованиям из текста задания
- Используй langchain>=1.2.10 / langgraph>=0.2.0 согласно заданию
## ЗАПРЕЩЕНО:
- pass, TODO, ..., пустые функции
- langchain<=1.0.0 в requirements.txt
- Пропускать task_update_answer перед task_submit
- Писать код только в requirements.txt без main.py
"""
# ---------------------------------------------------------------------------
# Субагент: пересдача после ревью
# ---------------------------------------------------------------------------
rework_instructions = """
Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя).
У тебя есть ВСЕ инструменты напрямую. Не делегируй.
courseId = "698b49da77cb6d4d2e43ce78"
Gitea owner = "glevelll"
Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует.
## ПОРЯДОК:
[1] mcp__journal-bh-professor__task_submission_status({"taskId": "<id>"})
→ Проверь статус и получи фидбек
[2] mcp__journal-bh-professor__task_get({"taskId": "<id>"})
→ Получи URL репозитория из answer.content и прочитай комментарии
[3] git_clone(<url из answer.content>)
→ Клонируй существующий репозиторий в agent_workspace
→ <repo-name> = последняя часть URL (например task-abc123)
[4] Прочитай файлы через read_file, пойми что исправить
[5] Внеси исправления через edit_file или write_file
[6] git_add_and_commit("fix: <описание исправлений>", "<repo-name>")
[7] git_push("<repo-name>")
[8] mcp__journal-bh-professor__task_update_answer({
"taskId": "<id>",
"answerType": "link",
"content": "<ТОТ ЖЕ URL репозитория>"
})
[9] 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]