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
7 changes: 6 additions & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1802,7 +1802,12 @@ def _process_job(job: dict) -> bool:
for job in parallel_jobs:
_ctx = contextvars.copy_context()
_futures.append(_tick_pool.submit(_ctx.run, _process_job, job))
_results.extend(f.result() for f in _futures)
for f in concurrent.futures.as_completed(_futures, timeout=600):
try:
_results.append(f.result())
except Exception as exc:
logger.error("Parallel cron job future failed: %s", exc)
_results.append(False)

# Best-effort sweep of MCP stdio subprocesses that survived their
# session teardown during this tick. Runs AFTER every job has
Expand Down
6 changes: 6 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12546,6 +12546,12 @@ def _thread_metadata_for_source(
and getattr(source, "chat_type", None) == "dm"
):
metadata["telegram_dm_topic_reply_fallback"] = True
# Telegram DM topic lanes need direct_messages_topic_id in metadata
# so synthetic/queued messages (goal continuations, status notices)
# route to the correct topic even when reply anchor is unavailable.
tid = str(thread_id)
if tid and tid not in {"", "1"}:
metadata["direct_messages_topic_id"] = tid
anchor = reply_to_message_id or getattr(source, "message_id", None)
if anchor is not None:
metadata["telegram_reply_to_message_id"] = str(anchor)
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2914,6 +2914,7 @@ def _normalize_custom_provider_entry(
"api_mode", "transport", "model", "default_model", "models",
"context_length", "rate_limit_delay",
"request_timeout_seconds", "stale_timeout_seconds",
"discover_models",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
Expand Down Expand Up @@ -3004,6 +3005,10 @@ def _normalize_custom_provider_entry(
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
normalized["rate_limit_delay"] = rate_limit_delay

discover_models = entry.get("discover_models")
if isinstance(discover_models, bool):
normalized["discover_models"] = discover_models

return normalized


Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import re
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -110,6 +111,7 @@
JUDGE_USER_PROMPT_TEMPLATE = (
"Goal:\n{goal}\n\n"
"Agent's most recent response:\n{response}\n\n"
"Current time: {current_time}\n\n"
"Is the goal satisfied?"
)

Expand All @@ -120,6 +122,7 @@
"Additional criteria the user added mid-loop (all must also be "
"satisfied for the goal to be DONE):\n{subgoals_block}\n\n"
"Agent's most recent response:\n{response}\n\n"
"Current time: {current_time}\n\n"
"Decision: For each numbered criterion above, find concrete "
"evidence in the agent's response that the criterion is "
"satisfied. Do not accept generic phrases like 'all requirements "
Expand Down Expand Up @@ -415,6 +418,7 @@ def judge_goal(

# Build the prompt — pick the with-subgoals variant when applicable.
clean_subgoals = [s.strip() for s in (subgoals or []) if s and s.strip()]
current_time = datetime.now(tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
if clean_subgoals:
subgoals_block = "\n".join(
f"- {i}. {text}" for i, text in enumerate(clean_subgoals, start=1)
Expand All @@ -423,11 +427,13 @@ def judge_goal(
goal=_truncate(goal, 2000),
subgoals_block=_truncate(subgoals_block, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
current_time=current_time,
)
else:
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
goal=_truncate(goal, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
current_time=current_time,
)

try:
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"}
VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
_IS_WINDOWS = sys.platform == "win32"

# A running task's claim is valid for 15 minutes; after that the next
# dispatcher tick reclaims it. Workers that outlive this window should call
Expand Down Expand Up @@ -4024,6 +4025,7 @@ def _default_spawn(
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
except FileNotFoundError:
log_f.close()
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,7 +1688,11 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
continue
# Live model discovery from custom provider endpoints (matches
# Section 3 behavior for user ``providers:`` entries).
if api_url and api_key:
# Also probes when no api_key is set (e.g. local llama.cpp /
# Ollama servers) — the /models endpoint often works without
# auth. The CLI's _model_flow_named_custom always probes, so
# the Telegram/Discord picker should do the same for parity.
if api_url:
try:
from hermes_cli.models import fetch_api_models

Expand Down
5 changes: 5 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10033,6 +10033,11 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
if _ephemeral_out is not None:
self._ephemeral_max_output_tokens = None

# Strip image parts for non-vision models that have provider profiles
# (e.g. DeepSeek, Kimi). The legacy path below already does this, but
# registered providers with profiles were bypassing the strip.
api_messages = self._prepare_messages_for_non_vision_model(api_messages)

return _ct.build_kwargs(
model=self.model,
messages=api_messages,
Expand Down
8 changes: 8 additions & 0 deletions scripts/check-windows-footguns.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,14 @@ def print_rules() -> None:


def main(argv: list[str]) -> int:
# Windows terminals default to cp1252, which can't encode the ✓/✗
# characters used in the output. Reconfigure streams to UTF-8 so the
# script works correctly on the very platform it is designed to help.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")

args = parse_args(argv)

if args.list:
Expand Down
13 changes: 13 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,19 @@
"hermesagent26@gmail.com": "hermesagent26", # PR #26438 (kimi model-name reasoning pad)
"276067471+hermesagent26@users.noreply.github.com": "hermesagent26",
"71590782+kriscolab@users.noreply.github.com": "kriscolab", # PR #26926 (deepseek default_aux_model)
# batch salvage (May 2026 LHF run, group 3)
"darvsum@users.noreply.github.com": "darvsum", # PR #26766 (preserve discover_models in normalize)
"peter@Peters-Mac-mini.local": "hueilau", # PR #26498 (strip image parts for non-vision)
"33933019+hueilau@users.noreply.github.com": "hueilau",
"32297275+Timur00Kh@users.noreply.github.com": "Timur00Kh", # PR #27114 (telegram DM topic for synthetic events)
"al.bellemare@gmail.com": "Grogger", # PR #27061 (windows console flash suppress)
"clement@nousresearch.com": "lemassykoi", # PR #27042 (model-switch probe keyless providers)
"16377344+lemassykoi@users.noreply.github.com": "lemassykoi",
"draplater@icloud.com": "draplater", # PR #26707 (goal judge current time)
"6349758+draplater@users.noreply.github.com": "draplater",
"pr7426@users.noreply.github.com": "pr7426", # PR #27048 (cron parallel job loss)
"rahulnilvan43@gmail.com": "therahul-yo", # PR #26215 (mock keychain in tests)
"kingsleyemeka117@gmail.com": "flamiinngo", # PR #27205 (UnicodeEncodeError footgun checker)
}


Expand Down
7 changes: 7 additions & 0 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self):


class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)

def test_reads_valid_credentials(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
Expand Down
2 changes: 2 additions & 0 deletions tools/code_execution_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,7 @@ def execute_code(
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)

# --- Poll loop: watch for exit, timeout, and interrupt ---
Expand Down Expand Up @@ -1568,6 +1569,7 @@ def _is_usable_python(python_path: str) -> bool:
"import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)"],
timeout=5,
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
Expand Down
1 change: 1 addition & 0 deletions tools/environments/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ def _run_bash(self, cmd_string: str, *, login: bool = False,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
cwd=_popen_cwd,
)
if not _IS_WINDOWS:
Expand Down
1 change: 1 addition & 0 deletions tools/process_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ def spawn_local(
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)

session.process = proc
Expand Down
Loading