Skip to content
Merged
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
1 change: 0 additions & 1 deletion acp_adapter/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,6 @@ def build_tool_start(
)

# Generic fallback
import json
try:
args_text = json.dumps(arguments, indent=2, default=str)
except (TypeError, ValueError):
Expand Down
13 changes: 13 additions & 0 deletions agent/bedrock_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Ensure boto3/botocore are installed before any code in this module runs.
# Upstream removed boto3 from [all] extras (PRs #24220, #24515); lazy_deps
# handles on-demand installation so the Bedrock provider still works in the
# EKS deployment without baking boto3 into the base image.
# ---------------------------------------------------------------------------
try:
from tools.lazy_deps import ensure
ensure("provider.bedrock", prompt=False)
except Exception:
pass # lazy_deps unavailable or install failed — let downstream imports surface the real error


# ---------------------------------------------------------------------------
# Lazy boto3 import — only loaded when the Bedrock provider is actually used.
# This keeps startup fast for users who don't use Bedrock.
Expand Down
1 change: 1 addition & 0 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def _strip_provider_prefix(model: str) -> str:
"llama": 131072,
# Qwen — specific model families before the catch-all.
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
"qwen3.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter)
"qwen3-coder-plus": 1000000, # 1M context
"qwen3-coder": 262144, # 256K context
"qwen": 131072,
Expand Down
20 changes: 14 additions & 6 deletions agent/shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
DEFAULT_TIMEOUT_SECONDS = 60
MAX_TIMEOUT_SECONDS = 300
ALLOWLIST_FILENAME = "shell-hooks-allowlist.json"
_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook."

# (event, matcher, command) triples that have been wired to the plugin
# manager in the current process. Matcher is part of the key because
Expand Down Expand Up @@ -481,6 +482,17 @@ def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, default=str)


def _block_message(primary: Any, secondary: Any) -> str:
"""Return a validated string block message, falling back to the default.

Accepts two candidate fields (primary wins over secondary) so callers
can express field-priority differences between the two hook wire formats
without duplicating the type-check logic.
"""
raw = primary or secondary
return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE


def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
"""Translate stdout JSON into a Hermes wire-shape dict.

Expand Down Expand Up @@ -515,13 +527,9 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:

if event == "pre_tool_call":
if data.get("action") == "block":
message = data.get("message") or data.get("reason") or ""
if isinstance(message, str) and message:
return {"action": "block", "message": message}
return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))}
if data.get("decision") == "block":
message = data.get("reason") or data.get("message") or ""
if isinstance(message, str) and message:
return {"action": "block", "message": message}
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
return None

context = data.get("context")
Expand Down
11 changes: 9 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,7 @@ def _looks_like_slash_command(text: str) -> bool:

from agent.skill_commands import (
scan_skill_commands,
get_skill_commands,
build_skill_invocation_message,
build_preloaded_skills_prompt,
)
Expand Down Expand Up @@ -9656,12 +9657,18 @@ def _reload_skills(self) -> None:
prompt caching intact.
"""
try:
from agent.skill_commands import reload_skills
from agent.skill_commands import reload_skills, get_skill_commands

if not self._command_running:
print("🔄 Reloading skills...")

result = reload_skills()

# Sync cli.py's module-level _skill_commands so all consumers
# (help display, command dispatch, Tab-completion lambda) see the
# updated dict without needing to restart the session.
global _skill_commands
_skill_commands = get_skill_commands()
added = result.get("added", []) # [{"name", "description"}, ...]
removed = result.get("removed", []) # [{"name", "description"}, ...]
total = result.get("total", 0)
Expand Down Expand Up @@ -12667,7 +12674,7 @@ def get_prompt():


_completer = SlashCommandCompleter(
skill_commands_provider=lambda: _skill_commands,
skill_commands_provider=lambda: get_skill_commands(),
command_filter=cli_ref._command_available,
)
input_area = TextArea(
Expand Down
12 changes: 11 additions & 1 deletion gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1663,7 +1663,17 @@ async def send(
continue
raise
message_ids.append(str(msg.message_id))


# Re-trigger typing indicator after sending a message.
# Telegram clears the typing state when a new message is delivered,
# so without this the "...typing" bubble disappears mid-response
# (especially noticeable when the agent sends intermediate progress
# messages like "Checking:" before running tools).
try:
await self.send_typing(chat_id, metadata=metadata)
except Exception:
pass # Typing failures are non-fatal

return SendResult(
success=True,
message_id=message_ids[0] if message_ids else None,
Expand Down
3 changes: 2 additions & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ class CommandDef:
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", args_hint="[auto|codex_app_server]"),
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
cli_only=True),

Expand Down
12 changes: 11 additions & 1 deletion hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,10 +1073,20 @@ def run_doctor(args):
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if ssh_host:
ssh_user = os.getenv("TERMINAL_SSH_USER")
ssh_port = os.getenv("TERMINAL_SSH_PORT")
ssh_key = os.getenv("TERMINAL_SSH_KEY")
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
cmd += ["-p", ssh_port]
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [target, "echo ok"]
# Try to connect
try:
result = subprocess.run(
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
cmd,
capture_output=True,
text=True,
timeout=15
Expand Down
12 changes: 12 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,18 @@
"sp_ps@Mac-mini.lan": "phoenixshen", # PR #26768 (respect user-configured vision model)
"1594534+phoenixshen@users.noreply.github.com": "phoenixshen",
"147827411+AhmetArif0@users.noreply.github.com": "AhmetArif0", # PR #26635 (line proxy env vars)
# batch salvage (May 2026 LHF run, group 5)
"hari@Hariharans-MacBook-Air-8.local": "haran2001", # PR #27070 (i18n catalog test)
"hariharan15151@gmail.com": "haran2001", # PR #27068 (qwen3.6-plus 1M context)
"56040092+haran2001@users.noreply.github.com": "haran2001",
"1472110+ms-alan@users.noreply.github.com": "ms-alan", # PR #26443 (reload-skills tab completion)
"ganlinbupt@gmail.com": "godlin-gh", # PR #26118 (ACP polished tools)
"wesley.simplicio.ext@siemens-energy.com": "wesleysimplicio", # PR #25777 (xterm.js native selection)
"6108320+wesleysimplicio@users.noreply.github.com": "wesleysimplicio",
"carryzuo00@gmail.com": "Carry00", # PR #26851 (doctor SSH env vars)
"alaamohanad169-ship-it@users.noreply.github.com": "alaamohanad169-ship-it", # PR #26036 (telegram typing after send)
"vigo@hermes": "hawknewton", # PR #26294 (bedrock boto3 lazy_deps)
"211668+hawknewton@users.noreply.github.com": "hawknewton",
}


Expand Down
10 changes: 10 additions & 0 deletions tests/acp/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,16 @@ def test_build_tool_start_for_web_extract_is_compact(self):
assert result.content is None
assert result.raw_input is None

def test_build_tool_start_for_browser_navigate(self):
"""browser_navigate should emit a polished start event."""
args = {"url": "https://x.com"}
result = build_tool_start("tc-browser-start", "browser_navigate", args)
assert isinstance(result, ToolCallStart)
assert result.title == "navigate: https://x.com"
assert result.kind == "fetch"
assert result.content[0].content.text == '{\n "url": "https://x.com"\n}'
assert result.raw_input is None

def test_build_tool_start_for_search(self):
"""search_files should include pattern in content."""
args = {"pattern": "TODO", "target": "content"}
Expand Down
10 changes: 10 additions & 0 deletions tests/agent/test_model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,16 @@ def test_qwen3_coder_context_length(self, mock_fetch):
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-coder") == 262144

@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen3_6_plus_context_length(self, mock_fetch):
"""qwen3.6-plus has a 1M context window, not the generic 128K Qwen default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3.6-plus") == 1048576
# Provider-prefixed variants must resolve to the same explicit entry
# via the longest-substring fallback (no portal/OR cache available).
assert get_model_context_length("qwen/qwen3.6-plus") == 1048576
assert get_model_context_length("dashscope/qwen3.6-plus") == 1048576

@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen_generic_context_length(self, mock_fetch):
"""Generic qwen models still get the 128K default."""
Expand Down
24 changes: 24 additions & 0 deletions tests/agent/test_shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,30 @@ def test_pre_llm_call_block_ignored(self):
)
assert r is None

def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}

def test_block_decision_without_reason_uses_default(self):
"""Block is honored even when reason/message is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"decision": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}

def test_block_action_empty_message_uses_default(self):
"""Empty string message falls back to default, not empty string."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": ""}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}

def test_block_action_non_string_message_uses_default(self):
"""Non-string message (e.g. integer) falls back to default."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": 42}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}


# ── _serialize_payload ────────────────────────────────────────────────────

Expand Down
11 changes: 10 additions & 1 deletion tests/gateway/test_restart_drain.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,16 @@ async def test_restart_command_while_busy_requests_drain_without_interrupt(monke

result = await runner._handle_message(event)

assert result == t("gateway.draining", count=1)
expected = t("gateway.draining", count=1)
assert result == expected
# Guard against the silent-degradation regression in #22266: if the i18n
# catalog cannot be resolved (e.g. xdist workers losing the locales path)
# then ``t("gateway.draining", count=1)`` returns the bare key
# ``"gateway.draining"`` instead of the formatted English string, and both
# sides of the equality above would still match. Assert on the catalog
# output explicitly so a broken locale resolution fails loudly here.
assert expected != "gateway.draining"
assert "Draining" in expected and "1" in expected
running_agent.interrupt.assert_not_called()
runner.request_restart.assert_called_once_with(detached=True, via_service=False)

Expand Down
7 changes: 7 additions & 0 deletions tests/hermes_cli/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def test_alias_resolves_to_canonical(self):
assert resolve_command("gateway").name == "platforms"
assert resolve_command("set-home").name == "sethome"
assert resolve_command("reload_mcp").name == "reload-mcp"
assert resolve_command("codex_runtime").name == "codex-runtime"
assert resolve_command("tasks").name == "agents"

def test_topic_is_gateway_command(self):
Expand Down Expand Up @@ -251,6 +252,12 @@ def test_includes_builtin_commands_with_required_args(self):
assert "queue" in names
assert "steer" in names

def test_hyphenated_codex_runtime_is_exposed_as_underscore_command(self):
"""Telegram autocomplete exposes /codex-runtime as /codex_runtime."""
names = {name for name, _ in telegram_bot_commands()}
assert "codex_runtime" in names
assert "codex-runtime" not in names


class TestSlackSubcommandMap:
def test_returns_dict(self):
Expand Down
11 changes: 11 additions & 0 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,17 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
fontWeight: "400",
fontWeightBold: "700",
macOptionIsMeta: true,
// Hold Option (Alt on Linux/Windows) to force native text selection
// even when the inner Hermes TUI has enabled xterm mouse-events
// mode (CSI ?1000h family). Without this, click-and-drag in the
// chat canvas selects nothing and Cmd+C falls back to copying the
// entire visible buffer, which is rarely what the user wants.
// See #25720.
macOptionClickForcesSelection: true,
// Right-click selects the word under the pointer. xterm.js default
// is false; enabling it gives users a single-action selection
// path on top of the modifier-based bypass above.
rightClickSelectsWord: true,
// Single-scroll-system experiment:
// let the inner Hermes TUI own transcript history/scroll behavior.
// The outer browser xterm should act as a display/input bridge only.
Expand Down
Loading