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
7 changes: 1 addition & 6 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1158,12 +1158,7 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
# These must not be converted to hyphens. See issue #12295.
if _is_bedrock_model_id(model):
return model
# Only convert dots to hyphens for Anthropic/Claude models.
# Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots
# as part of their canonical names. See issue #17171.
_lower = model.lower()
if _lower.startswith("claude-") or _lower.startswith("anthropic/"):
model = model.replace(".", "-")
model = model.replace(".", "-")
return model


Expand Down
40 changes: 25 additions & 15 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1780,11 +1780,19 @@ async def _keep_typing(
if stop_event is None:
await asyncio.sleep(interval)
continue
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
continue
return
loop = asyncio.get_running_loop()
deadline = loop.time() + interval
while not stop_event.is_set():
remaining = deadline - loop.time()
if remaining <= 0:
break
# Poll instead of wait_for(stop_event.wait()). Cancelling
# wait_for while it owns the inner Event.wait task can leave
# shutdown paths stuck awaiting the typing task on Python
# 3.11/pytest-asyncio; sleep cancellation is immediate.
await asyncio.sleep(min(0.25, remaining))
if stop_event.is_set():
return
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
finally:
Expand Down Expand Up @@ -2382,6 +2390,16 @@ def _record_delivery(result):
**_keep_typing_kwargs,
)
)

async def _stop_typing_task() -> None:
typing_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
# Cancellation cleanup must not block adapter shutdown. The
# typing task is already cancelled; if the parent task is also
# cancelling, let this message-processing task unwind now.
pass

try:
await self._run_processing_hook("on_processing_start", event)
Expand Down Expand Up @@ -2604,11 +2622,7 @@ def _record_delivery(result):
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await _stop_typing_task()
# Process pending message in new background task
await self._process_message_background(pending_event, session_key)
return # Already cleaned up
Expand Down Expand Up @@ -2656,11 +2670,7 @@ def _record_delivery(result):
except Exception:
pass
# Stop typing indicator
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
Expand Down
22 changes: 19 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10056,10 +10056,26 @@ def _run_still_current() -> bool:

# Tool progress mode — resolved per-platform with env var fallback
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
_env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE")
_display_cfg = display_config if isinstance(display_config, dict) else {}
_platforms_cfg = _display_cfg.get("platforms") or {}
_platform_cfg = _platforms_cfg.get(platform_key) or {}
_legacy_tp_overrides = _display_cfg.get("tool_progress_overrides") or {}
_tool_progress_configured = (
"tool_progress" in _display_cfg
or (
isinstance(_platform_cfg, dict)
and "tool_progress" in _platform_cfg
)
or (
isinstance(_legacy_tp_overrides, dict)
and platform_key in _legacy_tp_overrides
)
)
progress_mode = (
_resolved_tp
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all"
_env_tp
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
Expand Down
1 change: 1 addition & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def _hash_chat_id(value: str) -> str:
)
from .whatsapp_identity import (
canonical_whatsapp_identifier,
normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers
)
from utils import atomic_replace

Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5363,7 +5363,7 @@ def _warn_stale_dashboard_processes() -> None:
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
for line in result.stdout.split("\n"):
for line in getattr(result, "stdout", "").split("\n"):
stripped = line.strip()
if not stripped or "grep" in stripped:
continue
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ async def auth_middleware(request: Request, call_next):
"dashboard": "display",
"code_execution": "agent",
"prompt_caching": "agent",
"telegram": "auxiliary",
}

# Display order for tabs — unlisted categories sort alphabetically after these.
Expand Down
1 change: 1 addition & 0 deletions tests/hermes_cli/test_auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,7 @@ def test_credential_sources_registry_has_expected_steps():
"~/.hermes/.anthropic_oauth.json",
"auth.json providers.nous",
"auth.json providers.openai-codex + ~/.codex/auth.json",
"auth.json providers.minimax-oauth",
"~/.qwen/oauth_creds.json",
"Custom provider config.yaml api_key field",
}
Expand Down
3 changes: 3 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2276,8 +2276,10 @@ def __init__(self):

# Make _build block until we release it — simulates slow agent init
release_build = threading.Event()
build_entered = threading.Event()

def _slow_make_agent(sid, key):
build_entered.set()
release_build.wait(timeout=3.0)
return _FakeAgent()

Expand Down Expand Up @@ -2315,6 +2317,7 @@ def _slow_make_agent(sid, key):
)
assert resp.get("result"), f"got error: {resp.get('error')}"
sid = resp["result"]["session_id"]
assert build_entered.wait(timeout=1.0), "deferred build did not start"

# Build thread is blocked in _slow_make_agent. Close the session
# NOW — this pops _sessions[sid] before _build can install the
Expand Down
13 changes: 11 additions & 2 deletions tools/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,11 +915,12 @@ async def _refresh_tools_task(self):
except Exception:
logger.exception("MCP server '%s': dynamic tool refresh failed", self.name)

def _schedule_tools_refresh(self) -> None:
def _schedule_tools_refresh(self) -> asyncio.Task:
"""Schedule a background tool refresh and keep it strongly referenced."""
task = asyncio.create_task(self._refresh_tools_task())
self._pending_refresh_tasks.add(task)
task.add_done_callback(self._pending_refresh_tasks.discard)
return task

def _make_message_handler(self):
"""Build a ``message_handler`` callback for ``ClientSession``.
Expand Down Expand Up @@ -950,6 +951,10 @@ async def _handler(message):
# a separate task and let the handler return
# promptly.
self._schedule_tools_refresh()
# Yield one loop tick so tests and short-lived
# notification contexts can observe the scheduled
# refresh without awaiting the full server RPC.
await asyncio.sleep(0)
case PromptListChangedNotification():
logger.debug("MCP server '%s': prompts/list_changed (ignored)", self.name)
case ResourceListChangedNotification():
Expand Down Expand Up @@ -2005,8 +2010,12 @@ def _handler(args: dict, **kwargs) -> str:
}, ensure_ascii=False)

async def _call():
async with server._rpc_lock:
rpc_lock = getattr(server, "_rpc_lock", None)
if rpc_lock is None:
result = await server.session.call_tool(tool_name, arguments=args)
else:
async with rpc_lock:
result = await server.session.call_tool(tool_name, arguments=args)
# MCP CallToolResult has .content (list of content blocks) and .isError
if result.isError:
error_text = ""
Expand Down
22 changes: 17 additions & 5 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1672,6 +1672,18 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
return text or "What do you see in this image?"


def _messages_as_conversation(db, session_id: str, *, include_ancestors: bool = False):
if include_ancestors:
try:
return db.get_messages_as_conversation(
session_id, include_ancestors=True
)
except TypeError as exc:
if "include_ancestors" not in str(exc):
raise
return db.get_messages_as_conversation(session_id)


def _history_to_messages(history: list[dict]) -> list[dict]:
messages = []
tool_call_args = {}
Expand Down Expand Up @@ -1880,9 +1892,9 @@ def _(rid, params: dict) -> dict:
_enable_gateway_prompts()
try:
db.reopen_session(target)
history = db.get_messages_as_conversation(target)
display_history = db.get_messages_as_conversation(
target, include_ancestors=True
history = _messages_as_conversation(db, target)
display_history = _messages_as_conversation(
db, target, include_ancestors=True
)
messages = _history_to_messages(display_history)
tokens = _set_session_context(target)
Expand Down Expand Up @@ -1986,8 +1998,8 @@ def _(rid, params: dict) -> dict:
db = _get_db()
if db is not None and session.get("session_key"):
try:
history = db.get_messages_as_conversation(
session["session_key"], include_ancestors=True
history = _messages_as_conversation(
db, session["session_key"], include_ancestors=True
)
except Exception:
pass
Expand Down
Loading