Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not unconditionally overwrite compressor state with an estimate here. Current preflight preserves the -1 post-compression sentinel (agent/turn_context.py:401-405), and the TUI deliberately treats absent real occupancy as unknown; this assignment defeats both contracts.

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
Expand Down
18 changes: 16 additions & 2 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4103,6 +4103,11 @@ async def _handle_callback_query(
)
return

# --- Medical reminder callbacks (med:event_id:item_id) ---
if data.startswith("med:"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This dispatch reaches a subprocess-backed handler without checking _is_callback_user_authorized(). The existing gt: script callback authorizes the caller before executing anything; apply the same guard before accepting med: actions.

await self._handle_med_reminder_callback(query, data)
return

# --- Exec approval callbacks (ea:choice:id) ---
if data.startswith("ea:"):
parts = data.split(":", 2)
Expand Down Expand Up @@ -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:<verb>` -> (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.
Expand Down
7 changes: 7 additions & 0 deletions tests/gateway/test_platform_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions tests/gateway/test_tts_media_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
15 changes: 15 additions & 0 deletions tests/run_agent/test_context_token_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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