Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2426750
fix(agent): respect ephemeral_system_prompt over stored DB prompt (#5…
isheng-eqi Jul 6, 2026
31e1b9c
chore: add ishengeqi@163.com to AUTHOR_MAP
isheng-eqi Jul 6, 2026
7107bb7
Merge branch 'main' of https://github.com/NousResearch/hermes-agent i…
isheng-eqi Jul 6, 2026
f159657
chore: trigger CI re-run
isheng-eqi Jul 7, 2026
9212c4f
fix(copilot): guard _is_copilot_url call with getattr to prevent Attr…
isheng-eqi Jul 7, 2026
9bb0a4f
fix(tui): poll kanban_notify_subs in notification poller for task eve…
isheng-eqi Jul 7, 2026
576c7db
fix(kanban): sync block schema with goal_mode enforcement; coerce Non…
isheng-eqi Jul 7, 2026
f2ac345
fix(kanban): block task on missing skills instead of crash-loop; surf…
isheng-eqi Jul 7, 2026
ae576c4
chore: trigger CI re-run (flake timeout)
isheng-eqi Jul 7, 2026
b0e4260
fix(aux): use full 600s unhealthy TTL for permanently unavailable pro…
isheng-eqi Jul 7, 2026
b4b1e3b
fix(cron): add whatsapp_cloud to _KNOWN_DELIVERY_PLATFORMS
isheng-eqi Jul 7, 2026
874d643
fix(file): use Python line counter instead of wc -l for accurate tota…
isheng-eqi Jul 7, 2026
e447974
fix(file): add input validation guards for write_file, patch, and sea…
isheng-eqi Jul 7, 2026
022466a
fix(process): use None sentinel for offset default instead of 0
isheng-eqi Jul 7, 2026
4bb30dc
fix: reject non-positive timeout and handle read_file offset past EOF
isheng-eqi Jul 7, 2026
e470fbf
fix: add encoding=utf-8 to open() for Windows footguns compliance
isheng-eqi Jul 7, 2026
16c5a61
test: update aux client test to match new default TTL behavior
isheng-eqi Jul 7, 2026
7edc76a
fix: prevent /stop signal loss and empty provider credential corruption
isheng-eqi Jul 7, 2026
1f48ffb
test: set pool.provider= on mocks to avoid MagicMock truthy guard tri…
isheng-eqi Jul 7, 2026
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
9 changes: 8 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,14 @@ def recover_with_credential_pool(
# that seeded the pool.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
if current_provider and pool_provider and current_provider != pool_provider:
# Guard: skip credential pool recovery when the pool is scoped to a
# different provider than the agent. Only guard when the pool has a
# known provider — an empty pool provider means "unscoped" (applies to
# any provider). An empty agent provider is treated as a mismatch
# because swapping the pool's credentials would set base_url/api_key
# without fixing the empty provider field, leaving the agent in a
# corrupted state (provider="" model="").
if pool_provider and current_provider != pool_provider:
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
Expand Down
10 changes: 8 additions & 2 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1803,7 +1803,11 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op

or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
if not or_key:
_mark_provider_unhealthy("openrouter", ttl=60)
# No key configured at all — permanently unavailable, not a
# transient payment error. Use the full unhealthy TTL (600s)
# so we don't retry every 60s and spam errors.log with WARNINGs
# for a provider the user never configured (#59974).
_mark_provider_unhealthy("openrouter")
return None, None
logger.debug("Auxiliary client: OpenRouter")
return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL,
Expand Down Expand Up @@ -1847,7 +1851,9 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
"Auxiliary Nous client unavailable: no Nous authentication found "
"(run: hermes auth)."
)
_mark_provider_unhealthy("nous", ttl=60)
# Permanently unavailable — use full TTL (600s) to avoid log spam
# from retrying every 60s for a provider the user never configured.
_mark_provider_unhealthy("nous")
return None, None
if runtime is None and nous:
logger.debug(
Expand Down
7 changes: 7 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2900,6 +2900,13 @@ def _call():
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
# Worker thread exited before the main thread's poll loop could check
# the interrupt flag. If the worker returned early due to an interrupt
# (e.g. _call_anthropic() detected _interrupt_requested and returned
# None), the InterruptedError above was never raised. Re-check the
# flag here so /stop is not silently swallowed. (#59999 area)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
if result["error"] is not None:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already delivered to
Expand Down
11 changes: 10 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,15 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)

if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# The user set an explicit personality via /personality or the
# caller passed an ephemeral override. Use it even when a stored
# prompt would otherwise match — the user's explicit intent wins
# over prefix-cache reuse. Caching will miss for this turn, but
# that is the expected trade-off for a deliberate personality switch
# (#58774).
if getattr(agent, "ephemeral_system_prompt", None):
agent._cached_system_prompt = agent.ephemeral_system_prompt
return
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
Expand Down Expand Up @@ -1161,7 +1170,7 @@ def run_conversation(
# Copilot x-initiator: the first API call of a user turn is
# marked "user" so Copilot bills a premium request; tool-loop
# follow-ups keep the default "agent" header (#3040).
if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
if getattr(agent, "_is_user_initiated_turn", False) and getattr(agent, "_is_copilot_url", lambda: False)():
_xh = dict(api_kwargs.get("extra_headers") or {})
_xh["x-initiator"] = "user"
api_kwargs["extra_headers"] = _xh
Expand Down
43 changes: 43 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15844,7 +15844,50 @@ def main(
missing_display,
", ".join(loaded_skills),
)
# Surface the skip on the kanban board so the card author
# knows the worker ran without their intended skill context,
# rather than only having this in the worker log file (#59764).
kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "")
if kanban_task_id:
try:
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
try:
_kb.add_comment(
conn, kanban_task_id,
f"[worker] Skipped unknown skills: {missing_display}. "
f"Running with: {', '.join(loaded_skills)}.",
)
finally:
conn.close()
except Exception:
pass
else:
# When running as a kanban worker (HERMES_KANBAN_TASK set),
# all skills missing is a card-configuration error, not a
# transient failure. Block the task with a structured reason
# instead of raising ValueError — otherwise the dispatcher
# retries and the worker crashes on every spawn (#59764).
kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "")
if kanban_task_id:
reason = (
f"Worker missing all required skills: {missing_display}. "
f"Install the skills on profile or update the card."
)
try:
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
try:
_kb.block_task(
conn, kanban_task_id,
reason=reason,
kind="capability",
)
finally:
conn.close()
except Exception:
pass
sys.exit(0)
raise ValueError(f"Unknown skill(s): {missing_display}")
if skills_prompt:
cli.system_prompt = "\n\n".join(
Expand Down
2 changes: 1 addition & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
# Valid delivery platforms — used to validate user-supplied platform names
# in cron delivery targets, preventing env var enumeration via crafted names.
_KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram", "discord", "slack", "whatsapp", "signal",
"telegram", "discord", "slack", "whatsapp", "whatsapp_cloud", "signal",
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
"qqbot", "yuanbao",
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"huanshan5195@users.noreply.github.com": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level)
"infinitycrew39@gmail.com": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints)
"jonathan.kovacs999@gmail.com": "CocaKova", # PR #57692 salvage (cron: run jobs under the profile secret scope so get_secret does not fail-close with UnscopedSecretError under profile isolation)
"ishengeqi@163.com": "isheng-eqi",
"hermes.wanderer@yahoo.com": "trismegistus-wanderer", # PR #31856 salvage (gateway: defer idle-TTL agent-cache eviction until the session store says the session actually expired, so the expiry watcher can still fire MemoryProvider.on_session_end with the live transcript; #11205)
"louis@letsfive.io": "Mibayy", # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress)
"louis@letsfive.io": "Mibayy", # PR #3176 salvage (api-server: per-client model routing via model_routes)
Expand Down
5 changes: 3 additions & 2 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,7 @@ def test_try_openrouter_pool_exhausted_falls_back_to_env(self, monkeypatch):
assert mock_openai.call_args.kwargs["base_url"] == OPENROUTER_BASE_URL

def test_try_openrouter_pool_exhausted_no_env_marks_unhealthy(self, monkeypatch):
"""Pool exhausted AND no env var final failure marks provider unhealthy."""
"""Pool exhausted AND no env var -> final failure marks provider unhealthy."""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, None)), \
patch("agent.auxiliary_client._mark_provider_unhealthy") as mock_mark, \
Expand All @@ -1128,7 +1128,8 @@ def test_try_openrouter_pool_exhausted_no_env_marks_unhealthy(self, monkeypatch)
assert client is None
assert model is None
mock_openai.assert_not_called()
mock_mark.assert_called_once_with("openrouter", ttl=60)
# Permanently unavailable (no key) -> default TTL, not ttl=60
mock_mark.assert_called_once_with("openrouter")

class TestGetTextAuxiliaryClient:
"""Test the full resolution chain for get_text_auxiliary_client."""
Expand Down
3 changes: 3 additions & 0 deletions tests/agent/test_credential_pool_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ def _make_agent_with_pool(self, pool_entries=3):

pool = MagicMock()
pool.has_credentials.return_value = True
# Must be set explicitly — MagicMock.provider returns a truthy
# child mock, which would trigger the provider-mismatch guard.
pool.provider = ""

# mark_exhausted_and_rotate returns next entry until exhausted
self._rotation_index = 0
Expand Down
47 changes: 47 additions & 0 deletions tests/agent/test_system_prompt_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"):
agent.platform = "cli"
agent._session_db = session_db
agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt)
# Explicitly None — MagicMock auto-creates attributes on access, so
# getattr(agent, "ephemeral_system_prompt", None) would return a Mock.
agent.ephemeral_system_prompt = None
return agent


Expand Down Expand Up @@ -261,5 +264,49 @@ def test_restored_prompt_is_byte_identical_to_stored(self):
assert agent._cached_system_prompt.encode("utf-8") == stored.encode("utf-8")


class TestEphemeralSystemPromptOverride:
"""When the caller sets an ephemeral system prompt (e.g. /personality),
it must win over the session-DB stored prompt (#58774)."""

def test_ephemeral_overrides_stored_prompt(self):
"""ephemeral_system_prompt takes precedence over a matching stored prompt."""
stored = "Stored prompt from session DB"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent.ephemeral_system_prompt = "Personality: pirate"

_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])

assert agent._cached_system_prompt == "Personality: pirate"
agent._build_system_prompt.assert_not_called()

def test_ephemeral_none_does_not_block_restore(self):
"""ephemeral_system_prompt=None (the default) should still restore from DB."""
stored = "Stored prompt from session DB"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent.ephemeral_system_prompt = None

_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])

assert agent._cached_system_prompt == stored
agent._build_system_prompt.assert_not_called()

def test_ephemeral_empty_string_does_not_block_restore(self):
"""ephemeral_system_prompt='' should still restore from DB (empty is falsy)."""
stored = "Stored prompt from session DB"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent.ephemeral_system_prompt = ""

_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])

assert agent._cached_system_prompt == stored
agent._build_system_prompt.assert_not_called()


if __name__ == "__main__":
pytest.main([__file__, "-v"])
3 changes: 3 additions & 0 deletions tests/run_agent/test_credential_pool_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def _make_pool(entries):
pool = MagicMock()
pool.entries = entries
pool.current.return_value = entries[0]
# Must be set explicitly — MagicMock.provider returns a truthy
# child mock, which would trigger the provider-mismatch guard.
pool.provider = ""
return pool


Expand Down
14 changes: 13 additions & 1 deletion tests/test_copilot_initiator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _make_agent(monkeypatch, base_url, api_mode="chat_completions"):

def _inject(agent, api_kwargs):
"""Mirror the injection block in agent/conversation_loop.py."""
if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
if getattr(agent, "_is_user_initiated_turn", False) and getattr(agent, "_is_copilot_url", lambda: False)():
_xh = dict(api_kwargs.get("extra_headers") or {})
_xh["x-initiator"] = "user"
api_kwargs["extra_headers"] = _xh
Expand Down Expand Up @@ -131,6 +131,18 @@ def test_non_copilot_flag_not_flipped(self, monkeypatch):
# Flag unchanged — non-Copilot path doesn't touch it
assert agent._is_user_initiated_turn is True

def test_missing_is_copilot_url_does_not_crash(self):
"""Guard against intermittent missing _is_copilot_url (#59845)."""
# Simulate a partially-initialized agent (module-reload / wrapper)
# that has _is_user_initiated_turn but not _is_copilot_url.
class PartialAgent:
_is_user_initiated_turn = True
agent = PartialAgent()
kwargs = _inject(agent, {})
assert "extra_headers" not in kwargs
# Flag unchanged — guard falls through cleanly
assert agent._is_user_initiated_turn is True


class TestHeaderValues:
"""copilot_default_headers(is_agent_turn=...) sets x-initiator correctly."""
Expand Down
18 changes: 11 additions & 7 deletions tests/tools/test_kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,22 +741,26 @@ def _make_goal_mode_worker_env(monkeypatch, tmp_path):
return goal_task_id


def test_block_goal_mode_rejects_missing_kind(monkeypatch, tmp_path):
"""A goal_mode worker calling kanban_block with no kind must not be able
to use it as an unguarded escape from the goal loop (Issue #38696,
sibling of the kanban_complete judge gate / Issue #38367)."""
def test_block_goal_mode_coerces_missing_kind_to_needs_input(monkeypatch, tmp_path):
"""A goal_mode worker calling kanban_block with no kind gets kind='needs_input'.

The tool schema marks ``kind`` as optional, so workers following the
schema may omit it. Before #59764 this was rejected for goal_mode tasks,
breaking workers that followed the documented contract. Now a missing
kind is coerced to 'needs_input' so the block succeeds (#59764)."""
from tools import kanban_tools as kt
from hermes_cli import kanban_db as kb

tid = _make_goal_mode_worker_env(monkeypatch, tmp_path)
out = kt._handle_block({"reason": "giving up"})
d = json.loads(out)
assert "error" in d
assert "goal_mode" in d["error"]
assert d.get("ok") is True, f"expected ok, got: {d}"

conn = kb.connect()
try:
assert kb.get_task(conn, tid).status == "running"
task = kb.get_task(conn, tid)
assert task.status == "blocked"
assert task.block_kind == "needs_input"
finally:
conn.close()

Expand Down
33 changes: 27 additions & 6 deletions tools/file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,12 +1115,22 @@ def read_file(self, path: str, offset: int = 1, limit: int = 500) -> ReadResult:
if offset == 1:
read_output, _ = _strip_bom(read_output)

# Get total line count
wc_cmd = f"wc -l < {self._escape_shell_arg(path)}"
wc_result = self._exec(wc_cmd)
wc_output = _strip_terminal_fence_leaks(wc_result.stdout)
# Get total line count. Use Python's universal newline reader
# instead of ``wc -l`` which counts newline characters, not lines.
# A file with N content lines and no trailing newline has N-1
# newlines → ``wc -l`` returns N-1 (off-by-one). A file with a
# trailing empty line has N+1 newlines → ``wc -l`` returns N+1.
# Python's ``sum(1 for _ in f)`` counts actual lines correctly
# regardless of trailing-newline convention (#59999).
py_cmd = (
"python -c \"import sys; "
"print(sum(1 for _ in open(sys.argv[1], encoding='utf-8', errors='replace')))\" "
+ self._escape_shell_arg(path)
)
py_result = self._exec(py_cmd)
py_output = _strip_terminal_fence_leaks(py_result.stdout)
try:
total_lines = int(wc_output.strip())
total_lines = int(py_output.strip())
except ValueError:
total_lines = 0

Expand All @@ -1129,7 +1139,18 @@ def read_file(self, path: str, offset: int = 1, limit: int = 500) -> ReadResult:
hint = None
if truncated:
hint = f"Use offset={end_line + 1} to continue reading (showing {offset}-{end_line} of {total_lines} lines)"


# When offset exceeds total lines, return a clear message instead
# of a misleading empty line with just the line-number prefix.
if offset > total_lines and not read_output.strip():
return ReadResult(
content="",
total_lines=total_lines,
file_size=file_size,
truncated=False,
hint=f"Offset {offset} exceeds file length ({total_lines} lines)",
)

return ReadResult(
content=self._add_line_numbers(read_output, offset),
total_lines=total_lines,
Expand Down
Loading
Loading