fix: safely update named notes
All checks were successful
delivery / quality (3.10) (push) Successful in 13s
delivery / quality (3.12) (push) Successful in 12s
delivery / build-and-push (push) Successful in 3s
delivery / deploy (push) Successful in 6s

This commit is contained in:
kandrusyak
2026-07-28 01:14:34 +03:00
parent 971e32ecd1
commit 172e1af430
2 changed files with 264 additions and 37 deletions

View File

@@ -70,30 +70,6 @@ MUTATION_SUCCESS_MESSAGES = {
"delete_status": "Отслеживаемый объект{suffix} удалён.",
}
MUTATION_ACTION_LABELS = {
"remember": "сохранить информацию в памяти",
"delete_memory": "удалить запись памяти",
"create_note": "сохранить заметку",
"update_note": "обновить заметку",
"delete_note": "удалить заметку",
"create_reminder": "создать напоминание",
"update_reminder": "обновить напоминание",
"cancel_reminder": "отменить напоминание",
"create_status": "создать отслеживаемый объект",
"update_status": "обновить статус объекта",
"delete_status": "удалить отслеживаемый объект",
}
MUTATION_ERROR_MESSAGES = {
"text is required": "не указан текст",
"valid id is required": "не указан корректный идентификатор",
"when and text are required": "не указаны время или текст",
"when or text is required": "не указаны новое время или текст",
"could not parse reminder time": "не удалось распознать время",
"reminder time must be in the future": "время должно быть в будущем",
}
def mutating_tool_call_key(
name: str,
arguments: dict[str, Any],
@@ -130,6 +106,12 @@ def render_mutating_tool_results(
for item in tool_results
):
return None
if any(
not isinstance(item.get("result"), dict)
or item["result"].get("ok") is not True
for item in tool_results
):
return None
messages: list[str] = []
for item in tool_results:
@@ -161,16 +143,6 @@ def render_mutating_tool_results(
)
continue
raw_error = result.get("error")
reason = (
MUTATION_ERROR_MESSAGES.get(str(raw_error), str(raw_error))
if raw_error
else "объект не найден или уже отсутствует"
)
messages.append(
f"Не удалось {MUTATION_ACTION_LABELS[name]}{suffix}: {reason}."
)
return " ".join(messages)
@@ -235,6 +207,31 @@ def cancel_reminder_is_authorized(current_request: str) -> bool:
)
def note_update_target_is_grounded(
current_request: str,
arguments: dict[str, Any],
listed_note_ids: set[int],
) -> bool:
"""Reject note ids guessed without an explicit id or a fresh list result."""
try:
note_id = int(str(arguments.get("id", "")).strip())
except ValueError:
return False
if note_id <= 0:
return False
if note_id in listed_note_ids:
return True
escaped_id = re.escape(str(note_id))
explicit_id_pattern = re.compile(
rf"(?:[#№]\s*{escaped_id}\b|"
rf"\b(?:id|ид|номер)\s*[:#№]?\s*{escaped_id}\b|"
rf"\аметк\w*\s+{escaped_id}\b)",
re.IGNORECASE,
)
return explicit_id_pattern.search(current_request) is not None
def build_agent_tool_prompt(_tz: ZoneInfo | None = None) -> str:
return (
"Ты работаешь как агент с внутренними tools. Пользователь пишет свободным текстом, "
@@ -277,7 +274,13 @@ def build_agent_tool_prompt(_tz: ZoneInfo | None = None) -> str:
"и отмены используй соответствующие list_*, delete_* и cancel_reminder.\n"
"- Если пользователь просит дополнить заметку или список, используй update_note: "
"в text передай полный новый текст, сохранив прежнее содержимое и добавив новые "
"данные. Если id или прежний текст неизвестны, сначала вызови list_notes. "
"данные. Если пользователь не указал id заметки явно в текущем запросе, "
"сначала обязательно вызови list_notes и выбери id из его результата. "
"Если прежний текст неизвестен, также сначала вызови list_notes. "
"Если после list_notes подходящей заметки нет, создай ее через create_note: "
"в text передай понятное название заметки или списка и данные, которые просил "
"добавить пользователь. Не сообщай об отсутствии и не спрашивай подтверждение, "
"если название и новое содержимое однозначны. "
"Никогда не используй delete_note для изменения, замены или дополнения заметки.\n"
"- Если пользователь просит перенести напоминание или изменить его текст, "
"используй update_reminder. Передай только изменяемые поля when и/или text; "
@@ -487,6 +490,7 @@ async def run_agent_prompt(
try:
last_tool_results: list[dict[str, Any]] = []
successful_mutating_calls: set[str] = set()
listed_note_ids: set[int] = set()
reset_context: bool | None = None
final_answer: str | None = None
@@ -548,6 +552,49 @@ async def run_agent_prompt(
)
continue
ungrounded_note_updates = [
call
for call in decision.tool_calls
if (
str(call.get("name", "")).strip().lower()
== "update_note"
and not note_update_target_is_grounded(
prompt,
call.get("arguments", {}),
listed_note_ids,
)
)
]
if ungrounded_note_updates:
messages.append(
{
"role": "assistant",
"content": raw_answer,
}
)
messages.append(
{
"role": "user",
"content": json_dumps(
{
"kind": "protocol_error",
"error": (
"update_note запрещен: id заметки не "
"указан явно в current_request и не "
"получен из list_notes в этом запросе. "
"Сначала вызови только list_notes, затем "
"выбери подходящую заметку из result.items "
"и передай ее полный обновленный текст. "
"Если подходящей заметки нет, создай ее "
"через create_note с понятным названием "
"и данными из current_request."
),
}
),
}
)
continue
tool_results = []
for call in decision.tool_calls:
tool_name = str(call.get("name", "")).strip().lower()
@@ -582,6 +629,16 @@ async def run_agent_prompt(
and result.get("ok") is True
):
successful_mutating_calls.add(call_key)
if (
tool_name == "list_notes"
and result.get("ok") is True
):
for item in result.get("items", []):
if not isinstance(item, dict):
continue
item_id = item.get("id")
if isinstance(item_id, int) and item_id > 0:
listed_note_ids.add(item_id)
tool_results.append(
{
"name": tool_name,