Expand server tools and bot controls

This commit is contained in:
mirivlad 2026-04-07 16:49:33 +08:00
parent 59792f5d5f
commit 2b8bc6ed8b
8 changed files with 297 additions and 29 deletions

View File

@ -28,9 +28,10 @@ Qwen OAuth + OpenAI-compatible endpoint
- хранение токенов в `~/.qwen/oauth_creds.json`
- HTTP API сервера
- агентный цикл с tool calling
- базовые инструменты: `list_files`, `read_file`, `write_file`, `exec_command`
- инструменты: `list_files`, `glob_search`, `grep_text`, `stat_path`, `read_file`, `write_file`, `make_directory`, `exec_command`
- Telegram polling без внешних библиотек
- JSON-хранилище сессий
- API списка и просмотра сессий
## Ограничения текущей реализации
@ -78,3 +79,13 @@ python3 bot/app.py
curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start
```
## API
- `GET /health`
- `GET /api/v1/auth/status`
- `POST /api/v1/auth/device/start`
- `POST /api/v1/auth/device/poll`
- `GET /api/v1/sessions`
- `POST /api/v1/session/get`
- `POST /api/v1/session/clear`
- `POST /api/v1/chat`

View File

@ -15,7 +15,7 @@ STATE_FILE = Path(__file__).resolve().parent.parent / ".new-qwen" / "telegram-st
def load_state() -> dict[str, Any]:
if not STATE_FILE.exists():
return {"offset": None, "sessions": {}}
return {"offset": None, "sessions": {}, "auth_flows": {}}
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
@ -41,11 +41,19 @@ def get_json(url: str) -> dict[str, Any]:
return json.loads(response.read().decode("utf-8"))
def ensure_auth(api: TelegramAPI, config: BotConfig, chat_id: int) -> bool:
def send_text_chunks(api: TelegramAPI, chat_id: int, text: str) -> None:
normalized = text or "Пустой ответ."
chunk_size = 3800
for start in range(0, len(normalized), chunk_size):
api.send_message(chat_id, normalized[start : start + chunk_size])
def ensure_auth(api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int) -> bool:
status = get_json(f"{config.server_url}/api/v1/auth/status")
if status.get("authenticated"):
return True
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {})
state.setdefault("auth_flows", {})[str(chat_id)] = started["flow_id"]
api.send_message(
chat_id,
"Qwen OAuth не настроен.\n"
@ -65,13 +73,30 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
session_key = f"{chat_id}:{user_id}"
session_id = state.setdefault("sessions", {}).get(session_key)
state.setdefault("auth_flows", {})
if text == "/start":
api.send_message(chat_id, "new-qwen bot готов. Команды: /auth, /clear.")
api.send_message(
chat_id,
"new-qwen bot готов.\nКоманды: /help, /auth, /status, /session, /clear.",
)
return
if text == "/help":
api.send_message(
chat_id,
"Команды:\n"
"/auth - начать Qwen OAuth\n"
"/auth_check [flow_id] - проверить авторизацию\n"
"/status - статус OAuth и сервера\n"
"/session - показать текущую сессию\n"
"/clear - очистить контекст",
)
return
if text == "/auth":
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {})
state["auth_flows"][str(chat_id)] = started["flow_id"]
api.send_message(
chat_id,
"Откройте ссылку для авторизации Qwen OAuth:\n"
@ -82,19 +107,52 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
if text.startswith("/auth_check"):
parts = text.split(maxsplit=1)
if len(parts) != 2:
api.send_message(chat_id, "Использование: /auth_check <flow_id>")
flow_id = parts[1] if len(parts) == 2 else state["auth_flows"].get(str(chat_id))
if not flow_id:
api.send_message(chat_id, "Нет активного flow_id. Сначала вызови /auth.")
return
result = post_json(
f"{config.server_url}/api/v1/auth/device/poll",
{"flow_id": parts[1]},
{"flow_id": flow_id},
)
if result.get("done"):
state["auth_flows"].pop(str(chat_id), None)
api.send_message(chat_id, "Qwen OAuth успешно настроен.")
else:
api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.")
return
if text == "/status":
status = get_json(f"{config.server_url}/api/v1/auth/status")
send_text_chunks(
api,
chat_id,
"Сервер доступен.\n"
f"OAuth: {'configured' if status.get('authenticated') else 'not configured'}\n"
f"resource_url: {status.get('resource_url')}\n"
f"expires_at: {status.get('expires_at')}",
)
return
if text == "/session":
if not session_id:
api.send_message(chat_id, "У этого чата ещё нет активной сессии.")
return
session = post_json(
f"{config.server_url}/api/v1/session/get",
{"session_id": session_id},
)
send_text_chunks(
api,
chat_id,
"session_id: {session_id}\nmessages: {count}\nlast_answer: {last_answer}".format(
session_id=session_id,
count=len(session.get("messages", [])),
last_answer=session.get("last_answer") or "-",
),
)
return
if text == "/clear":
if session_id:
post_json(f"{config.server_url}/api/v1/session/clear", {"session_id": session_id})
@ -102,7 +160,7 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
api.send_message(chat_id, "Контекст сессии очищен.")
return
if not ensure_auth(api, config, chat_id):
if not ensure_auth(api, config, state, chat_id):
return
api.send_message(chat_id, "Обрабатываю запрос...")
@ -116,7 +174,7 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
)
state["sessions"][session_key] = result["session_id"]
answer = result.get("answer") or "Пустой ответ от модели."
api.send_message(chat_id, answer[:4000])
send_text_chunks(api, chat_id, answer)
def main() -> None:

View File

@ -5,4 +5,5 @@ NEW_QWEN_WORKSPACE_ROOT=/home/mirivlad/git
NEW_QWEN_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions
NEW_QWEN_SYSTEM_PROMPT=
NEW_QWEN_MAX_TOOL_ROUNDS=8
NEW_QWEN_MAX_FILE_READ_BYTES=200000
NEW_QWEN_MAX_COMMAND_OUTPUT_BYTES=12000

View File

@ -20,7 +20,7 @@ class AppState:
self.config = config
self.oauth = QwenOAuthManager()
self.sessions = SessionStore(config.session_dir)
self.tools = ToolRegistry(config.workspace_root)
self.tools = ToolRegistry(config)
self.agent = QwenAgent(config, self.oauth, self.tools)
self.pending_device_flows: dict[str, DeviceAuthState] = {}
self.lock = threading.Lock()
@ -65,6 +65,9 @@ class RequestHandler(BaseHTTPRequestHandler):
if self.path == "/api/v1/auth/status":
self._send(HTTPStatus.OK, self.app.auth_status())
return
if self.path == "/api/v1/sessions":
self._send(HTTPStatus.OK, {"sessions": self.app.sessions.list_sessions()})
return
self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def do_POST(self) -> None:
@ -120,6 +123,7 @@ class RequestHandler(BaseHTTPRequestHandler):
"user_id": user_id,
"updated_at": int(time.time()),
"messages": persisted_messages,
"last_answer": result["answer"],
},
)
self._send(
@ -133,6 +137,12 @@ class RequestHandler(BaseHTTPRequestHandler):
)
return
if self.path == "/api/v1/session/get":
body = self._json_body()
session_id = body["session_id"]
self._send(HTTPStatus.OK, self.app.sessions.load(session_id))
return
if self.path == "/api/v1/session/clear":
body = self._json_body()
session_id = body["session_id"]
@ -163,4 +173,3 @@ def main() -> None:
if __name__ == "__main__":
main()

View File

@ -25,6 +25,8 @@ class ServerConfig:
session_dir: Path
system_prompt: str
max_tool_rounds: int
max_file_read_bytes: int
max_command_output_bytes: int
@classmethod
def load(cls) -> "ServerConfig":
@ -47,5 +49,10 @@ class ServerConfig:
session_dir=session_dir.resolve(),
system_prompt=os.environ.get("NEW_QWEN_SYSTEM_PROMPT", "").strip(),
max_tool_rounds=int(os.environ.get("NEW_QWEN_MAX_TOOL_ROUNDS", "8")),
max_file_read_bytes=int(
os.environ.get("NEW_QWEN_MAX_FILE_READ_BYTES", "200000")
),
max_command_output_bytes=int(
os.environ.get("NEW_QWEN_MAX_COMMAND_OUTPUT_BYTES", "12000")
),
)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import json
from typing import Any
from urllib import request
from urllib import error, request
from config import ServerConfig
from oauth import OAuthError, QwenOAuthManager
@ -42,8 +42,12 @@ class QwenAgent:
},
method="POST",
)
with request.urlopen(req, timeout=180) as response:
return json.loads(response.read().decode("utf-8"))
try:
with request.urlopen(req, timeout=180) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise OAuthError(f"LLM request failed with HTTP {exc.code}: {body}") from exc
def run(self, history: list[dict[str, Any]], user_message: str) -> dict[str, Any]:
system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT
@ -100,5 +104,13 @@ class QwenAgent:
}
)
raise OAuthError("Max tool rounds exceeded")
final_message = (
"Остановлено по лимиту tool rounds. Попробуй сузить задачу или продолжить отдельным сообщением."
)
events.append({"type": "assistant", "content": final_message})
return {
"answer": final_message,
"events": events,
"usage": None,
"messages": messages + [{"role": "assistant", "content": final_message}],
}

View File

@ -10,7 +10,7 @@ class SessionStore:
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._lock = threading.RLock()
def _path(self, session_id: str) -> Path:
return self.base_dir / f"{session_id}.json"
@ -35,9 +35,27 @@ class SessionStore:
self.save(session_id, payload)
return payload
def list_sessions(self) -> list[dict[str, Any]]:
sessions: list[dict[str, Any]] = []
for path in sorted(self.base_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
sessions.append(
{
"session_id": payload.get("session_id", path.stem),
"user_id": payload.get("user_id"),
"updated_at": payload.get("updated_at"),
"message_count": len(payload.get("messages", [])),
"last_answer": payload.get("last_answer"),
}
)
sessions.sort(key=lambda item: item.get("updated_at") or 0, reverse=True)
return sessions
def clear(self, session_id: str) -> None:
with self._lock:
path = self._path(session_id)
if path.exists():
path.unlink()

View File

@ -1,22 +1,32 @@
from __future__ import annotations
import fnmatch
import json
import os
import re
import subprocess
from pathlib import Path
from typing import Any, Callable
from config import ServerConfig
class ToolError(RuntimeError):
pass
class ToolRegistry:
def __init__(self, workspace_root: Path) -> None:
self.workspace_root = workspace_root.resolve()
def __init__(self, config: ServerConfig) -> None:
self.config = config
self.workspace_root = config.workspace_root.resolve()
self._handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"list_files": self.list_files,
"glob_search": self.glob_search,
"grep_text": self.grep_text,
"stat_path": self.stat_path,
"read_file": self.read_file,
"write_file": self.write_file,
"make_directory": self.make_directory,
"exec_command": self.exec_command,
}
@ -36,6 +46,52 @@ class ToolRegistry:
},
},
},
{
"type": "function",
"function": {
"name": "glob_search",
"description": "Find workspace paths matching a glob pattern.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"base_path": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "grep_text",
"description": "Search text in workspace files using a regular expression.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"base_path": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "stat_path",
"description": "Return metadata for a workspace path.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
@ -65,6 +121,20 @@ class ToolRegistry:
},
},
},
{
"type": "function",
"function": {
"name": "make_directory",
"description": "Create a directory inside the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
@ -108,25 +178,108 @@ class ToolRegistry:
items.append(
{
"name": item.name,
"path": str(item.relative_to(self.workspace_root)),
"path": str(item.relative_to(self.workspace_root).as_posix()),
"type": "dir" if item.is_dir() else "file",
}
)
return {"items": items}
def glob_search(self, arguments: dict[str, Any]) -> dict[str, Any]:
pattern = arguments["pattern"]
base = self._resolve(arguments.get("base_path", "."))
if not base.is_dir():
raise ToolError("base_path is not a directory")
limit = max(1, min(int(arguments.get("limit", 200)), 1000))
matches: list[str] = []
for root, dirs, files in os.walk(base):
dirs.sort()
files.sort()
rel_root = Path(root).relative_to(self.workspace_root)
for name in dirs + files:
rel_path = (rel_root / name).as_posix()
if fnmatch.fnmatch(rel_path, pattern):
matches.append(rel_path)
if len(matches) >= limit:
return {"matches": matches, "truncated": True}
return {"matches": matches, "truncated": False}
def grep_text(self, arguments: dict[str, Any]) -> dict[str, Any]:
regex = re.compile(arguments["pattern"])
base = self._resolve(arguments.get("base_path", "."))
if not base.is_dir():
raise ToolError("base_path is not a directory")
limit = max(1, min(int(arguments.get("limit", 100)), 500))
matches: list[dict[str, Any]] = []
for root, dirs, files in os.walk(base):
dirs.sort()
files.sort()
for file_name in files:
file_path = Path(root) / file_name
try:
text = file_path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if regex.search(line):
matches.append(
{
"path": file_path.relative_to(self.workspace_root).as_posix(),
"line": lineno,
"text": line[:500],
}
)
if len(matches) >= limit:
return {"matches": matches, "truncated": True}
return {"matches": matches, "truncated": False}
def stat_path(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
rel_path = target.relative_to(self.workspace_root).as_posix()
if not target.exists():
return {"exists": False, "path": rel_path}
stat = target.stat()
return {
"exists": True,
"path": rel_path,
"type": "dir" if target.is_dir() else "file",
"size": stat.st_size,
"mtime": int(stat.st_mtime),
}
def read_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
if not target.exists():
raise ToolError("File does not exist")
if not target.is_file():
raise ToolError("Path is not a file")
return {"path": str(target.relative_to(self.workspace_root)), "content": target.read_text(encoding="utf-8")}
content = target.read_text(encoding="utf-8")
encoded = content.encode("utf-8")
truncated = False
if len(encoded) > self.config.max_file_read_bytes:
content = encoded[: self.config.max_file_read_bytes].decode(
"utf-8",
errors="ignore",
)
truncated = True
return {
"path": target.relative_to(self.workspace_root).as_posix(),
"content": content,
"truncated": truncated,
}
def write_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(arguments["content"], encoding="utf-8")
return {"path": str(target.relative_to(self.workspace_root)), "bytes_written": len(arguments["content"].encode("utf-8"))}
return {
"path": target.relative_to(self.workspace_root).as_posix(),
"bytes_written": len(arguments["content"].encode("utf-8")),
}
def make_directory(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
target.mkdir(parents=True, exist_ok=True)
return {"path": target.relative_to(self.workspace_root).as_posix(), "created": True}
def exec_command(self, arguments: dict[str, Any]) -> dict[str, Any]:
cwd = self._resolve(arguments.get("cwd", "."))
@ -141,13 +294,12 @@ class ToolRegistry:
)
return {
"command": command,
"cwd": str(cwd.relative_to(self.workspace_root)),
"cwd": cwd.relative_to(self.workspace_root).as_posix(),
"returncode": completed.returncode,
"stdout": completed.stdout[-12000:],
"stderr": completed.stderr[-12000:],
"stdout": completed.stdout[-self.config.max_command_output_bytes :],
"stderr": completed.stderr[-self.config.max_command_output_bytes :],
}
@staticmethod
def encode_result(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False)