Add chat job polling and edit tool

This commit is contained in:
mirivlad 2026-04-07 17:05:30 +08:00
parent f9b9d7d242
commit 940bef2f4a
6 changed files with 278 additions and 11 deletions

View File

@ -28,12 +28,13 @@ Qwen OAuth + OpenAI-compatible endpoint
- хранение токенов в `~/.qwen/oauth_creds.json` - хранение токенов в `~/.qwen/oauth_creds.json`
- HTTP API сервера - HTTP API сервера
- агентный цикл с tool calling - агентный цикл с tool calling
- инструменты: `list_files`, `glob_search`, `grep_text`, `stat_path`, `read_file`, `write_file`, `make_directory`, `exec_command` - инструменты: `list_files`, `glob_search`, `grep_text`, `stat_path`, `read_file`, `replace_in_file`, `write_file`, `make_directory`, `exec_command`
- Telegram polling без внешних библиотек - Telegram polling без внешних библиотек
- JSON-хранилище сессий - JSON-хранилище сессий
- API списка и просмотра сессий - API списка и просмотра сессий
- автоматический polling OAuth flow в боте - автоматический polling OAuth flow в боте
- очередь сообщений, пришедших до завершения OAuth - очередь сообщений, пришедших до завершения OAuth
- job-based chat polling между `bot` и `serv`
## Ограничения текущей реализации ## Ограничения текущей реализации
@ -93,3 +94,5 @@ curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start
- `POST /api/v1/session/get` - `POST /api/v1/session/get`
- `POST /api/v1/session/clear` - `POST /api/v1/session/clear`
- `POST /api/v1/chat` - `POST /api/v1/chat`
- `POST /api/v1/chat/start`
- `POST /api/v1/chat/poll`

View File

@ -51,6 +51,24 @@ def send_text_chunks(api: TelegramAPI, chat_id: int, text: str) -> None:
api.send_message(chat_id, normalized[start : start + chunk_size]) api.send_message(chat_id, normalized[start : start + chunk_size])
def summarize_event(event: dict[str, Any]) -> str | None:
event_type = event.get("type")
if event_type == "job_status":
return event.get("message")
if event_type == "model_request":
return "Думаю над ответом"
if event_type == "tool_call":
return f"Вызываю инструмент: {event.get('name')}"
if event_type == "tool_result":
result = event.get("result", {})
if isinstance(result, dict) and "error" in result:
return f"Инструмент {event.get('name')} завершился с ошибкой"
return f"Инструмент {event.get('name')} завершён"
if event_type == "error":
return f"Ошибка: {event.get('message')}"
return None
def get_auth_flow(state: dict[str, Any], chat_id: int) -> dict[str, Any] | None: def get_auth_flow(state: dict[str, Any], chat_id: int) -> dict[str, Any] | None:
return state.setdefault("auth_flows", {}).get(str(chat_id)) return state.setdefault("auth_flows", {}).get(str(chat_id))
@ -141,16 +159,39 @@ def deliver_chat_message(
session_id = state.setdefault("sessions", {}).get(session_key) session_id = state.setdefault("sessions", {}).get(session_key)
prefix = "Обрабатываю отложенный запрос..." if delayed else "Обрабатываю запрос..." prefix = "Обрабатываю отложенный запрос..." if delayed else "Обрабатываю запрос..."
api.send_message(chat_id, prefix) api.send_message(chat_id, prefix)
result = post_json( start_result = post_json(
f"{config.server_url}/api/v1/chat", f"{config.server_url}/api/v1/chat/start",
{ {
"session_id": session_id, "session_id": session_id,
"user_id": user_id, "user_id": user_id,
"message": text, "message": text,
}, },
) )
state["sessions"][session_key] = result["session_id"] state["sessions"][session_key] = start_result["session_id"]
answer = result.get("answer") or "Пустой ответ от модели." job_id = start_result["job_id"]
seen_seq = 0
sent_statuses: set[str] = set()
answer = None
while True:
poll_result = post_json(
f"{config.server_url}/api/v1/chat/poll",
{"job_id": job_id, "since_seq": seen_seq},
)
for event in poll_result.get("events", []):
seen_seq = max(seen_seq, int(event.get("seq", 0)))
summary = summarize_event(event)
if summary and summary not in sent_statuses:
api.send_message(chat_id, summary[:4000])
sent_statuses.add(summary)
if poll_result.get("status") == "completed":
answer = poll_result.get("answer")
state["sessions"][session_key] = poll_result["session_id"]
break
if poll_result.get("status") == "failed":
raise RuntimeError(poll_result.get("error") or "Chat job failed")
time.sleep(1.2)
answer = answer or "Пустой ответ от модели."
send_text_chunks(api, chat_id, answer) send_text_chunks(api, chat_id, answer)

View File

@ -9,6 +9,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from config import ServerConfig from config import ServerConfig
from jobs import JobStore
from llm import QwenAgent from llm import QwenAgent
from oauth import DeviceAuthState, OAuthError, QwenOAuthManager from oauth import DeviceAuthState, OAuthError, QwenOAuthManager
from sessions import SessionStore from sessions import SessionStore
@ -22,6 +23,7 @@ class AppState:
self.sessions = SessionStore(config.session_dir) self.sessions = SessionStore(config.session_dir)
self.tools = ToolRegistry(config) self.tools = ToolRegistry(config)
self.agent = QwenAgent(config, self.oauth, self.tools) self.agent = QwenAgent(config, self.oauth, self.tools)
self.jobs = JobStore()
self.pending_device_flows: dict[str, DeviceAuthState] = {} self.pending_device_flows: dict[str, DeviceAuthState] = {}
self.lock = threading.Lock() self.lock = threading.Lock()
@ -70,6 +72,47 @@ class RequestHandler(BaseHTTPRequestHandler):
return return
self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"}) self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def _run_chat_job(self, job_id: str, session_id: str, user_id: str, message: str) -> None:
try:
self.app.jobs.set_status(job_id, "running")
self.app.jobs.append_event(
job_id,
{"type": "job_status", "message": "Запрос принят сервером"},
)
session = self.app.sessions.load(session_id)
history = session.get("messages", [])
result = self.app.agent.run(
history,
message,
on_event=lambda event: self.app.jobs.append_event(job_id, event),
)
persisted_messages = result["messages"][1:]
self.app.sessions.save(
session_id,
{
"session_id": session_id,
"user_id": user_id,
"updated_at": int(time.time()),
"messages": persisted_messages,
"last_answer": result["answer"],
},
)
self.app.jobs.append_event(
job_id,
{"type": "job_status", "message": "Ответ готов"},
)
self.app.jobs.finish(
job_id,
answer=result["answer"],
usage=result.get("usage"),
)
except Exception as exc:
self.app.jobs.append_event(
job_id,
{"type": "error", "message": str(exc)},
)
self.app.jobs.fail(job_id, str(exc))
def do_POST(self) -> None: def do_POST(self) -> None:
try: try:
if self.path == "/api/v1/auth/device/start": if self.path == "/api/v1/auth/device/start":
@ -137,6 +180,53 @@ class RequestHandler(BaseHTTPRequestHandler):
) )
return return
if self.path == "/api/v1/chat/start":
body = self._json_body()
session_id = body.get("session_id") or uuid.uuid4().hex
user_id = str(body.get("user_id") or "anonymous")
message = body["message"]
job = self.app.jobs.create(session_id, user_id, message)
thread = threading.Thread(
target=self._run_chat_job,
args=(job["job_id"], session_id, user_id, message),
daemon=True,
)
thread.start()
self._send(
HTTPStatus.OK,
{
"job_id": job["job_id"],
"session_id": session_id,
"status": "queued",
},
)
return
if self.path == "/api/v1/chat/poll":
body = self._json_body()
job_id = body["job_id"]
since_seq = int(body.get("since_seq", 0))
job = self.app.jobs.get(job_id)
if not job:
self._send(HTTPStatus.NOT_FOUND, {"error": "Unknown job_id"})
return
events = [
event for event in job.get("events", []) if event.get("seq", 0) > since_seq
]
self._send(
HTTPStatus.OK,
{
"job_id": job_id,
"session_id": job["session_id"],
"status": job["status"],
"events": events,
"answer": job.get("answer"),
"usage": job.get("usage"),
"error": job.get("error"),
},
)
return
if self.path == "/api/v1/session/get": if self.path == "/api/v1/session/get":
body = self._json_body() body = self._json_body()
session_id = body["session_id"] session_id = body["session_id"]

76
serv/jobs.py Normal file
View File

@ -0,0 +1,76 @@
from __future__ import annotations
import threading
import time
import uuid
from typing import Any
class JobStore:
def __init__(self) -> None:
self._jobs: dict[str, dict[str, Any]] = {}
self._lock = threading.RLock()
def create(self, session_id: str, user_id: str, message: str) -> dict[str, Any]:
job_id = uuid.uuid4().hex
job = {
"job_id": job_id,
"session_id": session_id,
"user_id": user_id,
"message": message,
"status": "queued",
"created_at": time.time(),
"updated_at": time.time(),
"events": [],
"answer": None,
"usage": None,
"error": None,
}
with self._lock:
self._jobs[job_id] = job
return job
def get(self, job_id: str) -> dict[str, Any] | None:
with self._lock:
job = self._jobs.get(job_id)
if not job:
return None
return {
key: (value.copy() if isinstance(value, list) else value)
for key, value in job.items()
}
def append_event(self, job_id: str, event: dict[str, Any]) -> None:
with self._lock:
job = self._jobs[job_id]
seq = len(job["events"]) + 1
job["events"].append({"seq": seq, **event})
job["updated_at"] = time.time()
def set_status(self, job_id: str, status: str) -> None:
with self._lock:
job = self._jobs[job_id]
job["status"] = status
job["updated_at"] = time.time()
def finish(
self,
job_id: str,
*,
answer: str,
usage: dict[str, Any] | None,
) -> None:
with self._lock:
job = self._jobs[job_id]
job["status"] = "completed"
job["answer"] = answer
job["usage"] = usage
job["updated_at"] = time.time()
def fail(self, job_id: str, error_message: str) -> None:
with self._lock:
job = self._jobs[job_id]
job["status"] = "failed"
job["error"] = error_message
job["updated_at"] = time.time()

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any from typing import Any, Callable
from urllib import error, request from urllib import error, request
from config import ServerConfig from config import ServerConfig
@ -49,7 +49,13 @@ class QwenAgent:
body = exc.read().decode("utf-8", errors="replace") body = exc.read().decode("utf-8", errors="replace")
raise OAuthError(f"LLM request failed with HTTP {exc.code}: {body}") from exc 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,
on_event: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
emit = on_event or (lambda _event: None)
system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
messages.extend(history) messages.extend(history)
@ -57,12 +63,15 @@ class QwenAgent:
events: list[dict[str, Any]] = [] events: list[dict[str, Any]] = []
for _ in range(self.config.max_tool_rounds): for _ in range(self.config.max_tool_rounds):
emit({"type": "model_request", "message": "Запрашиваю ответ модели"})
response = self._request_completion(messages) response = self._request_completion(messages)
choice = response["choices"][0]["message"] choice = response["choices"][0]["message"]
tool_calls = choice.get("tool_calls") or [] tool_calls = choice.get("tool_calls") or []
content = choice.get("content") content = choice.get("content")
if content: if content:
events.append({"type": "assistant", "content": content}) assistant_event = {"type": "assistant", "content": content}
events.append(assistant_event)
emit(assistant_event)
if not tool_calls: if not tool_calls:
return { return {
@ -87,7 +96,9 @@ class QwenAgent:
except json.JSONDecodeError: except json.JSONDecodeError:
arguments = {} arguments = {}
events.append({"type": "tool_call", "name": tool_name, "arguments": arguments}) tool_call_event = {"type": "tool_call", "name": tool_name, "arguments": arguments}
events.append(tool_call_event)
emit(tool_call_event)
try: try:
result = self.tools.execute(tool_name, arguments) result = self.tools.execute(tool_name, arguments)
except ToolError as exc: except ToolError as exc:
@ -95,7 +106,9 @@ class QwenAgent:
except Exception as exc: except Exception as exc:
result = {"error": f"Unexpected tool failure: {exc}"} result = {"error": f"Unexpected tool failure: {exc}"}
events.append({"type": "tool_result", "name": tool_name, "result": result}) tool_result_event = {"type": "tool_result", "name": tool_name, "result": result}
events.append(tool_result_event)
emit(tool_result_event)
messages.append( messages.append(
{ {
"role": "tool", "role": "tool",
@ -107,7 +120,9 @@ class QwenAgent:
final_message = ( final_message = (
"Остановлено по лимиту tool rounds. Попробуй сузить задачу или продолжить отдельным сообщением." "Остановлено по лимиту tool rounds. Попробуй сузить задачу или продолжить отдельным сообщением."
) )
events.append({"type": "assistant", "content": final_message}) final_event = {"type": "assistant", "content": final_message}
events.append(final_event)
emit(final_event)
return { return {
"answer": final_message, "answer": final_message,
"events": events, "events": events,

View File

@ -25,6 +25,7 @@ class ToolRegistry:
"grep_text": self.grep_text, "grep_text": self.grep_text,
"stat_path": self.stat_path, "stat_path": self.stat_path,
"read_file": self.read_file, "read_file": self.read_file,
"replace_in_file": self.replace_in_file,
"write_file": self.write_file, "write_file": self.write_file,
"make_directory": self.make_directory, "make_directory": self.make_directory,
"exec_command": self.exec_command, "exec_command": self.exec_command,
@ -106,6 +107,23 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "replace_in_file",
"description": "Replace exact text in a workspace file without rewriting unrelated content.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
"expected_count": {"type": "integer"},
},
"required": ["path", "old_text", "new_text"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@ -276,6 +294,30 @@ class ToolRegistry:
"bytes_written": len(arguments["content"].encode("utf-8")), "bytes_written": len(arguments["content"].encode("utf-8")),
} }
def replace_in_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")
old_text = arguments["old_text"]
new_text = arguments["new_text"]
expected_count = arguments.get("expected_count")
content = target.read_text(encoding="utf-8")
count = content.count(old_text)
if count == 0:
raise ToolError("old_text not found in file")
if expected_count is not None and count != int(expected_count):
raise ToolError(
f"expected_count mismatch: found {count}, expected {int(expected_count)}"
)
updated = content.replace(old_text, new_text)
target.write_text(updated, encoding="utf-8")
return {
"path": target.relative_to(self.workspace_root).as_posix(),
"replacements": count,
}
def make_directory(self, arguments: dict[str, Any]) -> dict[str, Any]: def make_directory(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"]) target = self._resolve(arguments["path"])
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)