From f530607503633811c2db8214b61ee979bf9d5f8e Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 23 Apr 2026 14:04:27 +0000 Subject: [PATCH] fix(llm_claude): store tool turns in history + stricter prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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% в логах). --- satellite/llm_claude.py | 95 ++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/satellite/llm_claude.py b/satellite/llm_claude.py index 20fd34c..1ca06f3 100644 --- a/satellite/llm_claude.py +++ b/satellite/llm_claude.py @@ -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))