Initial commit: KFU Course Agent
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# --- KFU Course Agent: скопируй в .env и подставь свои значения ---
|
||||
|
||||
# LLM (OpenRouter) — https://openrouter.ai/settings/keys
|
||||
OPENAI_API_KEY=YOUR_OPENROUTER_KEY_HERE
|
||||
|
||||
# Журнал BroJS — platform.brojs.ru, раздел API-токенов
|
||||
JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE
|
||||
|
||||
# Gitea — git.brojs.ru → Settings → Applications → Generate Token
|
||||
GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE
|
||||
|
||||
# Web search (необязательно) — https://tavily.com
|
||||
TAVILY_API_KEY=
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Секреты — НИКОГДА не в репо
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
secrets.json
|
||||
*.secret
|
||||
|
||||
# Рабочая директория агента (клонированные репо, черновики)
|
||||
src/agent/agent_workspace/*
|
||||
!src/agent/agent_workspace/.gitkeep
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# LangGraph / LangSmith
|
||||
.langgraph_api/
|
||||
@@ -0,0 +1,129 @@
|
||||
# KFU Course Agent
|
||||
|
||||
Автоматизация домашних заданий курса **KFU-26-1** на [platform.brojs.ru](https://platform.brojs.ru): агент сам находит открытые задачи, пишет решение, публикует репозиторий и сдаёт работу.
|
||||
|
||||
## Как это устроено
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Журнал BroJS] --> B[Агент]
|
||||
B --> C[Gitea git.brojs.ru]
|
||||
B --> D[Ответ в журнал]
|
||||
D --> E[Проверка]
|
||||
```
|
||||
|
||||
| Этап | Действие |
|
||||
|------|----------|
|
||||
| 1 | Список незакрытых заданий через Journal MCP |
|
||||
| 2 | Генерация Python-решения |
|
||||
| 3 | Репозиторий `git.brojs.ru/glevelll/task-<id>` |
|
||||
| 4 | Коммит через Gitea API |
|
||||
| 5 | Ссылка в ответ + `task_submit` |
|
||||
| 6 | При отклонении — клон, правки, повторная отправка |
|
||||
|
||||
## Требования
|
||||
|
||||
- Python **3.11+**
|
||||
- Ключи в `.env` (см. ниже)
|
||||
|
||||
**Зависимости:** [deepagents](https://github.com/langchain-ai/deepagents), [LangGraph](https://github.com/langchain-ai/langgraph), [LangChain](https://python.langchain.com/), OpenRouter (`gpt-oss-20b:free`), Gitea API, BroJS Journal MCP.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Glevelll/brojs-agent.git
|
||||
cd brojs-agent
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# заполните .env реальными ключами
|
||||
```
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Назначение | Где взять |
|
||||
|------------|------------|-----------|
|
||||
| `OPENAI_API_KEY` | LLM через OpenRouter | [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) |
|
||||
| `JOURNAL_TOKEN` | API журнала | platform.brojs.ru → профиль → токены |
|
||||
| `GITEA_TOKEN` | git.brojs.ru | Settings → Applications → Access Tokens |
|
||||
| `TAVILY_API_KEY` | Поиск в сети (необязательно) | [tavily.com](https://tavily.com) |
|
||||
|
||||
Файл `.env` не коммитится — он в `.gitignore`.
|
||||
|
||||
## Запуск
|
||||
|
||||
### Пайплайн (все открытые coding-задания)
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import asyncio
|
||||
from src.agent.graph.pipeline import pipeline
|
||||
|
||||
async def main():
|
||||
state = {'tasks': [], 'current_index': 0, 'results': [], 'errors': []}
|
||||
cfg = {'configurable': {'thread_id': 'run-1'}}
|
||||
out = await pipeline.ainvoke(state, cfg)
|
||||
print('Готово:', len(out['results']))
|
||||
if out['errors']:
|
||||
print('Сбои:', out['errors'])
|
||||
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
### LangGraph Studio
|
||||
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]"
|
||||
langgraph dev --allow-blocking --port 2024
|
||||
```
|
||||
|
||||
UI: [LangSmith Studio](https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024)
|
||||
|
||||
### Чат с оркестратором
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import asyncio
|
||||
from src.agent.agent import agent
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
async def main():
|
||||
cfg = {'configurable': {'thread_id': 'chat-1'}}
|
||||
while True:
|
||||
q = input('> ').strip()
|
||||
if q in ('exit', 'quit', 'q'):
|
||||
break
|
||||
r = await agent.ainvoke({'messages': [HumanMessage(content=q)]}, cfg)
|
||||
print(r['messages'][-1].content)
|
||||
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
## Карта репозитория
|
||||
|
||||
```
|
||||
.
|
||||
├── agent.py # экспорт для langgraph dev
|
||||
├── langgraph.json
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
└── src/agent/
|
||||
├── agent.py # оркестратор, homework, rework
|
||||
├── graph/pipeline.py # пакетная обработка заданий
|
||||
├── prompts.py
|
||||
├── gitea_tools.py
|
||||
├── mcp_client.py
|
||||
├── middlewares/
|
||||
└── agent_workspace/ # клоны репозиториев
|
||||
```
|
||||
|
||||
## Секреты в коде
|
||||
|
||||
Плейсхолдеры подхватываются из `.env`:
|
||||
|
||||
| Модуль | Переменная |
|
||||
|--------|------------|
|
||||
| `llm.py` | `OPENAI_API_KEY` |
|
||||
| `mcp_client.py` | `JOURNAL_TOKEN` |
|
||||
| `gitea_tools.py` | `GITEA_TOKEN` |
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Экспорт графов для LangGraph CLI (dev / deploy)."""
|
||||
from src.agent.agent import agent, homework_direct_agent, rework_agent
|
||||
from src.agent.graph.pipeline import pipeline
|
||||
|
||||
__all__ = ["agent", "pipeline", "homework_direct_agent", "rework_agent"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:agent",
|
||||
"pipeline": "./agent.py:pipeline"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[project]
|
||||
name = "brojs-agent"
|
||||
version = "0.1.0"
|
||||
description = "Автоматический агент для заданий KFU-26-1 (BroJS + Gitea)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"deepagents>=0.6.3",
|
||||
"langchain>=1.3.0",
|
||||
"langchain-openai>=0.3.0",
|
||||
"langchain-mcp-adapters>=0.1.0",
|
||||
"langgraph>=1.0.8",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"markdownify>=0.13.0",
|
||||
"tavily-python>=0.3.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.agent.agent import agent, homework_direct_agent, rework_agent
|
||||
|
||||
__all__ = ["agent", "homework_direct_agent", "rework_agent"]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Создание агентов: главный оркестратор, исполнитель ДЗ, агент пересдачи."""
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
||||
|
||||
from src.agent.constants import (
|
||||
AGENT_WORKSPACE_DIR,
|
||||
AGENTS_MD_VFS_PATH,
|
||||
BUNDLED_SKILLS_DIR,
|
||||
SKILLS_VFS_MOUNT,
|
||||
ensure_agents_md_file,
|
||||
)
|
||||
from src.agent.gitea_tools import GITEA_TOOLS
|
||||
from src.agent.llm import llm
|
||||
from src.agent.mcp_client import load_journal_toolsets
|
||||
from src.agent.middlewares import SanitizeToolCallsMiddleware, ValidateJournalWorkflowMiddleware
|
||||
from src.agent.prompts import (
|
||||
homework_doing_instructions,
|
||||
main_agent_instructions,
|
||||
rework_instructions,
|
||||
)
|
||||
from src.agent.subagents import subagent_specs_without_tools
|
||||
from src.agent.console import log
|
||||
from src.agent.tools import GIT_TOOLS, WEB_TOOLS
|
||||
|
||||
# · Инициализация
|
||||
|
||||
ensure_agents_md_file()
|
||||
|
||||
journal = load_journal_toolsets()
|
||||
_journal_tools = list(journal.courses_lessons_tools) + list(journal.tasks_submissions_tools)
|
||||
|
||||
log(
|
||||
f"инструменты: journal={len(_journal_tools)}, "
|
||||
f"gitea={len(GITEA_TOOLS)}, git={len(GIT_TOOLS)}"
|
||||
)
|
||||
|
||||
# · Бэкенды (VFS агента)
|
||||
|
||||
_workspace_backend = LocalShellBackend(
|
||||
root_dir=str(AGENT_WORKSPACE_DIR),
|
||||
virtual_mode=True,
|
||||
inherit_env=True,
|
||||
)
|
||||
_skills_backend = FilesystemBackend(
|
||||
root_dir=str(BUNDLED_SKILLS_DIR),
|
||||
virtual_mode=True,
|
||||
)
|
||||
_composite_backend = CompositeBackend(
|
||||
default=_workspace_backend,
|
||||
routes={SKILLS_VFS_MOUNT: _skills_backend},
|
||||
)
|
||||
|
||||
# · Наборы инструментов
|
||||
|
||||
_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools]
|
||||
_web_tools = WEB_TOOLS
|
||||
|
||||
_subagent_tool_map = {
|
||||
"web_search": _web_tools,
|
||||
"homework_doing": _homework_tools,
|
||||
"journal_bh_tasks_submissions": _journal_tools,
|
||||
}
|
||||
|
||||
# Имена всех инструментов для SanitizeToolCallsMiddleware
|
||||
_BUILTIN = {
|
||||
"write_todos", "ls", "read_file", "write_file", "edit_file",
|
||||
"glob", "grep", "execute", "task",
|
||||
}
|
||||
_gitea_names = {t.name for t in GITEA_TOOLS}
|
||||
_journal_names = {t.name for t in _journal_tools}
|
||||
_git_names = {t.name for t in GIT_TOOLS}
|
||||
_web_names = {t.name for t in WEB_TOOLS}
|
||||
|
||||
_main_tool_names = _BUILTIN | _gitea_names
|
||||
|
||||
_subagent_tool_names: dict[str, set[str]] = {
|
||||
"web_search": _BUILTIN | _web_names,
|
||||
"homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names,
|
||||
"journal_bh_tasks_submissions": _BUILTIN | _journal_names,
|
||||
}
|
||||
|
||||
# · Middleware
|
||||
|
||||
def _make_subagent_middleware(name: str) -> list:
|
||||
mw = [SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names[name])]
|
||||
if name in ("homework_doing", "journal_bh_tasks_submissions"):
|
||||
mw.append(ValidateJournalWorkflowMiddleware())
|
||||
return mw
|
||||
|
||||
|
||||
# · Субагенты
|
||||
|
||||
subagents = [
|
||||
{
|
||||
**spec,
|
||||
"tools": _subagent_tool_map[spec["name"]],
|
||||
"middleware": _make_subagent_middleware(spec["name"]),
|
||||
}
|
||||
for spec in subagent_specs_without_tools
|
||||
]
|
||||
|
||||
# · Главный агент
|
||||
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=list(GITEA_TOOLS),
|
||||
system_prompt=main_agent_instructions,
|
||||
backend=_composite_backend,
|
||||
memory=[AGENTS_MD_VFS_PATH],
|
||||
subagents=subagents,
|
||||
middleware=[SanitizeToolCallsMiddleware(known_tools=_main_tool_names)],
|
||||
)
|
||||
|
||||
# · Прямое выполнение ДЗ
|
||||
|
||||
homework_direct_agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=_homework_tools,
|
||||
system_prompt=homework_doing_instructions,
|
||||
backend=_composite_backend,
|
||||
middleware=[
|
||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||
ValidateJournalWorkflowMiddleware(),
|
||||
],
|
||||
)
|
||||
|
||||
# · Пересдача
|
||||
|
||||
rework_agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=_homework_tools,
|
||||
system_prompt=rework_instructions,
|
||||
backend=_composite_backend,
|
||||
middleware=[
|
||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||
ValidateJournalWorkflowMiddleware(),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Единый стиль сообщений в терминале."""
|
||||
|
||||
_TAG = "kfu-agent"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{_TAG}] {msg}")
|
||||
|
||||
|
||||
def log_detail(msg: str) -> None:
|
||||
print(f" · {msg}")
|
||||
|
||||
|
||||
def log_block(title: str) -> None:
|
||||
line = "─" * max(24, len(title) + 4)
|
||||
print(f"\n{line}\n {title}\n{line}")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Константы проекта: пути, ID курса, Gitea-настройки."""
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
AGENT_WORKSPACE_DIR = PACKAGE_DIR / "agent_workspace"
|
||||
BUNDLED_SKILLS_DIR = PACKAGE_DIR / "skills"
|
||||
|
||||
AGENTS_MD_FILENAME = "AGENTS.md"
|
||||
AGENTS_MD_VFS_PATH = "/AGENTS.md"
|
||||
SKILLS_VFS_MOUNT = "/skills/"
|
||||
|
||||
# ID курса KFU-26-1 на platform.brojs.ru
|
||||
COURSE_ID = "698b49da77cb6d4d2e43ce78"
|
||||
|
||||
# Gitea
|
||||
GITEA_OWNER = "glevelll"
|
||||
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||
|
||||
AGENTS_MD_SEED = """\
|
||||
# Контекст сессии
|
||||
|
||||
Краткие заметки между запусками: договорённости, нюансы задания, предпочтения.
|
||||
Дополняй файл через `edit_file`, если есть что сохранить на потом.
|
||||
"""
|
||||
|
||||
|
||||
def ensure_agents_md_file() -> None:
|
||||
"""Создаёт workspace и начальный AGENTS.md, если файла ещё нет."""
|
||||
AGENT_WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
host_path = AGENT_WORKSPACE_DIR / AGENTS_MD_FILENAME
|
||||
if not host_path.exists():
|
||||
host_path.write_text(AGENTS_MD_SEED, encoding="utf-8")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Инструменты для работы с Gitea (git.brojs.ru) через REST API."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# токен Gitea — см. .env → GITEA_TOKEN
|
||||
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {
|
||||
"Authorization": f"token {_GITEA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _get(path: str, **params) -> dict | list:
|
||||
resp = httpx.get(
|
||||
f"{GITEA_BASE_URL}{path}",
|
||||
headers=_headers(),
|
||||
params=params,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _post(path: str, data: dict) -> dict:
|
||||
resp = httpx.post(
|
||||
f"{GITEA_BASE_URL}{path}",
|
||||
headers=_headers(),
|
||||
json=data,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict) -> dict:
|
||||
resp = httpx.put(
|
||||
f"{GITEA_BASE_URL}{path}",
|
||||
headers=_headers(),
|
||||
json=data,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@tool()
|
||||
def gitea_list_repos() -> str:
|
||||
"""Получить список репозиториев текущего пользователя на git.brojs.ru.
|
||||
Также возвращает username — используй его как owner во всех операциях.
|
||||
"""
|
||||
try:
|
||||
result = _get("/api/v1/repos/search", limit=50, token=_GITEA_TOKEN)
|
||||
repos = result.get("data", result) if isinstance(result, dict) else result
|
||||
names = [r.get("name", "") for r in repos if isinstance(r, dict)]
|
||||
return (
|
||||
f"username (owner): {GITEA_OWNER}\n"
|
||||
f"Репозитории ({len(names)}): {', '.join(names) or 'нет'}"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Ошибка получения репозиториев: {e}"
|
||||
|
||||
|
||||
@tool()
|
||||
def gitea_create_repo(name: str, private: bool = False, description: str = "") -> str:
|
||||
"""Создать новый репозиторий на git.brojs.ru.
|
||||
|
||||
Args:
|
||||
name: имя репозитория (например task-abc123)
|
||||
private: сделать приватным (по умолчанию False)
|
||||
description: описание репозитория
|
||||
"""
|
||||
try:
|
||||
result = _post(
|
||||
"/api/v1/user/repos",
|
||||
{
|
||||
"name": name,
|
||||
"private": private,
|
||||
"description": description,
|
||||
"auto_init": True,
|
||||
"default_branch": "main",
|
||||
},
|
||||
)
|
||||
url = result.get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}")
|
||||
return f"Репозиторий создан: {url}"
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
return f"Репозиторий уже существует: {GITEA_BASE_URL}/{GITEA_OWNER}/{name}"
|
||||
return f"Ошибка создания репозитория: {e.response.text}"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
|
||||
@tool()
|
||||
def gitea_write_file(
|
||||
repo: str,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
owner: str = GITEA_OWNER,
|
||||
) -> str:
|
||||
"""Создать или обновить файл в репозитории на git.brojs.ru.
|
||||
Автоматически коммитит изменения на сервере — git push НЕ нужен.
|
||||
|
||||
Args:
|
||||
repo: имя репозитория (например task-abc123)
|
||||
path: путь к файлу (например main.py или src/agent.py)
|
||||
content: содержимое файла в виде plain text (НЕ base64)
|
||||
message: сообщение коммита
|
||||
owner: владелец репозитория (по умолчанию glevelll)
|
||||
"""
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||
|
||||
try:
|
||||
# Проверяем существование файла для получения sha
|
||||
existing = _get(endpoint)
|
||||
sha = existing.get("sha", "")
|
||||
result = _put(endpoint, {"message": message, "content": encoded, "sha": sha})
|
||||
action = "обновлён"
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
# Файл не существует — создаём
|
||||
try:
|
||||
result = _post(endpoint, {"message": message, "content": encoded})
|
||||
action = "создан"
|
||||
except Exception as create_err:
|
||||
return f"Ошибка создания файла {path}: {create_err}"
|
||||
else:
|
||||
return f"Ошибка записи файла {path}: {e.response.text}"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
commit_sha = (result.get("commit") or {}).get("sha", "")[:8]
|
||||
return f"Файл {path} {action} в {owner}/{repo} (commit: {commit_sha})\nURL: {GITEA_BASE_URL}/{owner}/{repo}/src/branch/main/{path}"
|
||||
|
||||
|
||||
@tool()
|
||||
def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str:
|
||||
"""Получить содержимое файла из репозитория на git.brojs.ru.
|
||||
|
||||
Args:
|
||||
repo: имя репозитория
|
||||
path: путь к файлу
|
||||
owner: владелец репозитория (по умолчанию glevelll)
|
||||
|
||||
Returns:
|
||||
JSON-строка с полями content (текст), sha, path
|
||||
"""
|
||||
try:
|
||||
result = _get(f"/api/v1/repos/{owner}/{repo}/contents/{path}")
|
||||
raw = result.get("content", "")
|
||||
# Gitea возвращает base64 с переносами строк
|
||||
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
|
||||
sha = result.get("sha", "")
|
||||
return json.dumps({"content": content, "sha": sha, "path": path}, ensure_ascii=False)
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Ошибка получения файла {path}: {e.response.text}"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
|
||||
# Список всех gitea-инструментов для удобного импорта
|
||||
GITEA_TOOLS = [
|
||||
gitea_list_repos,
|
||||
gitea_create_repo,
|
||||
gitea_write_file,
|
||||
gitea_get_file,
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.agent.graph.pipeline import pipeline
|
||||
|
||||
__all__ = ["pipeline"]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""LangGraph pipeline: последовательно выполняет все незакрытые задания курса."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import TypedDict
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.graph import START, StateGraph
|
||||
|
||||
from src.agent.agent import homework_direct_agent, rework_agent
|
||||
from src.agent.console import log
|
||||
from src.agent.constants import COURSE_ID, GITEA_OWNER
|
||||
from src.agent.gitea_tools import _get as gitea_get
|
||||
from src.agent.mcp_client import JOURNAL_PREFIX, load_journal_toolsets
|
||||
|
||||
# · Состояние пайплайна
|
||||
|
||||
class TaskInfo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
status: str
|
||||
|
||||
|
||||
class PipelineState(TypedDict):
|
||||
tasks: list[TaskInfo]
|
||||
current_index: int
|
||||
results: list[dict]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
# · Хелперы
|
||||
|
||||
_journal = load_journal_toolsets()
|
||||
|
||||
|
||||
def _get_journal_tool(suffix: str):
|
||||
all_tools = _journal.courses_lessons_tools + _journal.tasks_submissions_tools
|
||||
target = f"{JOURNAL_PREFIX}{suffix}"
|
||||
for t in all_tools:
|
||||
if t.name == target:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _parse_text(raw) -> str:
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
return item.get("text", "")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _parse_tasks(raw) -> list[TaskInfo]:
|
||||
text = _parse_text(raw) if not isinstance(raw, str) else raw
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
items = data.get("tasks", data) if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
result = []
|
||||
for item in items:
|
||||
t = item.get("task", item) if isinstance(item, dict) else {}
|
||||
tid = t.get("id", "")
|
||||
if tid:
|
||||
result.append(TaskInfo(
|
||||
id=tid,
|
||||
title=t.get("title", t.get("name", "")),
|
||||
status=item.get("status", "") if isinstance(item, dict) else "",
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
_CODING_KW = [
|
||||
"code", "напиши", "реализуй", "python", "langchain", "langgraph",
|
||||
"агент", "agent", "граф", "graph", "файл", "функц", "программ",
|
||||
"скрипт", "алгоритм", "библиотек", "api", "сервер", "модуль", "класс",
|
||||
]
|
||||
|
||||
|
||||
def _is_coding(task: TaskInfo) -> bool:
|
||||
title = (task.get("title") or "").lower()
|
||||
return any(kw in title for kw in _CODING_KW)
|
||||
|
||||
|
||||
async def _task_text(task_id: str) -> str:
|
||||
tool = _get_journal_tool("task_text")
|
||||
if not tool:
|
||||
return ""
|
||||
try:
|
||||
return _parse_text(await tool.ainvoke({"taskId": task_id}))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _task_json(task_id: str) -> dict:
|
||||
tool = _get_journal_tool("task_get")
|
||||
if not tool:
|
||||
return {}
|
||||
try:
|
||||
raw = _parse_text(await tool.ainvoke({"taskId": task_id}))
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def _existing_repo_url(task_id: str) -> str | None:
|
||||
data = await _task_json(task_id)
|
||||
url = (data.get("answer") or {}).get("content", "")
|
||||
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
async def _verify_repo(repo_name: str) -> dict:
|
||||
"""Проверяет наличие ключевых файлов в репозитории через Gitea API."""
|
||||
verification: dict = {"files_found": [], "files_missing": [], "issues": []}
|
||||
for fname in ["main.py", "requirements.txt", "src/main.py", "app.py"]:
|
||||
try:
|
||||
result = gitea_get(f"/api/v1/repos/{GITEA_OWNER}/{repo_name}/contents/{fname}")
|
||||
raw = result.get("content", "")
|
||||
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
|
||||
verification["files_found"].append(fname)
|
||||
if len(content.strip()) < 50:
|
||||
verification["issues"].append(f"{fname}: слишком короткий ({len(content)} символов)")
|
||||
if fname == "requirements.txt" and "langchain" not in content:
|
||||
verification["issues"].append("requirements.txt: нет зависимости langchain")
|
||||
except Exception:
|
||||
if fname in ("main.py", "requirements.txt"):
|
||||
verification["files_missing"].append(fname)
|
||||
return verification
|
||||
|
||||
|
||||
def _needs_retry(v: dict) -> bool:
|
||||
has_code = any(f in v["files_found"] for f in ["main.py", "src/main.py", "app.py"])
|
||||
has_reqs = "requirements.txt" in v["files_found"]
|
||||
return not has_code or not has_reqs or bool(v["issues"])
|
||||
|
||||
|
||||
def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
|
||||
lines = [f"Решение задания {task['id']} нуждается в доработке.", ""]
|
||||
if v["files_missing"]:
|
||||
lines.append(f"Отсутствуют файлы: {', '.join(v['files_missing'])}")
|
||||
for issue in v["issues"]:
|
||||
lines.append(f"Проблема: {issue}")
|
||||
lines += [
|
||||
"",
|
||||
f"Репозиторий: https://git.brojs.ru/{GITEA_OWNER}/{repo_name}",
|
||||
"",
|
||||
"Требуется:",
|
||||
"- Напиши ПОЛНЫЙ код в main.py (не только requirements.txt)",
|
||||
"- Добавь requirements.txt с зависимостями (langchain>1.0.0)",
|
||||
"- Используй gitea_write_file для исправления файлов",
|
||||
"- Вызови task_update_answer и task_submit",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# · Узлы графа
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
|
||||
async def fetch_tasks(state: PipelineState) -> dict:
|
||||
"""Загружает все незакрытые задания курса."""
|
||||
tool = _get_journal_tool("tasks_list")
|
||||
if not tool:
|
||||
return {"tasks": [], "current_index": 0, "results": [], "errors": ["tasks_list не найден"]}
|
||||
|
||||
raw = await tool.ainvoke({"courseId": COURSE_ID})
|
||||
all_tasks = _parse_tasks(raw)
|
||||
|
||||
pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)]
|
||||
coding = [t for t in pending if _is_coding(t)]
|
||||
skipped = [t for t in pending if not _is_coding(t)]
|
||||
|
||||
if skipped:
|
||||
log(f"пропуск (не coding): {', '.join(t['title'] for t in skipped)}")
|
||||
|
||||
log(f"в очереди: {len(coding)} задание(й)")
|
||||
return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
|
||||
|
||||
|
||||
async def process_one_task(state: PipelineState) -> dict:
|
||||
"""Выполняет одно задание."""
|
||||
if state["current_index"] >= len(state["tasks"]):
|
||||
return state
|
||||
|
||||
task = state["tasks"][state["current_index"]]
|
||||
task_id = task["id"]
|
||||
results = list(state.get("results", []))
|
||||
errors = list(state.get("errors", []))
|
||||
|
||||
repo_url = await _existing_repo_url(task_id)
|
||||
is_rework = repo_url is not None
|
||||
|
||||
if is_rework:
|
||||
data = await _task_json(task_id)
|
||||
comments = data.get("comments") or data.get("feedback", "")
|
||||
prompt = (
|
||||
f"Пересдача задания.\n\n"
|
||||
f"ID: {task_id}\n"
|
||||
f"Название: {task.get('title', '')}\n"
|
||||
f"Репозиторий: {repo_url}\n"
|
||||
f"Комментарии преподавателя: {comments}\n\n"
|
||||
"Внеси исправления и отправь снова."
|
||||
)
|
||||
agent_to_use = rework_agent
|
||||
else:
|
||||
task_text = await _task_text(task_id)
|
||||
prompt = (
|
||||
f"Выполни задание.\n\n"
|
||||
f"ID: {task_id}\n"
|
||||
f"Название: {task.get('title', '')}\n\n"
|
||||
f"Текст задания:\n{task_text}\n\n"
|
||||
"Первая сдача. Напиши код с нуля."
|
||||
)
|
||||
agent_to_use = homework_direct_agent
|
||||
|
||||
try:
|
||||
result = await agent_to_use.ainvoke(
|
||||
{"messages": [HumanMessage(content=prompt)]},
|
||||
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
|
||||
)
|
||||
last = (result.get("messages") or [{}])[-1]
|
||||
output = getattr(last, "content", str(last))
|
||||
mode = "rework" if is_rework else "first_submission"
|
||||
|
||||
# Верификация репозитория (только для новых сдач)
|
||||
repo_name = f"task-{task_id}"
|
||||
verification = {}
|
||||
retries = 0
|
||||
|
||||
if not is_rework:
|
||||
verification = await _verify_repo(repo_name)
|
||||
while _needs_retry(verification) and retries < MAX_RETRIES:
|
||||
retries += 1
|
||||
fix_msg = _fix_prompt(task, repo_name, verification)
|
||||
result = await agent_to_use.ainvoke(
|
||||
{"messages": [HumanMessage(content=fix_msg)]},
|
||||
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
|
||||
)
|
||||
verification = await _verify_repo(repo_name)
|
||||
|
||||
results.append({
|
||||
"task_id": task_id,
|
||||
"status": "done",
|
||||
"mode": mode,
|
||||
"output": output[:500],
|
||||
"verification": verification,
|
||||
"retries": retries,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Задание {task_id} ({'rework' if is_rework else 'new'}): {e}")
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"current_index": state["current_index"] + 1,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def route(state: PipelineState) -> str:
|
||||
if state["current_index"] < len(state["tasks"]):
|
||||
return "process_one_task"
|
||||
return "__end__"
|
||||
|
||||
|
||||
# · Сборка графа
|
||||
|
||||
_builder = StateGraph(PipelineState)
|
||||
_builder.add_node("fetch_tasks", fetch_tasks)
|
||||
_builder.add_node("process_one_task", process_one_task)
|
||||
|
||||
_builder.add_edge(START, "fetch_tasks")
|
||||
_builder.add_conditional_edges("fetch_tasks", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
||||
_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
||||
|
||||
pipeline = _builder.compile()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Инициализация LLM через OpenRouter."""
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ключ OpenRouter — см. .env.example → OPENAI_API_KEY
|
||||
llm = ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
|
||||
temperature=0.0,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Загрузка инструментов BroJS Journal через MCP (HTTP transport)."""
|
||||
import asyncio
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
|
||||
from src.agent.console import log, log_block, log_detail
|
||||
|
||||
load_dotenv()
|
||||
|
||||
JOURNAL_SERVER_NAME = "journal-bh-professor"
|
||||
JOURNAL_PREFIX = f"mcp__{JOURNAL_SERVER_NAME}__"
|
||||
|
||||
# см. .env: JOURNAL_TOKEN
|
||||
_JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "YOUR_JOURNAL_TOKEN_HERE")
|
||||
|
||||
JOURNAL_MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp"
|
||||
|
||||
# Инструменты для работы с курсами и уроками
|
||||
JOURNAL_COURSES_LESSONS = frozenset({
|
||||
"courses_list",
|
||||
"lessons_list",
|
||||
})
|
||||
|
||||
# Инструменты для работы с заданиями и сдачами
|
||||
JOURNAL_TASKS_SUBMISSIONS = frozenset({
|
||||
"tasks_list",
|
||||
"task_text",
|
||||
"task_get",
|
||||
"task_update_answer",
|
||||
"task_submit",
|
||||
"task_comment",
|
||||
"task_submission_status",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalToolsets:
|
||||
courses_lessons_tools: list
|
||||
tasks_submissions_tools: list
|
||||
|
||||
|
||||
def _build_mcp_config() -> dict:
|
||||
return {
|
||||
JOURNAL_SERVER_NAME: {
|
||||
"transport": "http",
|
||||
"url": JOURNAL_MCP_URL,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {_JOURNAL_TOKEN}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_tools() -> dict[str, list]:
|
||||
config = _build_mcp_config()
|
||||
client = MultiServerMCPClient(config)
|
||||
out: dict[str, list] = {}
|
||||
for name in config:
|
||||
try:
|
||||
out[name] = await client.get_tools(server_name=name)
|
||||
except Exception as exc:
|
||||
log(f"MCP «{name}» — ошибка загрузки: {type(exc).__name__}: {exc}")
|
||||
out[name] = []
|
||||
return out
|
||||
|
||||
|
||||
def _load_tools_sync() -> dict[str, list]:
|
||||
"""Загружает MCP-инструменты синхронно, корректно обрабатывая уже запущенный event loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Event loop уже запущен (ноутбук / LangGraph dev) — запускаем в отдельном потоке
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(lambda: asyncio.run(_fetch_tools())).result()
|
||||
except RuntimeError:
|
||||
# Event loop не запущен — просто asyncio.run
|
||||
return asyncio.run(_fetch_tools())
|
||||
|
||||
|
||||
def _rename_tools(tools_by_server: dict[str, list]) -> None:
|
||||
"""Добавляет префикс mcp__<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:
|
||||
"""Загружает и возвращает разбитые на группы инструменты журнала."""
|
||||
log_block("Journal MCP")
|
||||
tools_by_server = _load_tools_sync()
|
||||
_rename_tools(tools_by_server)
|
||||
|
||||
journal_tools = tools_by_server.get(JOURNAL_SERVER_NAME, [])
|
||||
|
||||
log(f"{JOURNAL_SERVER_NAME}: {len(journal_tools)} tool(s)")
|
||||
for t in journal_tools:
|
||||
log_detail(t.name)
|
||||
|
||||
return JournalToolsets(
|
||||
courses_lessons_tools=_subset(journal_tools, JOURNAL_COURSES_LESSONS),
|
||||
tasks_submissions_tools=_subset(journal_tools, JOURNAL_TASKS_SUBMISSIONS),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Системные промпты для всех агентов."""
|
||||
|
||||
# · Субагент: веб-исследование
|
||||
|
||||
research_instructions = """
|
||||
Ты — субагент интернет-исследования. Твоя задача — найти релевантные источники,
|
||||
открыть нужные страницы и вернуть аккуратную выжимку по реально прочитанным материалам.
|
||||
|
||||
## Доступные инструменты
|
||||
- `web_search`: ищет кандидатов в интернете и возвращает сниппеты с URL.
|
||||
- `get_page_content`: открывает конкретную страницу по URL и возвращает её содержимое.
|
||||
|
||||
## Один инструмент за шаг
|
||||
За одно сообщение — **только один** вызов. Дождись ответа, затем при необходимости сделай следующий вызов.
|
||||
|
||||
## Правила
|
||||
1. Считай результат `web_search` только черновой наводкой. Сниппеты не являются доказательством.
|
||||
2. Любой факт подтверждён только после успешного `get_page_content`.
|
||||
3. Не придумывай URL, цитаты, даты или факты.
|
||||
4. Если страницу не удалось открыть, честно скажи об этом.
|
||||
|
||||
## Формат ответа
|
||||
- `Короткий вывод:` 2-5 предложений.
|
||||
- `Подтверждено по страницам:` список фактов с URL.
|
||||
- `Не подтверждено:` что осталось на уровне сниппетов.
|
||||
- `Открытые источники:` список URL с успешно загруженным контентом.
|
||||
"""
|
||||
|
||||
# · Субагент: журнал (задания и сдачи)
|
||||
|
||||
journal_tasks_submissions_instructions = """
|
||||
Ты — субагент BroJS Journal: задания (Task) и сдачи.
|
||||
|
||||
## Известные курсы
|
||||
- KFU-26-1 = `698b49da77cb6d4d2e43ce78`
|
||||
|
||||
## Доступные инструменты (с префиксом mcp__journal-bh-professor__)
|
||||
- `mcp__journal-bh-professor__courses_list` — список курсов
|
||||
- `mcp__journal-bh-professor__lessons_list` — уроки курса (нужен courseId)
|
||||
- `mcp__journal-bh-professor__tasks_list` — задания (нужен courseId)
|
||||
- `mcp__journal-bh-professor__task_text` — полный текст задания
|
||||
- `mcp__journal-bh-professor__task_get` — детали задания (включая answer, комментарии)
|
||||
- `mcp__journal-bh-professor__task_update_answer` — установить ответ (answerType, content)
|
||||
- `mcp__journal-bh-professor__task_submit` — отправить задание на проверку
|
||||
- `mcp__journal-bh-professor__task_comment` — оставить комментарий
|
||||
- `mcp__journal-bh-professor__task_submission_status` — статус сдачи
|
||||
|
||||
## Один инструмент за шаг
|
||||
За одно сообщение — **только один** вызов. Параллельные вызовы запрещены.
|
||||
|
||||
## Правила
|
||||
1. Никогда не угадывай ID — бери их только из ответов API.
|
||||
2. Если курс назван по имени — используй courses_list для получения courseId.
|
||||
3. task_update_answer **обязателен** перед task_submit.
|
||||
"""
|
||||
|
||||
# · Субагент: первая сдача ДЗ
|
||||
|
||||
homework_doing_instructions = """
|
||||
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА).
|
||||
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
|
||||
|
||||
courseId = "698b49da77cb6d4d2e43ce78"
|
||||
Gitea owner = "glevelll"
|
||||
|
||||
ВАЖНО: Journal-инструменты имеют префикс mcp__journal-bh-professor__
|
||||
Gitea-инструменты: gitea_create_repo, gitea_write_file, gitea_get_file, gitea_list_repos
|
||||
Git-инструменты: git_clone, git_pull, git_status, git_add_and_commit, git_push
|
||||
|
||||
## ПОРЯДОК ВЫПОЛНЕНИЯ:
|
||||
|
||||
[1] mcp__journal-bh-professor__task_text({"taskId": "<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`: получить статусы всех заданий курса
|
||||
|
||||
## Жёсткие ограничения
|
||||
- Не вызывай больше одного инструмента за шаг
|
||||
- Не делегируй субагенту несколько независимых задач сразу
|
||||
- Не говори что задание выполнено, если оно не было реально выполнено
|
||||
- Не подменяй требования задания своими догадками
|
||||
"""
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Git-инструменты и веб-поиск для агента."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from langchain.tools import tool
|
||||
from markdownify import markdownify
|
||||
|
||||
from src.agent.constants import AGENT_WORKSPACE_DIR, GITEA_BASE_URL
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# · Web
|
||||
|
||||
_BROWSER_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
),
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
_HTTP_CLIENT = httpx.Client(timeout=30.0, follow_redirects=True, headers=_BROWSER_HEADERS)
|
||||
|
||||
|
||||
@tool()
|
||||
def get_page_content(url: str) -> str:
|
||||
"""Получить текстовое содержимое веб-страницы по URL."""
|
||||
try:
|
||||
resp = _HTTP_CLIENT.get(url)
|
||||
if resp.status_code != 200:
|
||||
return f"Ошибка {resp.status_code} при загрузке {url}"
|
||||
return markdownify(resp.text)
|
||||
except Exception as e:
|
||||
return f"Не удалось загрузить страницу: {e}"
|
||||
|
||||
|
||||
@tool()
|
||||
def web_search(query: str, max_results: int = 5) -> str:
|
||||
"""Поиск информации в интернете через Tavily.
|
||||
|
||||
Args:
|
||||
query: поисковый запрос
|
||||
max_results: максимальное количество результатов
|
||||
"""
|
||||
tavily_key = os.getenv("TAVILY_API_KEY", "")
|
||||
if not tavily_key:
|
||||
return (
|
||||
"Поиск недоступен: TAVILY_API_KEY не задан. "
|
||||
"Добавь ключ в .env для использования web_search."
|
||||
)
|
||||
try:
|
||||
from tavily import TavilyClient
|
||||
client = TavilyClient(api_key=tavily_key)
|
||||
resp = client.search(query, max_results=max_results)
|
||||
parts = []
|
||||
for r in resp.get("results", []):
|
||||
parts.append(
|
||||
f"**{r.get('title', '')}**\n"
|
||||
f"url: {r.get('url', '')}\n"
|
||||
f"content: {r.get('content', '')}\n"
|
||||
)
|
||||
return "\n".join(parts) if parts else "Ничего не найдено"
|
||||
except Exception as e:
|
||||
return f"Ошибка поиска: {e}"
|
||||
|
||||
|
||||
# · Git (agent_workspace)
|
||||
|
||||
def _cwd(subdir: str | None = None) -> str:
|
||||
if subdir:
|
||||
return str(AGENT_WORKSPACE_DIR / subdir)
|
||||
return str(AGENT_WORKSPACE_DIR)
|
||||
|
||||
|
||||
def _inject_token(url: str) -> str:
|
||||
"""Вставляет GITEA_TOKEN в URL для аутентификации при git push/pull."""
|
||||
token = os.getenv("GITEA_TOKEN", "")
|
||||
if token and GITEA_BASE_URL.replace("https://", "") in url and "@" not in url:
|
||||
return url.replace("https://", f"https://oauth2:{token}@")
|
||||
return url
|
||||
|
||||
|
||||
@tool()
|
||||
def git_clone(url: str, depth: int = 1) -> str:
|
||||
"""Клонировать Git-репозиторий в agent_workspace.
|
||||
|
||||
Args:
|
||||
url: URL репозитория (например https://git.brojs.ru/glevelll/task-abc)
|
||||
depth: глубина клонирования (по умолчанию 1 — только последний коммит)
|
||||
"""
|
||||
auth_url = _inject_token(url)
|
||||
result = subprocess.run(
|
||||
["git", "clone", "--depth", str(depth), auth_url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(AGENT_WORKSPACE_DIR),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"Ошибка клонирования: {result.stderr}"
|
||||
return f"Репозиторий клонирован в agent_workspace: {result.stdout or 'OK'}"
|
||||
|
||||
|
||||
@tool()
|
||||
def git_pull(path: str | None = None) -> str:
|
||||
"""Обновить репозиторий (git pull).
|
||||
|
||||
Args:
|
||||
path: подпапка в agent_workspace (например task-abc)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "pull"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_cwd(path),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"Ошибка pull: {result.stderr}"
|
||||
return result.stdout or "Уже актуально"
|
||||
|
||||
|
||||
@tool()
|
||||
def git_status(path: str | None = None) -> str:
|
||||
"""Показать статус репозитория.
|
||||
|
||||
Args:
|
||||
path: подпапка в agent_workspace
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_cwd(path),
|
||||
)
|
||||
return result.stdout or "Нет изменений"
|
||||
|
||||
|
||||
@tool()
|
||||
def git_add_and_commit(message: str, path: str | None = None) -> str:
|
||||
"""Добавить все изменения и создать коммит.
|
||||
|
||||
Args:
|
||||
message: сообщение коммита
|
||||
path: подпапка в agent_workspace
|
||||
"""
|
||||
cwd = _cwd(path)
|
||||
add = subprocess.run(["git", "add", "."], capture_output=True, text=True, cwd=cwd)
|
||||
if add.returncode != 0:
|
||||
return f"Ошибка git add: {add.stderr}"
|
||||
|
||||
commit = subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
if commit.returncode != 0:
|
||||
return f"Ошибка git commit: {commit.stderr}"
|
||||
return commit.stdout
|
||||
|
||||
|
||||
@tool()
|
||||
def git_push(path: str | None = None) -> str:
|
||||
"""Отправить коммиты в удалённый репозиторий (git push).
|
||||
|
||||
Args:
|
||||
path: подпапка в agent_workspace
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "push"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_cwd(path),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"Ошибка push: {result.stderr}"
|
||||
return result.stdout or "Push выполнен успешно"
|
||||
|
||||
|
||||
# Удобный список всех git-инструментов
|
||||
GIT_TOOLS = [git_clone, git_pull, git_status, git_add_and_commit, git_push]
|
||||
WEB_TOOLS = [web_search, get_page_content]
|
||||
Reference in New Issue
Block a user