diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bbc379adf25e8..e69c02792022c 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -885,6 +885,12 @@ def run_conversation( approx_request_tokens = estimate_request_tokens_rough( api_messages, tools=agent.tools or None ) + try: + agent.context_compressor.last_prompt_tokens = approx_request_tokens + agent.context_compressor.last_completion_tokens = 0 + agent.context_compressor.last_total_tokens = approx_request_tokens + except Exception: + pass _runtime_context_error = _ollama_context_limit_error( agent, approx_request_tokens diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 085ea1d20e079..e6d657d3aa33b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -4561,9 +4561,23 @@ async def _stop_typing_task() -> None: speech_text = self.prepare_tts_text(text_content) if not speech_text: raise ValueError("Empty text after markdown cleanup") - tts_result_str = await asyncio.to_thread( - text_to_speech_tool, text=speech_text + from gateway.session_context import clear_session_vars, set_session_vars + _tts_session_tokens = set_session_vars( + platform=_platform_name(getattr(event.source, "platform", self.platform)), + chat_id=str(getattr(event.source, "chat_id", "") or ""), + chat_name=str(getattr(event.source, "chat_name", "") or ""), + thread_id=str(getattr(event.source, "thread_id", "") or ""), + user_id=str(getattr(event.source, "user_id", "") or ""), + user_name=str(getattr(event.source, "user_name", "") or ""), + session_key=session_key, + message_id=str(getattr(event.source, "message_id", "") or getattr(event, "message_id", "") or ""), ) + try: + tts_result_str = await asyncio.to_thread( + text_to_speech_tool, text=speech_text + ) + finally: + clear_session_vars(_tts_session_tokens) tts_data = _json.loads(tts_result_str) _tts_path = tts_data.get("file_path") except Exception as tts_err: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 026ee7bc55cd4..f7f2ee2bca752 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -4103,6 +4103,11 @@ async def _handle_callback_query( ) return + # --- Medical reminder callbacks (med:event_id:item_id) --- + if data.startswith("med:"): + await self._handle_med_reminder_callback(query, data) + return + # --- Exec approval callbacks (ea:choice:id) --- if data.startswith("ea:"): parts = data.split(":", 2) @@ -4417,6 +4422,106 @@ async def _handle_callback_query( except Exception as exc: logger.error("Failed to write update response from callback: %s", exc) + async def _handle_med_reminder_callback(self, query, data: str) -> None: + """Resolve a medication-reminder inline button via profile script.""" + try: + from hermes_constants import get_hermes_home + + script_path = ( + get_hermes_home() + / "scripts" + / "medical-reminders" + / "nastya_med_reminder.py" + ) + if not script_path.exists(): + await query.answer(text="❌ Скрипт напоминаний не найден.") + logger.error("[%s] medical reminder script missing: %s", self.name, script_path) + return + + query_message = getattr(query, "message", None) + chat_id = str(getattr(query_message, "chat_id", "")) if query_message else "" + message_id = str(getattr(query_message, "message_id", "")) if query_message else "" + user_id = str(getattr(getattr(query, "from_user", None), "id", "")) + env = os.environ.copy() + env["HERMES_HOME"] = str(get_hermes_home()) + + proc = await asyncio.create_subprocess_exec( + sys.executable, + str(script_path), + "ack", + "--callback-data", + data, + "--chat-id", + chat_id, + "--message-id", + message_id, + "--user-id", + user_id, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(script_path.parent), + env=env, + ) + stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=30) + stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip() + stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip() + if proc.returncode != 0: + logger.error( + "[%s] medical reminder callback failed rc=%s stderr=%s", + self.name, + proc.returncode, + stderr_text, + ) + await query.answer(text="❌ Не удалось отметить. Попробуйте ещё раз.") + return + + try: + payload = json.loads(stdout_text or "{}") + except Exception: + logger.error("[%s] invalid medical reminder callback output: %r", self.name, stdout_text) + await query.answer(text="❌ Некорректный ответ напоминаний.") + return + + answer = str(payload.get("answer") or "Отмечено")[:200] + await query.answer(text=answer) + + edit_text = payload.get("edit_text") + if not edit_text or not query_message: + return + + reply_markup = None + raw_markup = payload.get("reply_markup") + if isinstance(raw_markup, dict): + rows = [] + for row in raw_markup.get("inline_keyboard", []) or []: + buttons = [] + for button in row or []: + if not isinstance(button, dict): + continue + text = str(button.get("text") or "✓") + callback_data = button.get("callback_data") + if callback_data: + buttons.append(InlineKeyboardButton(text, callback_data=str(callback_data))) + if buttons: + rows.append(buttons) + if rows: + reply_markup = InlineKeyboardMarkup(rows) + + try: + await query.edit_message_text( + text=str(edit_text), + parse_mode=ParseMode.HTML, + reply_markup=reply_markup, + **self._link_preview_kwargs(), + ) + except Exception as exc: + logger.debug("[%s] medical reminder edit skipped/failed: %s", self.name, exc) + except asyncio.TimeoutError: + await query.answer(text="❌ Напоминание не успело отметиться, попробуйте ещё раз.") + except Exception as exc: + logger.error("[%s] medical reminder callback exception: %s", self.name, exc, exc_info=True) + await query.answer(text="❌ Ошибка напоминания.") + # Maps `gt:` -> (script-name, extra-args, success-label, is_state). # Scripts live in ~/.hermes/scripts/gmail-triage/. `arg` from the callback # data is always passed as the first positional arg. diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 3a4f85a5e4145..c2534871f62e6 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -326,6 +326,13 @@ def test_single_media_tag(self): assert media[0][0] == "/path/to/audio.ogg" assert media[0][1] is False # no voice tag + def test_media_tag_supports_html_artifact(self): + content = "HTML-анимация:\nMEDIA:/root/taskflow-shareholder-demo/taskflow_agentic_work_animation.html" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [("/root/taskflow-shareholder-demo/taskflow_agentic_work_animation.html", False)] + assert "MEDIA:" not in cleaned + assert "HTML-анимация" in cleaned + def test_media_with_voice_directive(self): content = "[[audio_as_voice]]\nMEDIA:/path/to/voice.ogg" media, cleaned = BasePlatformAdapter.extract_media(content) diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index 016be97ea272b..6b136935ce76b 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -16,6 +16,7 @@ from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult from gateway.run import GatewayRunner from gateway.session import SessionSource, build_session_key +from gateway.session_context import clear_session_vars, get_session_env class _MediaRoutingAdapter(BasePlatformAdapter): @@ -261,3 +262,30 @@ async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_pa adapter.send_document.assert_not_awaited() adapter.send_voice.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_auto_tts_restores_telegram_session_context_after_agent_handler(tmp_path, monkeypatch): + """Auto-TTS runs after the agent handler returns, so it must set session context itself.""" + adapter = _MediaRoutingAdapter() + adapter._auto_tts_default = True + event = _event() + event.message_type = MessageType.VOICE + adapter._message_handler = AsyncMock(return_value="Голосовой ответ") + audio_file = tmp_path / "reply.ogg" + captured = {} + + def fake_tts_tool(*, text): + captured["platform"] = get_session_env("HERMES_SESSION_PLATFORM", "") + audio_file.write_bytes(b"opus") + return '{"success": true, "file_path": "%s"}' % str(audio_file) + + clear_session_vars([]) + monkeypatch.setattr("tools.tts_tool.check_tts_requirements", lambda: True) + monkeypatch.setattr("tools.tts_tool.text_to_speech_tool", fake_tts_tool) + adapter.play_tts = AsyncMock(return_value=SendResult(success=True, message_id="voice")) + + await adapter._process_message_background(event, build_session_key(event.source)) + + assert captured["platform"] == "telegram" + adapter.play_tts.assert_awaited_once() diff --git a/tests/run_agent/test_context_token_tracking.py b/tests/run_agent/test_context_token_tracking.py index 4f9dac0fa3a90..4065068d53929 100644 --- a/tests/run_agent/test_context_token_tracking.py +++ b/tests/run_agent/test_context_token_tracking.py @@ -126,3 +126,18 @@ def test_codex_no_cache_fields(monkeypatch): agent = _make_agent(monkeypatch, "codex_responses", "openai-codex", resp) agent.run_conversation("hi") assert agent.context_compressor.last_prompt_tokens == 3000 + + +def test_context_counter_falls_back_to_request_estimate_when_provider_omits_usage(monkeypatch): + resp = lambda: SimpleNamespace( + choices=[SimpleNamespace(index=0, message=SimpleNamespace( + role="assistant", content="ok", tool_calls=None, reasoning_content=None, + ), finish_reason="stop")], + usage=None, + model="test-model", + ) + agent = _make_agent(monkeypatch, "chat_completions", "openrouter", resp) + + agent.run_conversation("hi") + + assert agent.context_compressor.last_prompt_tokens > 0