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`
|
- хранение токенов в `~/.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`
|
||||||
|
|
|
||||||
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]:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
serv/app.py
13
serv/app.py
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
18
serv/llm.py
18
serv/llm.py
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with request.urlopen(req, timeout=180) as response:
|
with request.urlopen(req, timeout=180) as response:
|
||||||
return json.loads(response.read().decode("utf-8"))
|
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}],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
170
serv/tools.py
170
serv/tools.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue