refactor: centralize agent tool registry

This commit is contained in:
kandrusyak
2026-07-27 17:44:37 +03:00
parent 79ed741a3b
commit 248e90faf5
3 changed files with 427 additions and 203 deletions

View File

@@ -1,30 +1,29 @@
import json
import logging
import re
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from telegram import Update
from telegram.ext import ContextTypes
from .agent_tools import execute_agent_tool, render_agent_tool_catalog
from .ai import AIClientError
from .config import CONVERSATION_HISTORY_LIMIT, MAX_AGENT_STEPS
from .models import AgentDecision, ReminderParseResult
from .models import AgentDecision
from .prompts import ASSISTANT_SYSTEM_PROMPT
from .reminders import parse_reminder
from .storage import AssistantStorage
from .telegram_utils import (
escape_markdown_text,
get_ai_client,
get_storage,
get_tz,
parse_positive_int,
reply_markdown,
require_user_id,
typing_action,
)
from .time_utils import format_local_dt, to_utc_iso, utc_now
from .time_utils import format_local_dt
logger = logging.getLogger(__name__)
@@ -92,25 +91,7 @@ def build_agent_tool_prompt(tz: ZoneInfo) -> str:
"читаемость. Для короткого простого ответа разметка не обязательна. "
"Не добавляй декоративные эмодзи чаще одного раза на сообщение.\n\n"
"Доступные tools:\n"
"- get_current_datetime {} - узнать текущее локальное и UTC-время.\n"
'- remember {"text": string} - сохранить важный долгосрочный факт о пользователе.\n'
'- list_memory {"limit": number} - показать сохраненную память.\n'
'- delete_memory {"id": number} - удалить запись памяти.\n'
'- create_note {"text": string} - сохранить заметку.\n'
'- list_notes {"limit": number} - показать заметки.\n'
'- delete_note {"id": number} - удалить заметку.\n'
'- create_reminder {"when": string, "text": string} - поставить напоминание. '
"when можно указывать как '30m', 'через 2 часа', '18:30', '2026-07-16 18:30'. "
"Если пользователь говорит 'завтра/послезавтра/через неделю', сам рассчитай "
"дату от текущего локального времени и передай 'YYYY-MM-DD HH:MM'.\n"
'- list_reminders {"limit": number} - показать активные напоминания.\n'
'- cancel_reminder {"id": number} - отменить напоминание.\n'
'- create_status {"title": string, "status": string} - начать отслеживать статус.\n'
'- list_statuses {"limit": number} - показать отслеживаемые статусы.\n'
'- update_status {"id": number, "status": string} - обновить статус.\n'
'- delete_status {"id": number} - удалить отслеживаемый объект.\n\n'
'- search_conversation {"query": string, "limit": number} - найти старое обсуждение '
"во всей сохраненной переписке по содержательным ключевым словам.\n\n"
f"{render_agent_tool_catalog()}\n\n"
"Правила:\n"
"- Для просьб 'запомни', 'сохрани как факт', 'учти на будущее' используй remember.\n"
"- Для заметок используй create_note, для напоминаний create_reminder, "
@@ -205,185 +186,6 @@ def save_conversation_exchange(
storage.add_conversation_exchange(user_id, chat_id, prompt, answer)
def coerce_positive_int(value: Any, default: int, maximum: int = 50) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
if parsed <= 0:
return default
return min(parsed, maximum)
def coerce_required_text(arguments: dict[str, Any], key: str) -> str:
value = arguments.get(key, "")
return str(value).strip()
def parse_agent_reminder(
when: str, text: str, tz: ZoneInfo
) -> ReminderParseResult | None:
parsed = parse_reminder(f"{when} {text}".strip(), tz)
if parsed:
return parsed
normalized_when = when.strip().replace("T", " ")
try:
parsed_dt = datetime.fromisoformat(normalized_when)
except ValueError:
return None
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=tz)
return ReminderParseResult(parsed_dt.astimezone(timezone.utc), text)
def execute_agent_tool(
name: str,
arguments: dict[str, Any],
storage: AssistantStorage,
user_id: int,
chat_id: int,
tz: ZoneInfo,
) -> dict[str, Any]:
tool_name = name.strip().lower()
if tool_name == "get_current_datetime":
now = utc_now()
return {
"ok": True,
"local": now.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
"utc": to_utc_iso(now),
"timezone": tz.key,
}
if tool_name == "remember":
text = coerce_required_text(arguments, "text")
if not text:
return {"ok": False, "error": "text is required"}
memory_id = storage.add_memory(user_id, text)
return {"ok": True, "id": memory_id, "text": text}
if tool_name == "list_memory":
rows = storage.list_memories(
user_id, coerce_positive_int(arguments.get("limit"), 20)
)
return {"ok": True, "items": rows}
if tool_name == "delete_memory":
memory_id = parse_positive_int(str(arguments.get("id", "")))
if memory_id is None:
return {"ok": False, "error": "valid id is required"}
return {"ok": storage.delete_memory(user_id, memory_id), "id": memory_id}
if tool_name == "create_note":
text = coerce_required_text(arguments, "text")
if not text:
return {"ok": False, "error": "text is required"}
note_id = storage.add_note(user_id, text)
return {"ok": True, "id": note_id, "text": text}
if tool_name == "list_notes":
rows = storage.list_notes(
user_id, coerce_positive_int(arguments.get("limit"), 20)
)
return {"ok": True, "items": rows}
if tool_name == "delete_note":
note_id = parse_positive_int(str(arguments.get("id", "")))
if note_id is None:
return {"ok": False, "error": "valid id is required"}
return {"ok": storage.delete_note(user_id, note_id), "id": note_id}
if tool_name == "create_reminder":
when = coerce_required_text(arguments, "when")
text = coerce_required_text(arguments, "text")
if not when or not text:
return {"ok": False, "error": "when and text are required"}
parsed = parse_agent_reminder(when, text, tz)
if not parsed:
return {"ok": False, "error": "could not parse reminder time"}
if parsed.remind_at_utc <= utc_now():
return {"ok": False, "error": "reminder time must be in the future"}
reminder_id = storage.add_reminder(
user_id, chat_id, parsed.text, parsed.remind_at_utc
)
return {
"ok": True,
"id": reminder_id,
"text": parsed.text,
"remind_at": format_local_dt(parsed.remind_at_utc, tz),
}
if tool_name == "list_reminders":
rows = storage.list_reminders(
user_id, coerce_positive_int(arguments.get("limit"), 20)
)
for row in rows:
row["remind_at_local"] = format_local_dt(row["remind_at"], tz)
return {"ok": True, "items": rows}
if tool_name == "cancel_reminder":
reminder_id = parse_positive_int(str(arguments.get("id", "")))
if reminder_id is None:
return {"ok": False, "error": "valid id is required"}
return {"ok": storage.cancel_reminder(user_id, reminder_id), "id": reminder_id}
if tool_name == "create_status":
title = coerce_required_text(arguments, "title")
status = coerce_required_text(arguments, "status") or "open"
if not title:
return {"ok": False, "error": "title is required"}
item_id = storage.add_tracked_item(user_id, title, status)
return {"ok": True, "id": item_id, "title": title, "status": status}
if tool_name == "list_statuses":
rows = storage.list_tracked_items(
user_id, coerce_positive_int(arguments.get("limit"), 30)
)
return {"ok": True, "items": rows}
if tool_name == "update_status":
item_id = parse_positive_int(str(arguments.get("id", "")))
status = coerce_required_text(arguments, "status")
if item_id is None or not status:
return {"ok": False, "error": "valid id and status are required"}
return {
"ok": storage.set_tracked_status(user_id, item_id, status),
"id": item_id,
"status": status,
}
if tool_name == "delete_status":
item_id = parse_positive_int(str(arguments.get("id", "")))
if item_id is None:
return {"ok": False, "error": "valid id is required"}
return {"ok": storage.delete_tracked_item(user_id, item_id), "id": item_id}
if tool_name == "search_conversation":
query = coerce_required_text(arguments, "query")
if not query:
return {"ok": False, "error": "query is required"}
limit = coerce_positive_int(arguments.get("limit"), 5, maximum=10)
hits = storage.search_conversation_messages(user_id, chat_id, query, limit)
discussions = [
{
"matched_message_id": hit["id"],
"score": hit["score"],
"messages": storage.conversation_message_window(
user_id,
chat_id,
int(hit["id"]),
),
}
for hit in hits
]
return {"ok": True, "query": query, "discussions": discussions}
return {"ok": False, "error": f"unknown tool: {name}"}
async def run_agent_prompt(
update: Update,
context: ContextTypes.DEFAULT_TYPE,