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
12 changes: 5 additions & 7 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6422,14 +6422,12 @@ def _get_cached_client(
if cache_key not in _client_cache:
# Safety belt: if the cache has grown beyond the max, evict
# the oldest entries (FIFO — dict preserves insertion order).
# _release_cached_client_fds is loop-dead-gated: it only frees
# the raw sockets when the evicted client's bound loop is dead
# (no live loop can hold an in-flight request on them), so this
# is safe here AND reclaims fds that would otherwise leak in
# long-lived processes (gateway, kanban workers).
# Do not close an evicted client: another caller may still be
# using it for an in-flight request. The process shutdown path
# closes clients that remain cached; an evicted client is left
# to its caller's lifetime.
while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE:
evict_key, evict_entry = next(iter(_client_cache.items()))
_release_cached_client_fds(evict_entry[0], evict_entry[2])
evict_key = next(iter(_client_cache))
del _client_cache[evict_key]
Comment thread
exiao marked this conversation as resolved.
_client_cache[cache_key] = (client, default_model, bound_loop)
else:
Expand Down
29 changes: 15 additions & 14 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1487,13 +1487,14 @@ def _log_safe_path(path: str) -> str:
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
)

# Extensions accepted ONLY for explicit ``MEDIA:<path>`` tags (e.g. emitted by
# the ``send_file`` tool). These are a deliberate superset of
# MEDIA_DELIVERY_EXTS: when an agent explicitly tags a file it intends to send,
# we honor code/config/log files too. They are intentionally NOT added to
# MEDIA_DELIVERY_EXTS because the bare-path detector (extract_local_files)
# scans untagged prose, where auto-shipping a ``.py``/``.log`` the model merely
# mentioned would be a surprise (see test_extract_local_files.py rationale).
# Extensions historically accepted for explicit ``MEDIA:<path>`` tags. Retained
# for the producer-tool detector in gateway/run.py (``_TOOL_MEDIA_RE``), which
# scans tool output for known-producer paths. They are deliberately NOT folded
# into ``_MEDIA_TAG_EXT_ALTERNATION`` below: per the egress design (#36060), an
# explicit ``MEDIA:`` tag with a code/config/log extension must pass
# ``validate_media_delivery_path`` (exists on disk, safe root, not denylisted)
# via the validated pass rather than extract unconditionally — otherwise a
# prompt-injection ``MEDIA:/etc/anything.py`` would silently exfiltrate.
MEDIA_TAG_EXTRA_EXTS: Tuple[str, ...] = (
# Config / data
".toml", ".ini", ".cfg", ".conf",
Expand All @@ -1505,13 +1506,13 @@ def _log_safe_path(path: str) -> str:
# Logs
".log",
)
_MEDIA_TAG_EXT_ALTERNATION = "|".join(
sorted(
(e.lstrip(".") for e in (*MEDIA_DELIVERY_EXTS, *MEDIA_TAG_EXTRA_EXTS)),
key=len,
reverse=True,
)
)
# The unconditional ``MEDIA:`` cleanup/extract grammar covers ONLY the known
# deliverable media extensions (MEDIA_DELIVERY_EXTS). Unknown extensions —
# extension-less files AND the code/config/log set in MEDIA_TAG_EXTRA_EXTS
# route through the validated pass (MEDIA_EXTENSIONLESS_TAG_RE + validate_
# media_delivery_path), so they deliver when the file validates and stay
# visible in the text when it does not (#36060 universal egress).
_MEDIA_TAG_EXT_ALTERNATION = _MEDIA_EXT_ALTERNATION
Comment thread
exiao marked this conversation as resolved.

# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
Expand Down
166 changes: 0 additions & 166 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15408,172 +15408,6 @@ def run_sync():
except Exception:
pass

async def _handle_reasoning_command(self, event: MessageEvent) -> str:
"""Handle /reasoning command — manage reasoning effort and display toggle.

Usage:
/reasoning Show current effort level and display state
/reasoning <level> Set reasoning effort for this session only
/reasoning <level> --global Persist reasoning effort to config.yaml
/reasoning reset Clear this session's reasoning override
/reasoning show|on Show model reasoning in responses
/reasoning hide|off Hide model reasoning from responses
"""
import yaml
from hermes_constants import parse_reasoning_effort

raw_args = event.get_command_args().strip()
args, persist_global = self._parse_reasoning_command_args(raw_args)
config_path = _hermes_home / "config.yaml"
session_key = self._session_key_for_source(event.source)
self._show_reasoning = self._load_show_reasoning()
self._reasoning_config = self._resolve_session_reasoning_config(
source=event.source,
session_key=session_key,
)

def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
try:
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
keys = key_path.split(".")
current = user_config
for k in keys[:-1]:
if k not in current or not isinstance(current[k], dict):
current[k] = {}
current = current[k]
current[keys[-1]] = value
atomic_yaml_write(config_path, user_config)
return True
except Exception as e:
logger.error("Failed to save config key %s: %s", key_path, e)
return False

if not raw_args:
# Show current state
rc = self._reasoning_config
if rc is None:
level = t("gateway.reasoning.level_default")
elif rc.get("enabled") is False:
level = t("gateway.reasoning.level_disabled")
else:
level = rc.get("effort", "medium")
display_state = (
t("gateway.reasoning.display_on")
if self._show_reasoning
else t("gateway.reasoning.display_off")
)
has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {})
scope = (
t("gateway.reasoning.scope_session")
if has_session_override
else t("gateway.reasoning.scope_global")
)
return t(
"gateway.reasoning.status",
level=level,
scope=scope,
display=display_state,
)

# Display toggle (per-platform)
platform_key = _platform_config_key(event.source.platform)
if args in {"show", "on"}:
self._show_reasoning = True
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
return t("gateway.reasoning.display_set_on", platform=platform_key)

if args in {"hide", "off"}:
self._show_reasoning = False
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
return t("gateway.reasoning.display_set_off", platform=platform_key)

# Effort level change
effort = args.strip()
if effort == "reset":
if persist_global:
return t("gateway.reasoning.reset_global_unsupported")
self._set_session_reasoning_override(session_key, None)
self._reasoning_config = self._load_reasoning_config()
self._evict_cached_agent(session_key)
return t("gateway.reasoning.reset_done")
parsed = parse_reasoning_effort(effort)
if parsed is None:
return t(
"gateway.reasoning.unknown_arg",
arg=effort or raw_args.lower(),
)

self._reasoning_config = parsed
if persist_global:
if _save_config_key("agent.reasoning_effort", effort):
self._set_session_reasoning_override(session_key, None)
self._evict_cached_agent(session_key)
return t("gateway.reasoning.set_global", effort=effort)
self._set_session_reasoning_override(session_key, parsed)
self._evict_cached_agent(session_key)
return t("gateway.reasoning.set_global_save_failed", effort=effort)

self._set_session_reasoning_override(session_key, parsed)
self._evict_cached_agent(session_key)
return t("gateway.reasoning.set_session", effort=effort)

async def _handle_fast_command(self, event: MessageEvent) -> str:
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
import yaml
from hermes_cli.models import model_supports_fast_mode

args = event.get_command_args().strip().lower()
config_path = _hermes_home / "config.yaml"
self._service_tier = self._load_service_tier()

user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
if not model_supports_fast_mode(model):
return t("gateway.fast.not_supported")

def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
try:
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
keys = key_path.split(".")
current = user_config
for k in keys[:-1]:
if k not in current or not isinstance(current[k], dict):
current[k] = {}
current = current[k]
current[keys[-1]] = value
atomic_yaml_write(config_path, user_config)
return True
except Exception as e:
logger.error("Failed to save config key %s: %s", key_path, e)
return False

if not args or args == "status":
status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal")
return t("gateway.fast.status", mode=status)

if args in {"fast", "on"}:
self._service_tier = "priority"
saved_value = "fast"
label = t("gateway.fast.label_fast")
elif args in {"normal", "off"}:
self._service_tier = None
saved_value = "normal"
label = t("gateway.fast.label_normal")
else:
return t("gateway.fast.unknown_arg", arg=args)

if _save_config_key("agent.service_tier", saved_value):
return t("gateway.fast.saved", label=label)
return t("gateway.fast.session_only", label=label)

async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
from tools.approval import (
Expand Down
17 changes: 16 additions & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3246,7 +3246,22 @@ async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
)

user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
try:
model, _runtime_kwargs = self._resolve_session_agent_runtime(
source=event.source,
session_key=session_key,
user_config=user_config,
)
except RuntimeError:
# A slash command should retain its availability check when a test
# harness or incomplete setup has no resolvable provider. Real
# turns use the complete resolver above, including channel and
# runtime-provider model overrides.
self._rehydrate_session_model_override(session_key)
override = getattr(self, "_session_model_overrides", {}).get(session_key)
model = str((override or {}).get("model") or "")
if not model:
model = _resolve_gateway_model(user_config)
if not model_supports_fast_mode(model):
return t("gateway.fast.not_supported")

Expand Down
51 changes: 51 additions & 0 deletions tests/gateway/test_fast_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,57 @@ async def test_handle_fast_command_session_scoped_by_default(monkeypatch, tmp_pa
assert not (tmp_path / "config.yaml").exists()


@pytest.mark.asyncio
async def test_handle_fast_command_gates_on_session_model_override(monkeypatch, tmp_path):
"""/fast must validate the model that the session will actually run."""
runner = _make_runner()
event = _make_event("/fast fast")
session_key = runner._session_key_for_source(event.source)
runner._session_model_overrides = {
session_key: {
"model": "gpt-5.4",
"provider": "openrouter",
"api_key": "test-key",
}
}
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "unsupported-model")
import hermes_cli.models as models_mod

monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: model == "gpt-5.4")

response = await runner._handle_fast_command(event)

assert "FAST" in response
assert runner._service_tier == "priority"


@pytest.mark.asyncio
async def test_handle_fast_command_gates_on_channel_model_override(monkeypatch, tmp_path):
"""/fast must validate a channel model that overrides the global default."""
runner = _make_runner()
event = _make_event("/fast fast")
runner.config = SimpleNamespace(streaming=None)
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "unsupported-model")
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
monkeypatch.setattr(
gateway_run,
"_get_channel_override",
lambda *_args, **_kwargs: SimpleNamespace(model="gpt-5.4", provider=None),
)
import hermes_cli.models as models_mod

monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: model == "gpt-5.4")

response = await runner._handle_fast_command(event)

assert "FAST" in response
assert runner._service_tier == "priority"


@pytest.mark.asyncio
async def test_handle_fast_command_global_flag_persists_config(monkeypatch, tmp_path):
runner = _make_runner()
Expand Down
2 changes: 1 addition & 1 deletion tests/tools/test_kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1528,7 +1528,7 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):
monkeypatch.setattr(_P, "home", lambda: tmp_path)

from agent.prompt_builder import KANBAN_GUIDANCE
assert 1_500 < len(KANBAN_GUIDANCE) < 5_500, (
assert 1_500 < len(KANBAN_GUIDANCE) < 6_500, (
f"KANBAN_GUIDANCE is {len(KANBAN_GUIDANCE)} chars — too short (missing?) or too long"
)

Expand Down
20 changes: 18 additions & 2 deletions tests/tools/test_send_file_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,24 @@ class TestExtractMediaExpandedExtensions:
"mp4", "mov", "avi", "mkv", "webm",
"ogg", "opus", "mp3", "wav", "m4a",
])
def test_extract_media_matches_extension(self, ext):
content = f"Here is the file\nMEDIA:/tmp/test_file.{ext}"
def test_extract_media_matches_extension(self, ext, tmp_path, monkeypatch):
# Create the file in a safe delivery root: known media extensions
# extract unconditionally, but code/config/log extensions (.py, .log,
# .toml, …) only extract via the validated pass (file must exist under
# an allowed root), per the #36060 universal-egress design. A bare
# nonexistent path would correctly stay visible, so the fixture must be
# a real, deliverable file to exercise the match.
root = tmp_path / "output"
root.mkdir()
monkeypatch.setattr(
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
(str(root),),
)
monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False)
f = root / f"test_file.{ext}"
f.write_text("content", encoding="utf-8")

content = f"Here is the file\nMEDIA:{f}"
media, cleaned = BasePlatformAdapter.extract_media(content)
assert len(media) >= 1, f"extract_media() did not match .{ext}"
path = media[0][0]
Expand Down
Loading