Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # cli.py # hermes_cli/main.py # run_agent.py # tests/hermes_cli/test_cmd_update.py # tools/mcp_tool.py # web/src/lib/gatewayClient.ts
This commit is contained in:
@@ -91,7 +91,7 @@ def main():
|
||||
if msg.get("method") == "workspace/didChangeWatchedFiles":
|
||||
continue
|
||||
|
||||
if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"):
|
||||
if msg.get("method") in {"textDocument/didOpen", "textDocument/didChange"}:
|
||||
params = msg.get("params") or {}
|
||||
td = params.get("textDocument") or {}
|
||||
uri = td.get("uri", "")
|
||||
|
||||
@@ -87,10 +87,10 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
|
||||
cmd = captured["cmd"]
|
||||
assert "pyright" in cmd
|
||||
# Should not blow up when extra_pkgs is omitted/None
|
||||
install_targets = [c for c in cmd if not c.startswith("-") and c not in (
|
||||
install_targets = [c for c in cmd if not c.startswith("-") and c not in {
|
||||
"install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent),
|
||||
"/usr/bin/npm",
|
||||
)]
|
||||
}]
|
||||
assert install_targets == ["pyright"]
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,13 @@ class TestBuildAnthropicClient:
|
||||
|
||||
|
||||
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)
|
||||
@@ -1651,7 +1658,7 @@ class TestThinkingBlockSignatureManagement:
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assistant = next(m for m in result if m["role"] == "assistant")
|
||||
for block in assistant["content"]:
|
||||
if block.get("type") in ("thinking", "redacted_thinking"):
|
||||
if block.get("type") in {"thinking", "redacted_thinking"}:
|
||||
assert "cache_control" not in block
|
||||
|
||||
def test_thinking_stripped_from_merged_consecutive_assistants(self):
|
||||
@@ -1741,7 +1748,7 @@ class TestThinkingBlockSignatureManagement:
|
||||
# First two: no thinking blocks
|
||||
for a in assistants[:2]:
|
||||
assert not any(
|
||||
b.get("type") in ("thinking", "redacted_thinking")
|
||||
b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in a["content"]
|
||||
if isinstance(b, dict)
|
||||
)
|
||||
|
||||
@@ -673,6 +673,8 @@ class TestGetTextAuxiliaryClient:
|
||||
def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self):
|
||||
with patch("agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_text_auxiliary_client()
|
||||
@@ -923,6 +925,44 @@ class TestIsPaymentError:
|
||||
exc = Exception("connection reset")
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
# ── Daily / monthly quota exhaustion (#26803) ────────────────────────────
|
||||
|
||||
def test_429_quota_exceeded(self):
|
||||
"""Cloud provider quota exhaustion (e.g. Vertex AI) is a payment error."""
|
||||
exc = Exception("RESOURCE_EXHAUSTED: quota exceeded for project")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_too_many_tokens_per_day(self):
|
||||
"""Bedrock / LiteLLM daily token limit is a payment error."""
|
||||
exc = Exception("Too many tokens per day: 1000000 used, 1000000 limit")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_daily_limit_phrase(self):
|
||||
"""Generic 'daily limit' phrasing is a payment error."""
|
||||
exc = Exception("You have exceeded your daily limit.")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_resource_exhausted_grpc(self):
|
||||
"""Vertex AI gRPC RESOURCE_EXHAUSTED maps to payment error."""
|
||||
exc = Exception("resource exhausted")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_daily_quota_phrase(self):
|
||||
"""'daily quota' phrasing is a payment error."""
|
||||
exc = Exception("Daily quota of 500 requests reached.")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_transient_rate_limit_not_quota(self):
|
||||
"""Transient 429 rate limit without quota keywords is NOT a payment error."""
|
||||
exc = Exception("Rate limit exceeded. Retry after 10s.")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
|
||||
class TestIsRateLimitError:
|
||||
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
|
||||
@@ -1111,6 +1151,140 @@ class TestCallLlmPaymentFallback:
|
||||
# Fallback client should have been used
|
||||
assert fallback_client.chat.completions.create.called
|
||||
|
||||
|
||||
class TestAuxiliaryFallbackLayering:
|
||||
"""Explicit-provider users get layered fallback: configured_chain → main agent → warn."""
|
||||
|
||||
def _make_payment_err(self):
|
||||
exc = Exception("Payment Required: insufficient credits")
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):
|
||||
"""When a user has fallback_chain configured, it's tried BEFORE the main agent model."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_payment_err()
|
||||
|
||||
chain_client = MagicMock()
|
||||
chain_client.chat.completions.create.return_value = MagicMock(choices=[
|
||||
MagicMock(message=MagicMock(content="from configured chain"))
|
||||
])
|
||||
|
||||
main_called = MagicMock()
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "glm-4v-flash")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("glm", "glm-4v-flash", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(chain_client, "gpt-4o-mini", "fallback_chain[0](openai)")), \
|
||||
patch("agent.auxiliary_client._try_main_agent_model_fallback",
|
||||
side_effect=main_called):
|
||||
result = call_llm(
|
||||
task="vision",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert chain_client.chat.completions.create.called
|
||||
# Main agent fallback should NOT have been consulted — chain succeeded first
|
||||
main_called.assert_not_called()
|
||||
|
||||
def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeypatch):
|
||||
"""If configured fallback_chain returns nothing, main agent model is tried next."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_payment_err()
|
||||
|
||||
main_client = MagicMock()
|
||||
main_client.chat.completions.create.return_value = MagicMock(choices=[
|
||||
MagicMock(message=MagicMock(content="from main agent"))
|
||||
])
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "glm-4v-flash")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("glm", "glm-4v-flash", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, "")), \
|
||||
patch("agent.auxiliary_client._try_main_agent_model_fallback",
|
||||
return_value=(main_client, "claude-sonnet-4", "main-agent(openrouter)")):
|
||||
result = call_llm(
|
||||
task="vision",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert main_client.chat.completions.create.called
|
||||
|
||||
def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog):
|
||||
"""When chain AND main model both fail, a user-visible warning fires before re-raise."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_payment_err()
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "glm-4v-flash")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("glm", "glm-4v-flash", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, "")), \
|
||||
patch("agent.auxiliary_client._try_main_agent_model_fallback",
|
||||
return_value=(None, None, "")), \
|
||||
caplog.at_level("WARNING", logger="agent.auxiliary_client"):
|
||||
with pytest.raises(Exception, match="Payment Required"):
|
||||
call_llm(
|
||||
task="vision",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert any(
|
||||
"all fallbacks exhausted" in r.message for r in caplog.records
|
||||
), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
|
||||
class TestTryMainAgentModelFallback:
|
||||
"""_try_main_agent_model_fallback resolves the user's main provider+model as a safety net."""
|
||||
|
||||
def test_returns_none_when_main_provider_is_auto(self):
|
||||
from agent.auxiliary_client import _try_main_agent_model_fallback
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="auto"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="some-model"):
|
||||
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
|
||||
assert client is None and model is None and label == ""
|
||||
|
||||
def test_returns_none_when_failed_provider_equals_main(self):
|
||||
"""If the thing that failed IS the main model, no point retrying it."""
|
||||
from agent.auxiliary_client import _try_main_agent_model_fallback
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"):
|
||||
client, model, label = _try_main_agent_model_fallback("openrouter", task="vision")
|
||||
assert client is None and label == ""
|
||||
|
||||
def test_resolves_main_provider_client(self):
|
||||
from agent.auxiliary_client import _try_main_agent_model_fallback
|
||||
fake_client = MagicMock()
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
|
||||
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \
|
||||
patch("agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(fake_client, "anthropic/claude-sonnet-4")):
|
||||
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
|
||||
assert client is fake_client
|
||||
assert model == "anthropic/claude-sonnet-4"
|
||||
assert label == "main-agent(openrouter)"
|
||||
|
||||
def test_skips_when_main_provider_is_unhealthy(self):
|
||||
from agent.auxiliary_client import _try_main_agent_model_fallback
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
|
||||
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=True):
|
||||
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
|
||||
assert client is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate: _resolve_api_key_provider must skip anthropic when not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2349,10 +2523,13 @@ class TestAuxiliaryClientPoisonedCacheEviction:
|
||||
def test_call_llm_evicts_on_connection_error_with_explicit_provider(self):
|
||||
"""Connection error on an explicit provider must drop the cached client.
|
||||
|
||||
This is the exact reporter scenario: ``auxiliary.compression.provider:
|
||||
main`` (resolves to ``openai-codex``) → no fallback chain runs (not
|
||||
auto), but the cached client was poisoned by a prior timeout and must
|
||||
be evicted so the next call rebuilds.
|
||||
Reporter scenario: ``auxiliary.compression.provider: main`` (resolves
|
||||
to ``openai-codex``). After #26803, capacity errors (payment/quota/
|
||||
connection) DO trigger fallback even on explicit providers — so we
|
||||
also stub ``_try_payment_fallback`` to ``(None, None, "")`` so the
|
||||
connection error re-raises after eviction instead of escaping into
|
||||
a real network call. The contract under test is cache eviction,
|
||||
not the fallback gate.
|
||||
"""
|
||||
from agent.auxiliary_client import _client_cache, _client_cache_lock
|
||||
|
||||
@@ -2372,6 +2549,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
|
||||
), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(poisoned, "gpt-5.5"),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(None, None, ""),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
call_llm(
|
||||
@@ -2405,6 +2585,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
|
||||
), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(poisoned, "gpt-5.5"),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(None, None, ""),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
await async_call_llm(
|
||||
|
||||
@@ -371,7 +371,7 @@ class TestResolveVisionMainFirst:
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert client is fallback_client
|
||||
assert provider in ("openrouter", "nous")
|
||||
assert provider in {"openrouter", "nous"}
|
||||
|
||||
def test_explicit_provider_override_still_wins(self):
|
||||
"""Explicit config override bypasses main-first policy."""
|
||||
|
||||
@@ -1046,7 +1046,7 @@ class TestCompressWithClient:
|
||||
for i in range(1, len(result)):
|
||||
r1 = result[i - 1].get("role")
|
||||
r2 = result[i].get("role")
|
||||
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
|
||||
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
|
||||
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
|
||||
|
||||
def test_double_collision_merges_summary_into_tail(self):
|
||||
@@ -1087,7 +1087,7 @@ class TestCompressWithClient:
|
||||
for i in range(1, len(result)):
|
||||
r1 = result[i - 1].get("role")
|
||||
r2 = result[i].get("role")
|
||||
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
|
||||
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
|
||||
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
|
||||
|
||||
# The summary text should be merged into the first tail message
|
||||
@@ -1164,7 +1164,7 @@ class TestCompressWithClient:
|
||||
for i in range(1, len(result)):
|
||||
r1 = result[i - 1].get("role")
|
||||
r2 = result[i].get("role")
|
||||
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
|
||||
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
|
||||
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
|
||||
|
||||
# The summary should be merged into the first tail message (assistant at index 5)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -14,6 +16,14 @@ def _write_auth_store(tmp_path, payload: dict) -> None:
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def _jwt_with_claims(claims: dict) -> str:
|
||||
def _part(payload: dict) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
|
||||
|
||||
|
||||
def test_fill_first_selection_skips_recently_exhausted_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
@@ -510,6 +520,180 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch):
|
||||
assert entry.agent_key == "agent-key"
|
||||
|
||||
|
||||
def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat()
|
||||
token = _jwt_with_claims({
|
||||
"sub": "test-user",
|
||||
"scope": ["inference:invoke", "inference:mint_agent_key"],
|
||||
"exp": int(time.time() + 3600),
|
||||
})
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"active_provider": "nous",
|
||||
"providers": {
|
||||
"nous": {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"token_type": "Bearer",
|
||||
"scope": "inference:invoke inference:mint_agent_key",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": expires_at,
|
||||
"agent_key": token,
|
||||
"agent_key_expires_at": expires_at,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("nous")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.source == "device_code"
|
||||
assert entry.agent_key == token
|
||||
assert entry.runtime_api_key == token
|
||||
|
||||
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
pool_entry = auth_payload["credential_pool"]["nous"][0]
|
||||
assert pool_entry["agent_key"] == token
|
||||
assert pool_entry["agent_key_expires_at"] == expires_at
|
||||
|
||||
|
||||
def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"active_provider": "nous",
|
||||
"providers": {
|
||||
"nous": {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"token_type": "Bearer",
|
||||
"scope": "inference:mint_agent_key",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": "2026-03-24T12:00:00+00:00",
|
||||
"agent_key": "agent-key",
|
||||
"agent_key_expires_at": "2026-03-24T13:30:00+00:00",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import PooledCredential, load_pool
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
refresh_calls = {"count": 0}
|
||||
|
||||
def _terminal_refresh_failure(*_args, **_kwargs):
|
||||
refresh_calls["count"] += 1
|
||||
raise AuthError(
|
||||
"Refresh session has been revoked",
|
||||
provider="nous",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
pool = load_pool("nous")
|
||||
selected = pool.select()
|
||||
assert selected is not None
|
||||
assert selected.source == "device_code"
|
||||
pool.add_entry(PooledCredential.from_dict("nous", {
|
||||
"id": "legacy-seeded",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "old-access-token",
|
||||
"refresh_token": "old-refresh-token",
|
||||
"agent_key": "old-agent-key",
|
||||
}))
|
||||
pool.add_entry(PooledCredential.from_dict("nous", {
|
||||
"id": "manual-key",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"access_token": "manual-nous-key",
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure)
|
||||
|
||||
assert pool.try_refresh_current() is None
|
||||
|
||||
assert [entry.id for entry in pool.entries()] == ["manual-key"]
|
||||
|
||||
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
nous_state = auth_payload["providers"]["nous"]
|
||||
assert not nous_state.get("refresh_token")
|
||||
assert not nous_state.get("access_token")
|
||||
assert not nous_state.get("agent_key")
|
||||
assert nous_state["last_auth_error"]["code"] == "invalid_grant"
|
||||
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
|
||||
|
||||
assert pool.try_refresh_current() is None
|
||||
assert refresh_calls["count"] == 1
|
||||
|
||||
|
||||
def test_load_pool_removes_nous_device_code_when_singleton_quarantined(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"active_provider": "nous",
|
||||
"providers": {
|
||||
"nous": {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"last_auth_error": {"code": "invalid_grant"},
|
||||
}
|
||||
},
|
||||
"credential_pool": {
|
||||
"nous": [
|
||||
{
|
||||
"id": "seeded-current",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "stale-access",
|
||||
"refresh_token": "stale-refresh",
|
||||
"agent_key": "stale-agent",
|
||||
},
|
||||
{
|
||||
"id": "seeded-legacy",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "older-stale-access",
|
||||
},
|
||||
{
|
||||
"id": "manual-key",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"access_token": "manual-nous-key",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("nous")
|
||||
|
||||
assert [entry.id for entry in pool.entries()] == ["manual-key"]
|
||||
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
|
||||
|
||||
|
||||
def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
@@ -191,7 +191,7 @@ class TestDeepSeekAnthropicPreservesThinking:
|
||||
if not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for b in m["content"]:
|
||||
if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"):
|
||||
if isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}:
|
||||
assert "cache_control" not in b
|
||||
|
||||
def test_openai_compat_deepseek_base_is_not_matched(self) -> None:
|
||||
|
||||
@@ -746,6 +746,16 @@ class TestGetModelContextLength:
|
||||
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."""
|
||||
|
||||
@@ -100,6 +100,30 @@ class TestParseResponse:
|
||||
)
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -241,3 +241,58 @@ class TestSpawnEnvIsolation:
|
||||
assert captured["env"].get("CODEX_HOME") == "/tmp/profile/codex"
|
||||
# And HOME still passes through unchanged
|
||||
assert captured["env"].get("HOME") == "/users/alice"
|
||||
|
||||
def test_kanban_worker_adds_only_kanban_writable_root(self, monkeypatch):
|
||||
"""Codex-runtime Kanban workers need to write board state outside
|
||||
their scratch/worktree workspace, but should not fall back to
|
||||
danger-full-access. Hermes passes a narrow app-server config override
|
||||
for the Kanban root only.
|
||||
"""
|
||||
import subprocess
|
||||
from agent.transports import codex_app_server as cas
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, cmd, *args, **kwargs):
|
||||
captured["cmd"] = list(cmd)
|
||||
captured["env"] = kwargs.get("env", {}).copy()
|
||||
self.stdin = None
|
||||
self.stdout = None
|
||||
self.stderr = None
|
||||
self.pid = 1
|
||||
self.returncode = None
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
pass
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
||||
monkeypatch.setenv("HOME", "/users/alice")
|
||||
monkeypatch.setenv("HERMES_HOME", "/users/alice/.hermes/profiles/backend-worker")
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_smoke")
|
||||
monkeypatch.setenv(
|
||||
"HERMES_KANBAN_DB",
|
||||
"/users/alice/.hermes/kanban/boards/smoke/kanban.db",
|
||||
)
|
||||
|
||||
client = cas.CodexAppServerClient(codex_bin="codex")
|
||||
client._closed = True
|
||||
|
||||
cmd = captured["cmd"]
|
||||
assert cmd[:2] == ["codex", "app-server"]
|
||||
assert 'sandbox_mode="workspace-write"' in cmd
|
||||
assert (
|
||||
'sandbox_workspace_write.writable_roots=["/users/alice/.hermes/kanban/boards/smoke"]'
|
||||
in cmd
|
||||
)
|
||||
assert "sandbox_workspace_write.network_access=false" in cmd
|
||||
assert all("danger" not in part for part in cmd)
|
||||
|
||||
@@ -9,10 +9,12 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.transports.codex_app_server_session as session_mod
|
||||
from agent.transports.codex_app_server_session import (
|
||||
CodexAppServerSession,
|
||||
TurnResult,
|
||||
@@ -344,6 +346,23 @@ class TestRunTurn:
|
||||
assert r.interrupted is True
|
||||
assert r.error and "timed out" in r.error
|
||||
|
||||
def test_deadline_uses_monotonic_clock(self):
|
||||
client = FakeClient()
|
||||
s = make_session(client)
|
||||
monotonic_values = iter([1000.0, 999.0, 999.0, 1001.0])
|
||||
with patch.object(
|
||||
session_mod.time,
|
||||
"monotonic",
|
||||
side_effect=lambda: next(monotonic_values),
|
||||
):
|
||||
r = s.run_turn(
|
||||
"never finishes",
|
||||
turn_timeout=0.1,
|
||||
notification_poll_timeout=0.0,
|
||||
)
|
||||
assert r.interrupted is True
|
||||
assert r.error and "timed out" in r.error
|
||||
|
||||
def test_failed_turn_records_error_from_turn_completed(self):
|
||||
client = FakeClient()
|
||||
client.queue_notification(
|
||||
@@ -666,6 +685,35 @@ class TestSessionRetirement:
|
||||
# Confirm we issued turn/interrupt to free codex compute
|
||||
assert any(method == "turn/interrupt" for (method, _) in client.requests)
|
||||
|
||||
def test_post_tool_watchdog_uses_monotonic_clock(self):
|
||||
client = FakeClient()
|
||||
client.queue_notification(
|
||||
"item/completed",
|
||||
item={
|
||||
"type": "commandExecution", "id": "ex1",
|
||||
"command": "echo hi", "cwd": "/tmp",
|
||||
"status": "completed", "aggregatedOutput": "hi",
|
||||
"exitCode": 0, "commandActions": [],
|
||||
},
|
||||
threadId="t", turnId="tu1",
|
||||
)
|
||||
s = make_session(client)
|
||||
monotonic_values = iter([1000.0, 999.0, 999.0, 999.0, 1000.2])
|
||||
with patch.object(
|
||||
session_mod.time,
|
||||
"monotonic",
|
||||
side_effect=lambda: next(monotonic_values),
|
||||
):
|
||||
r = s.run_turn(
|
||||
"tool then silence",
|
||||
turn_timeout=5.0,
|
||||
notification_poll_timeout=0.0,
|
||||
post_tool_quiet_timeout=0.15,
|
||||
)
|
||||
assert r.interrupted is True
|
||||
assert r.should_retire is True
|
||||
assert r.error and "silent" in r.error
|
||||
|
||||
def test_post_tool_watchdog_resets_on_further_activity(self):
|
||||
"""A tool completion followed by an agent message should NOT trip
|
||||
the watchdog — further activity = codex still alive."""
|
||||
|
||||
Reference in New Issue
Block a user