Expand server tools and bot controls
This commit is contained in:
parent
59792f5d5f
commit
2b8bc6ed8b
13
README.md
13
README.md
|
|
@ -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`
|
||||
|
|
|
|||
74
bot/app.py
74
bot/app.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
serv/app.py
13
serv/app.py
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
18
serv/llm.py
18
serv/llm.py
|
|
@ -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",
|
||||
)
|
||||
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}],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
170
serv/tools.py
170
serv/tools.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue