Skip to content
Closed
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
46 changes: 33 additions & 13 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
return True, None


def _peek_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
"""Return a pool entry without refreshing or rotating credentials."""
try:
pool = load_pool(provider)
except Exception as exc:
logger.debug("Auxiliary client: could not load pool for %s: %s", provider, exc)
return False, None
if not pool or not pool.has_credentials():
return False, None
try:
return True, pool.peek()
except Exception as exc:
logger.debug("Auxiliary client: could not peek pool entry for %s: %s", provider, exc)
return True, None


def _pool_runtime_api_key(entry: Any) -> str:
if entry is None:
return ""
Expand All @@ -197,6 +213,15 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
return str(url or "").strip().rstrip("/")


def _jwt_is_expired(token: str) -> bool:
try:
from hermes_cli.auth import _codex_access_token_is_expiring

return bool(_codex_access_token_is_expiring(token, 0))
except Exception:
return False


# ── Codex Responses → chat.completions adapter ─────────────────────────────
# All auxiliary consumers call client.chat.completions.create(**kwargs) and
# read response.choices[0].message.content. This adapter translates those
Expand Down Expand Up @@ -637,11 +662,14 @@ def _read_codex_access_token() -> Optional[str]:
fallback-to-Codex working when the pool state is stale but the stored OAuth
token is still valid.
"""
pool_present, entry = _select_pool_entry("openai-codex")
pool_present, entry = _peek_pool_entry("openai-codex")
if pool_present:
token = _pool_runtime_api_key(entry)
if token:
return token
if _jwt_is_expired(token):
logger.debug("Codex pool access token expired, skipping")
else:
return token

try:
from hermes_cli.auth import _read_codex_tokens
Expand All @@ -653,17 +681,9 @@ def _read_codex_access_token() -> Optional[str]:

# Check JWT expiry — expired tokens block the auto chain and
# prevent fallback to working providers (e.g. Anthropic).
try:
import base64
payload = access_token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
exp = claims.get("exp", 0)
if exp and time.time() > exp:
logger.debug("Codex access token expired (exp=%s), skipping", exp)
return None
except Exception:
pass # Non-JWT token or decode error — use as-is
if _jwt_is_expired(access_token):
logger.debug("Codex access token expired, skipping")
return None

return access_token.strip()
except Exception as exc:
Expand Down
30 changes: 30 additions & 0 deletions agent/builtin_memory_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""No-op built-in memory provider shim.

The built-in memory store is implemented by ``tools.memory_tool.MemoryStore``.
This provider exists so ``MemoryManager`` callers can represent that built-in
slot explicitly alongside one external memory plugin.
"""

from __future__ import annotations

from typing import Any, Dict, List

from agent.memory_provider import MemoryProvider


class BuiltinMemoryProvider(MemoryProvider):
"""Compatibility provider for the always-present built-in memory slot."""

@property
def name(self) -> str:
return "builtin"

def is_available(self) -> bool:
return True

def initialize(self, session_id: str, **kwargs: Any) -> None:
self.session_id = session_id
self.init_kwargs = dict(kwargs)

def get_tool_schemas(self) -> List[Dict[str, Any]]:
return []
4 changes: 3 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6364,7 +6364,9 @@ def _voice_stop_and_transcribe(self):

if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
self._attached_images.clear()
attached_images = getattr(self, "_attached_images", None)
if attached_images is not None:
attached_images.clear()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._pending_input.put(transcript)
Expand Down
40 changes: 24 additions & 16 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import asyncio
import inspect
import json
import logging
import os
Expand All @@ -16,6 +17,13 @@

logger = logging.getLogger(__name__)


async def _maybe_await(result: Any) -> Any:
"""Await PTB calls in production while tolerating simple test doubles."""
if inspect.isawaitable(result):
return await result
return result

try:
from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Expand Down Expand Up @@ -232,16 +240,16 @@ async def _handle_polling_network_error(self, error: Exception) -> None:

try:
if self._app and self._app.updater and self._app.updater.running:
await self._app.updater.stop()
await _maybe_await(self._app.updater.stop())
except Exception:
pass

try:
await self._app.updater.start_polling(
await _maybe_await(self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
)
))
logger.info(
"[%s] Telegram polling resumed after network error (attempt %d)",
self.name, attempt,
Expand Down Expand Up @@ -279,16 +287,16 @@ async def _handle_polling_conflict(self, error: Exception) -> None:
)
try:
if self._app and self._app.updater and self._app.updater.running:
await self._app.updater.stop()
await _maybe_await(self._app.updater.stop())
except Exception:
pass
await asyncio.sleep(RETRY_DELAY)
try:
await self._app.updater.start_polling(
await _maybe_await(self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
error_callback=self._polling_error_callback_ref,
)
))
logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count)
self._polling_conflict_count = 0 # reset on success
return
Expand All @@ -309,7 +317,7 @@ async def _handle_polling_conflict(self, error: Exception) -> None:
self._set_fatal_error("telegram_polling_conflict", message, retryable=False)
try:
if self._app and self._app.updater:
await self._app.updater.stop()
await _maybe_await(self._app.updater.stop())
except Exception as stop_error:
logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True)
await self._notify_fatal_error()
Expand Down Expand Up @@ -622,7 +630,7 @@ def _env_float(name: str, default: float) -> float:
_max_connect = 3
for _attempt in range(_max_connect):
try:
await self._app.initialize()
await _maybe_await(self._app.initialize())
break
except (NetworkError, TimedOut, OSError) as init_err:
if _attempt < _max_connect - 1:
Expand All @@ -634,7 +642,7 @@ def _env_float(name: str, default: float) -> float:
await asyncio.sleep(wait)
else:
raise
await self._app.start()
await _maybe_await(self._app.start())

# Decide between webhook and polling mode
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
Expand All @@ -649,15 +657,15 @@ def _env_float(name: str, default: float) -> float:
from urllib.parse import urlparse
webhook_path = urlparse(webhook_url).path or "/telegram"

await self._app.updater.start_webhook(
await _maybe_await(self._app.updater.start_webhook(
listen="0.0.0.0",
port=webhook_port,
url_path=webhook_path,
webhook_url=webhook_url,
secret_token=webhook_secret,
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
)
))
self._webhook_mode = True
logger.info(
"[%s] Webhook server listening on 0.0.0.0:%d%s",
Expand All @@ -669,7 +677,7 @@ def _env_float(name: str, default: float) -> float:
# previous webhook registration and silently stop receiving updates.
delete_webhook = getattr(self._bot, "delete_webhook", None)
if callable(delete_webhook):
await delete_webhook(drop_pending_updates=False)
await _maybe_await(delete_webhook(drop_pending_updates=False))

loop = asyncio.get_running_loop()

Expand All @@ -687,11 +695,11 @@ def _polling_error_callback(error: Exception) -> None:
# Store reference for retry use in _handle_polling_conflict
self._polling_error_callback_ref = _polling_error_callback

await self._app.updater.start_polling(
await _maybe_await(self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
error_callback=_polling_error_callback,
)
))

# Register bot commands so Telegram shows a hint menu when users type /
# List is derived from the central COMMAND_REGISTRY — adding a new
Expand All @@ -703,9 +711,9 @@ def _polling_error_callback(error: Exception) -> None:
# payload size limit. Skill descriptions are truncated to 40
# chars in telegram_menu_commands() to fit 100 commands safely.
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
await self._bot.set_my_commands([
await _maybe_await(self._bot.set_my_commands([
BotCommand(name, desc) for name, desc in menu_commands
])
]))
if hidden_count:
logger.info(
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
Expand Down
4 changes: 2 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6481,7 +6481,7 @@ def _apply_session_model_override(
subsequent messages. Fields with ``None`` values are skipped so
partial overrides don't clobber valid config defaults.
"""
override = self._session_model_overrides.get(session_key)
override = getattr(self, "_session_model_overrides", {}).get(session_key)
if not override:
return model, runtime_kwargs
model = override.get("model", model)
Expand All @@ -6493,7 +6493,7 @@ def _apply_session_model_override(

def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool:
"""Return True if *agent_model* matches an active /model session override."""
override = self._session_model_overrides.get(session_key)
override = getattr(self, "_session_model_overrides", {}).get(session_key)
return override is not None and override.get("model") == agent_model

def _evict_cached_agent(self, session_key: str) -> None:
Expand Down
Loading
Loading