Skip to content
Open
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
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
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
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion covers only OpenRouter. Please add direct coverage for both unavailable Nous branches as well; current main's no-usable-inference-JWT path still uses ttl=60 at agent/auxiliary_client.py:2145.

mock_mark.assert_called_once_with("openrouter")

class TestGetTextAuxiliaryClient:
"""Test the full resolution chain for get_text_auxiliary_client."""
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"])
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
35 changes: 19 additions & 16 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,22 +696,22 @@ def _handle_block(args: dict, **kw) -> str:
# kanban_block(reason="anything") to escape the loop instead.
# Restrict goal_mode tasks to the kinds that represent a genuine
# external blocker the worker cannot resolve itself; `capability`
# and `transient` (or an unset kind) route back through
# kanban_complete, which the judge now gates.
# and `transient` route back through kanban_complete, which the
# judge now gates. An omitted (None) kind defaults to "needs_input"
# since the tool schema marks kind as optional (#59764).
task = kb.get_task(conn, tid)
if (
task
and task.goal_mode
and kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS
):
conn.close()
return tool_error(
f"goal_mode tasks can only block with kind in "
f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). "
f"If the task is actually finished or cannot proceed for "
f"another reason, call kanban_complete instead — the "
f"completion judge will evaluate it."
)
if task and task.goal_mode:
if kind is None:
kind = "needs_input"
if kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS:
conn.close()
return tool_error(
f"goal_mode tasks can only block with kind in "
f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). "
f"If the task is actually finished or cannot proceed for "
f"another reason, call kanban_complete instead — the "
f"completion judge will evaluate it."
)
try:
ok = kb.block_task(
conn, tid,
Expand Down Expand Up @@ -1322,7 +1322,10 @@ def _board_schema_prop() -> dict[str, str]:
"description": (
"Why you're blocked. 'dependency' waits in todo and "
"resumes automatically; the others surface to a human. "
"Omit only if none apply."
"Omit only if none apply. "
"On goal_mode tasks only 'dependency' and 'needs_input' "
"are accepted — the completion judge gates all other exits "
"(omit to default to 'needs_input')."
),
},
"board": _board_schema_prop(),
Expand Down
Loading
Loading