feat: add solve_task.py direct solver and fix MCP rate limit issues
- Add solve_task.py: fast direct solver (1 LLM call per task, no deepagents overhead) - Add RetryOnRateLimitMiddleware: auto-retry on 429 from any tool - Fix double MCP load: __init__.py cleared, pipeline reuses agent.py journal tools - Fix proxy: add NO_PROXY for openrouter.ai, platform.brojs.ru, git.brojs.ru - Add utility scripts: get_task_ids.py, read_tasks.py - Update run_pipeline.py: TARGET_IDS support, unbuffered output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+360
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
Быстрый решатель заданий BroJS.
|
||||
Схема: читаем задание (MCP) → 1 LLM-вызов → пушим на Gitea → сабмитим (MCP).
|
||||
Автономный — не импортирует src.agent, нет двойной загрузки MCP.
|
||||
|
||||
Использование:
|
||||
python solve_task.py <full_task_id>
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Обходим локальный прокси
|
||||
os.environ["NO_PROXY"] = "openrouter.ai,platform.brojs.ru,git.brojs.ru," + os.environ.get("NO_PROXY", "")
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import httpx
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||
GITEA_OWNER = os.getenv("GITEA_OWNER", "glevelll")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "")
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||
|
||||
MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
llm = ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=OPENAI_API_KEY,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP — один клиент на весь запуск
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_mcp_tools: dict = {}
|
||||
|
||||
|
||||
async def _load_mcp(retries=5, pause=30):
|
||||
global _mcp_tools
|
||||
if _mcp_tools:
|
||||
return
|
||||
config = {
|
||||
"journal": {
|
||||
"transport": "streamable_http",
|
||||
"url": MCP_URL,
|
||||
"headers": {"Authorization": f"Bearer {JOURNAL_TOKEN}"},
|
||||
}
|
||||
}
|
||||
client = MultiServerMCPClient(config)
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
tools = await client.get_tools(server_name="journal")
|
||||
_mcp_tools = {t.name: t for t in tools}
|
||||
print(f" [mcp] Загружено {len(_mcp_tools)} инструментов")
|
||||
return
|
||||
except Exception as e:
|
||||
if "429" in str(e) and attempt < retries:
|
||||
print(f" [mcp] 429 при загрузке, жду {pause}с...")
|
||||
await asyncio.sleep(pause)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def mcp_call(name: str, args: dict, retries=5, pause=30):
|
||||
await _load_mcp()
|
||||
tool = _mcp_tools.get(name)
|
||||
if not tool:
|
||||
raise RuntimeError(f"MCP tool '{name}' not found. Available: {list(_mcp_tools.keys())}")
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
result = await tool.ainvoke(args)
|
||||
if isinstance(result, list):
|
||||
return next((x["text"] for x in result if x.get("type") == "text"), str(result))
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
if "429" in str(e) and attempt < retries:
|
||||
print(f" [mcp] {name} → 429, жду {pause}с (попытка {attempt}/{retries})...")
|
||||
await asyncio.sleep(pause)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gh():
|
||||
return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def gitea_create_repo(name: str) -> str:
|
||||
with httpx.Client(timeout=30) as c:
|
||||
r = c.post(f"{GITEA_BASE_URL}/api/v1/user/repos", headers=_gh(),
|
||||
json={"name": name, "private": False, "auto_init": False})
|
||||
if r.status_code == 409:
|
||||
return f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}"
|
||||
r.raise_for_status()
|
||||
return r.json().get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}")
|
||||
|
||||
|
||||
def gitea_write(repo: str, path: str, content: str, msg: str):
|
||||
encoded = base64.b64encode(content.encode()).decode()
|
||||
url = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents/{path}"
|
||||
with httpx.Client(timeout=30) as c:
|
||||
r = c.get(url, headers=_gh())
|
||||
if r.status_code == 200:
|
||||
sha = r.json().get("sha", "")
|
||||
c.put(url, headers=_gh(), json={"message": msg, "content": encoded, "sha": sha}).raise_for_status()
|
||||
else:
|
||||
c.post(url, headers=_gh(), json={"message": msg, "content": encoded}).raise_for_status()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM: генерация кода
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROMPT = '''\
|
||||
Ты — Python-разработчик. Напиши решение для учебного задания по LLM/AI.
|
||||
Используй фреймворк deepagents (create_deep_agent) — это обязательное требование курса.
|
||||
|
||||
## Задание
|
||||
{task_text}
|
||||
|
||||
## ОБЯЗАТЕЛЬНЫЕ ТЕХНИЧЕСКИЕ ПАТТЕРНЫ
|
||||
|
||||
### LLM — всегда OpenRouter:
|
||||
```python
|
||||
import os
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
temperature=0.0,
|
||||
)
|
||||
```
|
||||
|
||||
### Базовый агент (deepagents) — ОБЯЗАТЕЛЬНАЯ основа:
|
||||
```python
|
||||
import asyncio, os
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain.tools import tool
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend
|
||||
|
||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
backend = CompositeBackend([
|
||||
LocalShellBackend(workspace_dir="./workspace"),
|
||||
FilesystemBackend(),
|
||||
])
|
||||
|
||||
@tool
|
||||
def my_tool(query: str) -> str:
|
||||
"""Tool description."""
|
||||
return f"result for {{query}}"
|
||||
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=[my_tool],
|
||||
backend=backend,
|
||||
system_prompt="You are a helpful agent.",
|
||||
)
|
||||
|
||||
async def main():
|
||||
result = await agent.ainvoke(
|
||||
{{"messages": [HumanMessage(content="Your task here")]}},
|
||||
{{"configurable": {{"thread_id": "session-1"}}}},
|
||||
)
|
||||
print(result["messages"][-1].content)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
requirements.txt: deepagents, langchain-openai>=0.3.0, langchain>=1.2.10, langgraph>=0.2.0
|
||||
|
||||
### RAG с Qdrant (для RAG-заданий):
|
||||
```python
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from langchain_qdrant import QdrantVectorStore
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams
|
||||
from langchain_core.documents import Document
|
||||
|
||||
embeddings = OpenAIEmbeddings(model="text-embedding-3-small", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENAI_API_KEY"))
|
||||
client = QdrantClient(":memory:")
|
||||
client.create_collection("knowledge", vectors_config=VectorParams(size=1536, distance=Distance.COSINE))
|
||||
vector_store = QdrantVectorStore(client=client, collection_name="knowledge", embedding=embeddings)
|
||||
|
||||
@tool
|
||||
def search_knowledge(query: str) -> str:
|
||||
"""Search the knowledge base."""
|
||||
docs = vector_store.similarity_search(query, k=3)
|
||||
return "\\n".join(d.page_content for d in docs) if docs else "No results."
|
||||
|
||||
@tool
|
||||
def add_to_knowledge(content: str, title: str = "doc") -> str:
|
||||
"""Add content to knowledge base."""
|
||||
vector_store.add_documents([Document(page_content=content, metadata={{"title": title}})])
|
||||
return f"Added: {{title}}"
|
||||
```
|
||||
requirements.txt добавить: langchain-qdrant, qdrant-client
|
||||
|
||||
### Планирующий агент (для planning-заданий):
|
||||
```python
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from typing import TypedDict, Annotated
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
class PlanState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
plan: list[str]
|
||||
current_step: int
|
||||
|
||||
def planner_node(state):
|
||||
# LLM создаёт план
|
||||
...
|
||||
|
||||
def executor_node(state):
|
||||
# LLM выполняет шаг плана
|
||||
...
|
||||
```
|
||||
|
||||
### Самокорректирующийся агент:
|
||||
```python
|
||||
# Агент проверяет свой вывод и исправляет если нужно
|
||||
@tool
|
||||
def validate_output(output: str) -> str:
|
||||
"""Validate the output and return issues if any."""
|
||||
issues = []
|
||||
if len(output) < 10:
|
||||
issues.append("Output too short")
|
||||
return "OK" if not issues else f"Issues: {{', '.join(issues)}}"
|
||||
```
|
||||
|
||||
### Структурированный вывод (Pydantic):
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
|
||||
class MyOutput(BaseModel):
|
||||
field1: str = Field(description="...")
|
||||
field2: int = Field(description="...")
|
||||
|
||||
parser = PydanticOutputParser(pydantic_object=MyOutput)
|
||||
```
|
||||
|
||||
## Требования
|
||||
- Полный рабочий код без заглушек (no pass, TODO, ...)
|
||||
- ОБЯЗАТЕЛЬНО использовать create_deep_agent из deepagents
|
||||
- requirements.txt: deepagents, langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0 + нужные доп. зависимости
|
||||
|
||||
## Ответ — ТОЛЬКО JSON без markdown:
|
||||
{{"main_py": "...", "requirements_txt": "...", "extra_files": {{}}}}
|
||||
|
||||
extra_files — только если нужны доп. файлы, иначе пустой объект.
|
||||
'''
|
||||
|
||||
|
||||
async def generate(task_text: str, retries=5) -> dict:
|
||||
prompt = _PROMPT.format(task_text=task_text)
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
print(f" [llm] Генерирую решение (попытка {attempt})...")
|
||||
resp = await llm.ainvoke(prompt)
|
||||
raw = resp.content.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("```")[1]
|
||||
if raw.startswith("json"):
|
||||
raw = raw[4:]
|
||||
return json.loads(raw.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" [llm] JSON parse error: {e}. Повтор...")
|
||||
if attempt == retries:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "429" in str(e) and attempt < retries:
|
||||
wait = 90 * attempt
|
||||
print(f" [llm] 429, жду {wait}с (попытка {attempt}/{retries})...")
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Основная логика
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def solve(task_id: str):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Задание: {task_id}")
|
||||
print('='*60)
|
||||
|
||||
# 1. Читаем текст задания
|
||||
print("[1/5] Читаем текст задания...")
|
||||
task_text = await mcp_call("task_text", {"taskId": task_id})
|
||||
print(f" Получено {len(task_text)} символов")
|
||||
|
||||
# 2. Генерируем код
|
||||
print("[2/5] Генерируем код (1 LLM-вызов)...")
|
||||
solution = await generate(task_text)
|
||||
main_py = solution.get("main_py", "")
|
||||
requirements = solution.get("requirements_txt", "")
|
||||
extra = solution.get("extra_files", {})
|
||||
print(f" main.py: {len(main_py)} символов, requirements.txt: {len(requirements)} символов")
|
||||
|
||||
# 3. Создаём репо
|
||||
repo = f"task-{task_id}"
|
||||
print(f"[3/5] Создаём репозиторий {repo}...")
|
||||
repo_url = gitea_create_repo(repo)
|
||||
print(f" {repo_url}")
|
||||
|
||||
# 4. Пушим файлы
|
||||
print("[4/5] Пушим файлы...")
|
||||
gitea_write(repo, "main.py", main_py, "add main.py")
|
||||
print(" main.py ✓")
|
||||
gitea_write(repo, "requirements.txt", requirements, "add requirements.txt")
|
||||
print(" requirements.txt ✓")
|
||||
for fname, fcontent in extra.items():
|
||||
gitea_write(repo, fname, fcontent, f"add {fname}")
|
||||
print(f" {fname} ✓")
|
||||
|
||||
# 5. Сабмитим
|
||||
print("[5/5] Сабмитим...")
|
||||
await mcp_call("task_update_answer", {
|
||||
"taskId": task_id, "answerType": "link", "content": repo_url,
|
||||
})
|
||||
print(" task_update_answer ✓")
|
||||
await asyncio.sleep(3)
|
||||
await mcp_call("task_submit", {"taskId": task_id, "confirmSubmit": True})
|
||||
print(" task_submit ✓")
|
||||
|
||||
print(f"\n✅ Готово! Репозиторий: {repo_url}")
|
||||
return repo_url
|
||||
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Использование: python solve_task.py <task_id>")
|
||||
sys.exit(1)
|
||||
await solve(sys.argv[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user