fix(llm_claude): store tool turns in history + stricter prompt

Bug: Claude hallucinated actions. User said «удалить таймер чайника»,
Claude replied «Таймер чайника отменён» без вызова cancel_timer.

Две причины:
1) История сохраняла только финальный текст предыдущих turn'ов.
   Claude видел «я говорил поставил таймер» и мог ответить «удалил» по
   паттерну без реального tool-use.
2) System prompt мягко просил использовать tools — Haiku иногда
   пропускал tool и отвечал сразу.

Фикс:
- История теперь содержит полные turn'ы (assistant с tool_use блоками,
  user с tool_result блоками). _build_messages/_strip_cache_control
  корректно обрабатывают content как string или list of blocks.
- System prompt добавил жёсткий раздел «ЖЁСТКИЕ ПРАВИЛА про tools»:
  явно запрещено говорить 'поставил/отменил/удалил' без вызова tool,
  информацию (погода, события) — только через tool, не выдумывать.

Размер истории вырастет (tool_result'ы могут быть по 500-2000 байт),
но это не проблема — prompt caching делает каждый turn дешёвым на
чтение (cache_r > 90% в логах).
This commit is contained in:
Cosmo
2026-04-23 14:04:27 +00:00
parent c7df540c0b
commit f530607503

View File

@@ -49,11 +49,20 @@ COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой а
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано. - Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
- Если не знаешь — скажи коротко, не оправдывайся. - Если не знаешь — скажи коротко, не оправдывайся.
Инструменты: ЖЁСТКИЕ ПРАВИЛА про tools:
У тебя есть tools для погоды, транспорта (трамваи на остановке Антонова-Овсеенко), 1. Любое ДЕЙСТВИЕ (поставить/отменить/изменить таймер, что-то включить/выключить)
календарных событий, заметок, и запуска таймера на дашборде. Используй их без просьбы делается ТОЛЬКО через вызов tool. Без tool действие не произошло.
разрешения — если пользователь спрашивает «какая погода», сразу вызывай get_weather, 2. Никогда не говори «поставил», «отменил», «удалил», «добавил», «изменил»,
потом формулируй ответ. Не пересказывай сырые данные дословно — дай человеческую сводку. если ты в этом же turn'e не вызвал соответствующий tool. Это галлюцинация,
пользователь потом обнаружит что ничего не изменилось и не будет тебе доверять.
3. Любая АКТУАЛЬНАЯ ИНФОРМАЦИЯ (погода, транспорт, события в календаре,
содержимое заметок) — всегда через tool. Не выдумывай числа и факты.
4. Порядок: сначала tool → потом в том же turn'e сформулируй ответ на основе
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
Доступные tools: get_weather, get_transport, get_today_events, get_notes,
set_timer, cancel_timer, adjust_timer.
Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}.""" Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}."""
@@ -65,9 +74,11 @@ LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой
- Без эмодзи, списков, код-блоков — это голос. - Без эмодзи, списков, код-блоков — это голос.
- Если не знаешь — скажи коротко. - Если не знаешь — скажи коротко.
Инструменты: ЖЁСТКИЕ ПРАВИЛА про tools:
У тебя есть tools — погода, трамваи, события, заметки, таймеры. Используй их 1. Действия (таймер, etc.) — только через вызов tool. Без tool действие не произошло.
без лишних вопросов, а результат формулируй человеческим языком. 2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
4. Tool → результат → короткий ответ человеческим языком.
Сегодня {today}.""" Сегодня {today}."""
@@ -126,11 +137,48 @@ def reset_history(agent_id: str):
log.info(f"История сброшена: {path}") log.info(f"История сброшена: {path}")
def _strip_cache_control(content):
"""Убирает cache_control из блоков — при сохранении в историю оно не нужно
(следующий turn заново посчитает границу)."""
if isinstance(content, list):
cleaned = []
for block in content:
if isinstance(block, dict):
block_copy = {k: v for k, v in block.items() if k != "cache_control"}
cleaned.append(block_copy)
else:
cleaned.append(block)
return cleaned
return content
def _wrap_last_block_with_cache(content):
"""Добавляет cache_control на последний блок/строку content.
Для string: оборачивает в [{type:text, text, cache_control}].
Для list[block]: делает копию и добавляет cache_control к последнему блоку."""
if isinstance(content, str):
return [{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
}]
if isinstance(content, list) and content:
new_list = list(content)
last = dict(new_list[-1]) if isinstance(new_list[-1], dict) else new_list[-1]
if isinstance(last, dict):
last["cache_control"] = {"type": "ephemeral"}
new_list[-1] = last
return new_list
return content
def _build_messages(history: list[dict]) -> list[dict]: def _build_messages(history: list[dict]) -> list[dict]:
""" """
Готовит messages array для Claude API с prompt caching. Готовит messages array для Claude API с prompt caching.
Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша), Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша),
а всё что раньше — помечается cache_control на границе. всё что раньше — помечается cache_control на границе (на последнем блоке
последнего «старого» сообщения).
Content может быть строкой или списком блоков (tool_use/tool_result turn'ы).
""" """
if len(history) <= CACHE_TAIL_UNCACHED: if len(history) <= CACHE_TAIL_UNCACHED:
return [{"role": m["role"], "content": m["content"]} for m in history] return [{"role": m["role"], "content": m["content"]} for m in history]
@@ -138,15 +186,10 @@ def _build_messages(history: list[dict]) -> list[dict]:
cache_boundary = len(history) - CACHE_TAIL_UNCACHED cache_boundary = len(history) - CACHE_TAIL_UNCACHED
messages = [] messages = []
for i, msg in enumerate(history): for i, msg in enumerate(history):
# Граница кеша — на последнем «старом» сообщении ставим cache_control.
if i == cache_boundary - 1: if i == cache_boundary - 1:
messages.append({ messages.append({
"role": msg["role"], "role": msg["role"],
"content": [{ "content": _wrap_last_block_with_cache(msg["content"]),
"type": "text",
"text": msg["content"],
"cache_control": {"type": "ephemeral"},
}],
}) })
else: else:
messages.append({"role": msg["role"], "content": msg["content"]}) messages.append({"role": msg["role"], "content": msg["content"]})
@@ -321,15 +364,19 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
_speak_if_local(msg) _speak_if_local(msg)
return msg return msg
# Сохраняем полный ассистентский turn (с tool-use/result) в историю. # Сохраняем полный ассистентский turn (включая tool_use / tool_result блоки).
# Для следующего turn'а history содержит: user text, затем assistant (text + tool_use), # Это критично чтобы Claude помнил что он реально делал инструментами —
# user (tool_result), assistant (final text). Всё в правильном порядке. # иначе на следующем turn'e он может галлюцинировать действия («отменил таймер»)
# Сейчас history у нас содержит только исходный user; добавим всё что произошло: # не вызывая реальные tools.
# - assistant с его blocks # api_messages к концу содержит: [...history_before_user, user(text), ...turns]
# - если были tool results, они тоже должны быть (но только между assistant турами) # где history уже включает новый user. Нам надо добавить всё после user msg.
# Для простоты сохраним финальный текст как одну запись — tool rounds не сохраняем в history, initial_user_idx = len(history) - 1 # позиция текущего user msg в api_messages
# иначе history в JSON будет пухнуть и не влезет в кеш. new_turns = api_messages[initial_user_idx + 1:]
history.append({"role": "assistant", "content": final_text}) for turn in new_turns:
history.append({
"role": turn["role"],
"content": _strip_cache_control(turn["content"]),
})
save_history(agent_id, history) save_history(agent_id, history)
result = clean_for_speech(strip_fillers(final_text)) result = clean_for_speech(strip_fillers(final_text))