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:
|
||||||
У тебя есть 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user