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:
@@ -49,11 +49,20 @@ COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой а
|
||||
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
|
||||
- Если не знаешь — скажи коротко, не оправдывайся.
|
||||
|
||||
Инструменты:
|
||||
У тебя есть tools для погоды, транспорта (трамваи на остановке Антонова-Овсеенко),
|
||||
календарных событий, заметок, и запуска таймера на дашборде. Используй их без просьбы
|
||||
разрешения — если пользователь спрашивает «какая погода», сразу вызывай get_weather,
|
||||
потом формулируй ответ. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Любое ДЕЙСТВИЕ (поставить/отменить/изменить таймер, что-то включить/выключить)
|
||||
делается ТОЛЬКО через вызов tool. Без tool действие не произошло.
|
||||
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}."""
|
||||
|
||||
@@ -65,9 +74,11 @@ LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой
|
||||
- Без эмодзи, списков, код-блоков — это голос.
|
||||
- Если не знаешь — скажи коротко.
|
||||
|
||||
Инструменты:
|
||||
У тебя есть tools — погода, трамваи, события, заметки, таймеры. Используй их
|
||||
без лишних вопросов, а результат формулируй человеческим языком.
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Действия (таймер, etc.) — только через вызов tool. Без tool действие не произошло.
|
||||
2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
|
||||
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
|
||||
4. Tool → результат → короткий ответ человеческим языком.
|
||||
|
||||
Сегодня {today}."""
|
||||
|
||||
@@ -126,11 +137,48 @@ def reset_history(agent_id: str):
|
||||
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]:
|
||||
"""
|
||||
Готовит messages array для Claude API с prompt caching.
|
||||
Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша),
|
||||
а всё что раньше — помечается cache_control на границе.
|
||||
всё что раньше — помечается cache_control на границе (на последнем блоке
|
||||
последнего «старого» сообщения).
|
||||
Content может быть строкой или списком блоков (tool_use/tool_result turn'ы).
|
||||
"""
|
||||
if len(history) <= CACHE_TAIL_UNCACHED:
|
||||
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
|
||||
messages = []
|
||||
for i, msg in enumerate(history):
|
||||
# Граница кеша — на последнем «старом» сообщении ставим cache_control.
|
||||
if i == cache_boundary - 1:
|
||||
messages.append({
|
||||
"role": msg["role"],
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": msg["content"],
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}],
|
||||
"content": _wrap_last_block_with_cache(msg["content"]),
|
||||
})
|
||||
else:
|
||||
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)
|
||||
return msg
|
||||
|
||||
# Сохраняем полный ассистентский turn (с tool-use/result) в историю.
|
||||
# Для следующего turn'а history содержит: user text, затем assistant (text + tool_use),
|
||||
# user (tool_result), assistant (final text). Всё в правильном порядке.
|
||||
# Сейчас history у нас содержит только исходный user; добавим всё что произошло:
|
||||
# - assistant с его blocks
|
||||
# - если были tool results, они тоже должны быть (но только между assistant турами)
|
||||
# Для простоты сохраним финальный текст как одну запись — tool rounds не сохраняем в history,
|
||||
# иначе history в JSON будет пухнуть и не влезет в кеш.
|
||||
history.append({"role": "assistant", "content": final_text})
|
||||
# Сохраняем полный ассистентский turn (включая tool_use / tool_result блоки).
|
||||
# Это критично чтобы Claude помнил что он реально делал инструментами —
|
||||
# иначе на следующем turn'e он может галлюцинировать действия («отменил таймер»)
|
||||
# не вызывая реальные tools.
|
||||
# api_messages к концу содержит: [...history_before_user, user(text), ...turns]
|
||||
# где history уже включает новый user. Нам надо добавить всё после user msg.
|
||||
initial_user_idx = len(history) - 1 # позиция текущего user msg в api_messages
|
||||
new_turns = api_messages[initial_user_idx + 1:]
|
||||
for turn in new_turns:
|
||||
history.append({
|
||||
"role": turn["role"],
|
||||
"content": _strip_cache_control(turn["content"]),
|
||||
})
|
||||
save_history(agent_id, history)
|
||||
|
||||
result = clean_for_speech(strip_fillers(final_text))
|
||||
|
||||
Reference in New Issue
Block a user