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` - хранение токенов в `~/.qwen/oauth_creds.json`
- HTTP API сервера - HTTP API сервера
- агентный цикл с tool calling - агентный цикл с 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 без внешних библиотек - Telegram polling без внешних библиотек
- JSON-хранилище сессий - JSON-хранилище сессий
- API списка и просмотра сессий
## Ограничения текущей реализации ## Ограничения текущей реализации
@ -78,3 +79,13 @@ python3 bot/app.py
curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start 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]: def load_state() -> dict[str, Any]:
if not STATE_FILE.exists(): 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")) 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")) 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") status = get_json(f"{config.server_url}/api/v1/auth/status")
if status.get("authenticated"): if status.get("authenticated"):
return True return True
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {}) 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( api.send_message(
chat_id, chat_id,
"Qwen OAuth не настроен.\n" "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_key = f"{chat_id}:{user_id}"
session_id = state.setdefault("sessions", {}).get(session_key) session_id = state.setdefault("sessions", {}).get(session_key)
state.setdefault("auth_flows", {})
if text == "/start": 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 return
if text == "/auth": if text == "/auth":
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {}) started = post_json(f"{config.server_url}/api/v1/auth/device/start", {})
state["auth_flows"][str(chat_id)] = started["flow_id"]
api.send_message( api.send_message(
chat_id, chat_id,
"Откройте ссылку для авторизации Qwen OAuth:\n" "Откройте ссылку для авторизации Qwen OAuth:\n"
@ -82,19 +107,52 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
if text.startswith("/auth_check"): if text.startswith("/auth_check"):
parts = text.split(maxsplit=1) parts = text.split(maxsplit=1)
if len(parts) != 2: flow_id = parts[1] if len(parts) == 2 else state["auth_flows"].get(str(chat_id))
api.send_message(chat_id, "Использование: /auth_check <flow_id>") if not flow_id:
api.send_message(chat_id, "Нет активного flow_id. Сначала вызови /auth.")
return return
result = post_json( result = post_json(
f"{config.server_url}/api/v1/auth/device/poll", f"{config.server_url}/api/v1/auth/device/poll",
{"flow_id": parts[1]}, {"flow_id": flow_id},
) )
if result.get("done"): if result.get("done"):
state["auth_flows"].pop(str(chat_id), None)
api.send_message(chat_id, "Qwen OAuth успешно настроен.") api.send_message(chat_id, "Qwen OAuth успешно настроен.")
else: else:
api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.") api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.")
return 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 text == "/clear":
if session_id: if session_id:
post_json(f"{config.server_url}/api/v1/session/clear", {"session_id": 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, "Контекст сессии очищен.") api.send_message(chat_id, "Контекст сессии очищен.")
return return
if not ensure_auth(api, config, chat_id): if not ensure_auth(api, config, state, chat_id):
return return
api.send_message(chat_id, "Обрабатываю запрос...") 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"] state["sessions"][session_key] = result["session_id"]
answer = result.get("answer") or "Пустой ответ от модели." answer = result.get("answer") or "Пустой ответ от модели."
api.send_message(chat_id, answer[:4000]) send_text_chunks(api, chat_id, answer)
def main() -> None: 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_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions
NEW_QWEN_SYSTEM_PROMPT= NEW_QWEN_SYSTEM_PROMPT=
NEW_QWEN_MAX_TOOL_ROUNDS=8 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.config = config
self.oauth = QwenOAuthManager() self.oauth = QwenOAuthManager()
self.sessions = SessionStore(config.session_dir) 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.agent = QwenAgent(config, self.oauth, self.tools)
self.pending_device_flows: dict[str, DeviceAuthState] = {} self.pending_device_flows: dict[str, DeviceAuthState] = {}
self.lock = threading.Lock() self.lock = threading.Lock()
@ -65,6 +65,9 @@ class RequestHandler(BaseHTTPRequestHandler):
if self.path == "/api/v1/auth/status": if self.path == "/api/v1/auth/status":
self._send(HTTPStatus.OK, self.app.auth_status()) self._send(HTTPStatus.OK, self.app.auth_status())
return 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"}) self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def do_POST(self) -> None: def do_POST(self) -> None:
@ -120,6 +123,7 @@ class RequestHandler(BaseHTTPRequestHandler):
"user_id": user_id, "user_id": user_id,
"updated_at": int(time.time()), "updated_at": int(time.time()),
"messages": persisted_messages, "messages": persisted_messages,
"last_answer": result["answer"],
}, },
) )
self._send( self._send(
@ -133,6 +137,12 @@ class RequestHandler(BaseHTTPRequestHandler):
) )
return 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": if self.path == "/api/v1/session/clear":
body = self._json_body() body = self._json_body()
session_id = body["session_id"] session_id = body["session_id"]
@ -163,4 +173,3 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -25,6 +25,8 @@ class ServerConfig:
session_dir: Path session_dir: Path
system_prompt: str system_prompt: str
max_tool_rounds: int max_tool_rounds: int
max_file_read_bytes: int
max_command_output_bytes: int
@classmethod @classmethod
def load(cls) -> "ServerConfig": def load(cls) -> "ServerConfig":
@ -47,5 +49,10 @@ class ServerConfig:
session_dir=session_dir.resolve(), session_dir=session_dir.resolve(),
system_prompt=os.environ.get("NEW_QWEN_SYSTEM_PROMPT", "").strip(), system_prompt=os.environ.get("NEW_QWEN_SYSTEM_PROMPT", "").strip(),
max_tool_rounds=int(os.environ.get("NEW_QWEN_MAX_TOOL_ROUNDS", "8")), 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 import json
from typing import Any from typing import Any
from urllib import request from urllib import error, request
from config import ServerConfig from config import ServerConfig
from oauth import OAuthError, QwenOAuthManager from oauth import OAuthError, QwenOAuthManager
@ -42,8 +42,12 @@ class QwenAgent:
}, },
method="POST", method="POST",
) )
with request.urlopen(req, timeout=180) as response: try:
return json.loads(response.read().decode("utf-8")) 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]: def run(self, history: list[dict[str, Any]], user_message: str) -> dict[str, Any]:
system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT 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: def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True) self.base_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.RLock()
def _path(self, session_id: str) -> Path: def _path(self, session_id: str) -> Path:
return self.base_dir / f"{session_id}.json" return self.base_dir / f"{session_id}.json"
@ -35,9 +35,27 @@ class SessionStore:
self.save(session_id, payload) self.save(session_id, payload)
return 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: def clear(self, session_id: str) -> None:
with self._lock: with self._lock:
path = self._path(session_id) path = self._path(session_id)
if path.exists(): if path.exists():
path.unlink() path.unlink()

View File

@ -1,22 +1,32 @@
from __future__ import annotations from __future__ import annotations
import fnmatch
import json import json
import os
import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from config import ServerConfig
class ToolError(RuntimeError): class ToolError(RuntimeError):
pass pass
class ToolRegistry: class ToolRegistry:
def __init__(self, workspace_root: Path) -> None: def __init__(self, config: ServerConfig) -> None:
self.workspace_root = workspace_root.resolve() self.config = config
self.workspace_root = config.workspace_root.resolve()
self._handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { self._handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"list_files": self.list_files, "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, "read_file": self.read_file,
"write_file": self.write_file, "write_file": self.write_file,
"make_directory": self.make_directory,
"exec_command": self.exec_command, "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", "type": "function",
"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", "type": "function",
"function": { "function": {
@ -108,25 +178,108 @@ class ToolRegistry:
items.append( items.append(
{ {
"name": item.name, "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", "type": "dir" if item.is_dir() else "file",
} }
) )
return {"items": items} 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]: def read_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"]) target = self._resolve(arguments["path"])
if not target.exists(): if not target.exists():
raise ToolError("File does not exist") raise ToolError("File does not exist")
if not target.is_file(): if not target.is_file():
raise ToolError("Path is not a 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]: def write_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"]) target = self._resolve(arguments["path"])
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(arguments["content"], encoding="utf-8") 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]: def exec_command(self, arguments: dict[str, Any]) -> dict[str, Any]:
cwd = self._resolve(arguments.get("cwd", ".")) cwd = self._resolve(arguments.get("cwd", "."))
@ -141,13 +294,12 @@ class ToolRegistry:
) )
return { return {
"command": command, "command": command,
"cwd": str(cwd.relative_to(self.workspace_root)), "cwd": cwd.relative_to(self.workspace_root).as_posix(),
"returncode": completed.returncode, "returncode": completed.returncode,
"stdout": completed.stdout[-12000:], "stdout": completed.stdout[-self.config.max_command_output_bytes :],
"stderr": completed.stderr[-12000:], "stderr": completed.stderr[-self.config.max_command_output_bytes :],
} }
@staticmethod @staticmethod
def encode_result(result: dict[str, Any]) -> str: def encode_result(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)