Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6224033c |
@@ -824,7 +824,6 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -840,7 +839,6 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1781,25 +1779,10 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
+32
-2
@@ -1193,8 +1193,38 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_base_url = None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
|
||||
@@ -51,10 +51,6 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
os.path.join(home, ".profile"),
|
||||
os.path.join(home, ".bash_profile"),
|
||||
os.path.join(home, ".zprofile"),
|
||||
os.path.join(home, ".zshenv"),
|
||||
os.path.join(home, ".zlogin"),
|
||||
os.path.join(home, ".bash_login"),
|
||||
os.path.join(home, ".gitconfig"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -82,7 +78,6 @@ def build_write_denied_prefixes(home: str) -> list[str]:
|
||||
os.path.join(home, ".azure"),
|
||||
os.path.join(home, ".config", "gh"),
|
||||
os.path.join(home, ".config", "gcloud"),
|
||||
os.path.join(home, ".config", "git"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
|
||||
@@ -44,66 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,14 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -508,16 +508,13 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram supports rich Markdown, so when it improves clarity you may "
|
||||
"use headings, tables (pipe `| col | col |` syntax), task lists "
|
||||
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
|
||||
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
|
||||
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
|
||||
"real Markdown tables and task lists over hand-built bullet substitutes "
|
||||
"when presenting structured data. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -39,8 +38,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -151,10 +150,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -176,8 +173,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -394,57 +391,48 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[handoff] install files freed after force-kill",
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -453,44 +441,13 @@ pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHa
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![venv_hermes(install_root)];
|
||||
paths.extend(desktop_app_payload_paths(install_root));
|
||||
paths
|
||||
}
|
||||
|
||||
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
release.join("win-unpacked").join("resources").join("app.asar"),
|
||||
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
]
|
||||
} else {
|
||||
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
paths.iter().filter(|p| is_locked(p)).cloned().collect()
|
||||
}
|
||||
|
||||
fn format_locked_paths(paths: &[PathBuf]) -> String {
|
||||
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -934,29 +891,6 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1835,44 +1835,6 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -2470,14 +2432,6 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
|
||||
@@ -42,9 +42,6 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -84,19 +84,6 @@ describe('PendingToolApproval', () => {
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full command inline when the Command toggle is clicked', () => {
|
||||
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
||||
setRequest(longCommand)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
// Collapsed by default: the full command is not in the DOM yet.
|
||||
expect(screen.queryByText(longCommand)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
|
||||
|
||||
expect(screen.getByText(longCommand)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends choice "deny" on Reject', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
|
||||
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -61,15 +60,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
// The pending tool row only shows a single truncated line of the command, and
|
||||
// a pending row can't be expanded (no result yet), so the full command was
|
||||
// previously only reachable via the "Always allow" modal. Let the user reveal
|
||||
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
|
||||
const [showCommand, setShowCommand] = useState(false)
|
||||
const busy = submitting !== null
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
const allowPermanent = request.allowPermanent !== false
|
||||
const hasCommand = request.command.trim().length > 0
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -126,89 +119,70 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
}, [confirmAlways, respond])
|
||||
|
||||
return (
|
||||
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
|
||||
{hasCommand && (
|
||||
<Button
|
||||
aria-expanded={showCommand}
|
||||
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => setShowCommand(value => !value)}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.command}
|
||||
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{showCommand && hasCommand && (
|
||||
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
|
||||
{request.command.trim()}
|
||||
</pre>
|
||||
)}
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
</Button>
|
||||
|
||||
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
|
||||
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{copy.alwaysDescription(request.description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{request.command.trim() && (
|
||||
|
||||
@@ -1687,7 +1687,6 @@ export const en: Translations = {
|
||||
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||
sendFailed: 'Could not send approval response',
|
||||
run: 'Run',
|
||||
command: 'Command',
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
|
||||
@@ -1827,7 +1827,6 @@ export const ja = defineLocale({
|
||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||
sendFailed: '承認応答を送信できませんでした',
|
||||
run: '実行',
|
||||
command: 'コマンド',
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
|
||||
@@ -1346,7 +1346,6 @@ export interface Translations {
|
||||
gatewayDisconnected: string
|
||||
sendFailed: string
|
||||
run: string
|
||||
command: string
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
|
||||
@@ -1771,7 +1771,6 @@ export const zhHant = defineLocale({
|
||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||
sendFailed: '無法傳送核准回應',
|
||||
run: '執行',
|
||||
command: '指令',
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
|
||||
@@ -1867,7 +1867,6 @@ export const zh: Translations = {
|
||||
gatewayDisconnected: 'Hermes 网关未连接',
|
||||
sendFailed: '无法发送审批响应',
|
||||
run: '运行',
|
||||
command: '命令',
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
|
||||
@@ -719,6 +719,11 @@ platform_toolsets:
|
||||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# # Bot API 10.1 Rich Messages: final replies send raw markdown via
|
||||
# # sendRichMessage so tables, task lists, collapsible details, math, etc.
|
||||
# # render natively (with automatic MarkdownV2 fallback). Opt-in while
|
||||
# # the new endpoint is validated; default false.
|
||||
# rich_messages: false # Set true to enable native rich rendering
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
@@ -2827,53 +2827,6 @@ def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _estimate_tui_input_height(
|
||||
lines: list[str] | tuple[str, ...],
|
||||
prompt_text: str,
|
||||
terminal_columns: int,
|
||||
*,
|
||||
max_height: int = 8,
|
||||
) -> int:
|
||||
"""Estimate classic prompt_toolkit input rows using live terminal cells.
|
||||
|
||||
The TextArea prompt is injected with prompt_toolkit's BeforeInput
|
||||
processor, which means it consumes cells only on logical line 0. After a
|
||||
narrow resize, that first row can leave only one input cell beside an icon
|
||||
prompt such as ``⚔ ``, while continuation rows use the full terminal width.
|
||||
Never substitute a fake wide fallback here: under- or over-allocating the
|
||||
TextArea height leaves stale prompt/input cells visible at the bottom of the
|
||||
terminal.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
except Exception:
|
||||
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
columns = int(terminal_columns or 0)
|
||||
except (TypeError, ValueError):
|
||||
columns = 0
|
||||
|
||||
columns = max(1, columns)
|
||||
prompt_width = max(0, get_cwidth(prompt_text or ""))
|
||||
|
||||
visual_lines = 0
|
||||
for index, line in enumerate(lines or [""]):
|
||||
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
|
||||
# applies only to logical line 0. Wrapped continuation rows, and later
|
||||
# logical lines, use the full terminal width. Count the display cells
|
||||
# after that same transformation rather than subtracting the prompt from
|
||||
# every wrapped row.
|
||||
line_width = get_cwidth(line or "")
|
||||
display_width = line_width + (prompt_width if index == 0 else 0)
|
||||
if display_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-display_width // columns))
|
||||
|
||||
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
"""Collect local image attachments for single-query CLI flows."""
|
||||
message = query or ""
|
||||
@@ -3736,12 +3689,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
startup UI and ``_replay_output_history`` cannot reconstruct it
|
||||
(the banner was never added to ``_OUTPUT_HISTORY``).
|
||||
|
||||
Let prompt_toolkit's own resize path run with its renderer cursor
|
||||
cache intact. Its Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which needs the cached
|
||||
cursor position to move back to the live prompt origin before
|
||||
erase_down(). Resetting the renderer before that erase loses the
|
||||
origin and can leave stale prompt glyphs after a narrow resize.
|
||||
Instead we just reset prompt_toolkit's renderer cache so the next
|
||||
incremental redraw starts from a clean slate, then let
|
||||
``original_on_resize`` recalculate layout for the new size.
|
||||
|
||||
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
|
||||
status bar and input separator rules stay hidden until the next user
|
||||
@@ -3752,6 +3702,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
next prompt restores the bar cleanly.
|
||||
"""
|
||||
self._status_bar_suppressed_after_resize = True
|
||||
try:
|
||||
app.renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
original_on_resize()
|
||||
|
||||
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
|
||||
@@ -12046,17 +12004,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
|
||||
try:
|
||||
terminal_columns = get_app().output.get_size().columns
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
terminal_columns = shutil.get_terminal_size((80, 24)).columns
|
||||
return _estimate_tui_input_height(
|
||||
doc.lines,
|
||||
self._get_tui_prompt_text(),
|
||||
terminal_columns,
|
||||
)
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
# Each logical line takes at least 1 visual row; long lines wrap.
|
||||
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width)) # ceil division
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@@ -12798,13 +12765,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
style=style,
|
||||
full_screen=False,
|
||||
mouse_support=False,
|
||||
# The status bar contains wall-clock read-outs (live prompt elapsed
|
||||
# and idle-since-last-turn). Once a turn finishes there may be no
|
||||
# further events to invalidate the app, so prompt_toolkit would keep
|
||||
# rendering the first post-turn value (usually ``✓ 0s``) forever.
|
||||
# A low-rate refresh keeps the clock honest without reintroducing a
|
||||
# custom repaint thread or touching conversation state.
|
||||
refresh_interval=1.0,
|
||||
# Erase the live bottom chrome (status bar, input box, separator
|
||||
# rules) on exit instead of freezing a final copy into scrollback.
|
||||
# Without this, prompt_toolkit's render_as_done teardown repaints
|
||||
|
||||
@@ -416,8 +416,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# Bot API 10.1 Rich Messages: opportunistically send final replies via
|
||||
# sendRichMessage with the raw agent markdown so tables/task lists/etc.
|
||||
# render natively. Opt-out via platforms.telegram.extra.rich_messages.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
@@ -947,8 +949,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
||||
|
||||
def _should_attempt_rich(self, content: str) -> bool:
|
||||
# getattr defaults: tests build adapters via object.__new__() (no
|
||||
# __init__), so the flags may be unset — default rich OFF (the
|
||||
# feature is opt-in via platforms.telegram.extra.rich_messages).
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
and self._content_fits_rich_limits(content)
|
||||
@@ -1126,7 +1132,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
|
||||
+45
-59
@@ -8815,29 +8815,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Auto-resetting session %s after compression exhaustion.",
|
||||
session_entry.session_id,
|
||||
)
|
||||
new_entry = self.session_store.reset_session(session_key)
|
||||
self.session_store.reset_session(session_key)
|
||||
self._evict_cached_agent(session_key)
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
if new_entry is not None:
|
||||
# Drop the stale reference to the bloated compressed child and
|
||||
# re-point the Telegram topic binding at the fresh session.
|
||||
# Compression rotated session_entry.session_id to the oversized
|
||||
# compressed child earlier this turn (the agent-result sync
|
||||
# above), and that _sync also rewrote the (chat_id, thread_id)
|
||||
# -> bloated-child binding. reset_session swaps in a clean,
|
||||
# parentless session, but without re-syncing the binding the
|
||||
# next inbound message in this topic gets switch_session'd back
|
||||
# onto the bloated child by the binding-heal walk, reloads the
|
||||
# oversized transcript, and re-triggers compression exhaustion
|
||||
# forever (#35809 — regression of the #9893/#10063 auto-reset).
|
||||
# No-op on non-topic lanes.
|
||||
session_entry = new_entry
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="compression-exhausted-reset",
|
||||
)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||||
"maximum context size and could not be compressed further. "
|
||||
@@ -14582,59 +14565,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
|
||||
_resolved_model = getattr(_agent, "model", None) if _agent else None
|
||||
|
||||
# Sync session_id immediately after run_conversation(). Compression
|
||||
# can rotate before a follow-up model call fails; the failure return
|
||||
# below must still point the gateway at the compressed child.
|
||||
# Compression can rotate the agent to a new session before the
|
||||
# follow-up model call succeeds. Sync that rotation before any
|
||||
# early return so the next gateway turn reloads the compressed
|
||||
# child transcript instead of the stale pre-compression parent.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
if agent and session_key and agent_session_id != session_id:
|
||||
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
if agent and session_key and effective_session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent_session_id,
|
||||
session_id, effective_session_id,
|
||||
)
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry:
|
||||
entry.session_id = agent_session_id
|
||||
entry.session_id = effective_session_id
|
||||
self.session_store._save()
|
||||
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent_session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
if entry:
|
||||
self._sync_telegram_topic_binding(
|
||||
source, entry, reason="agent-run-compression",
|
||||
source, entry, reason="agent-result-compression",
|
||||
)
|
||||
|
||||
effective_session_id = agent_session_id
|
||||
# When compression created a new session, the messages list was
|
||||
# shortened. Using the original history offset would make gateway
|
||||
# persistence slice away the compressed handoff/tail on failure.
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
if not final_response:
|
||||
@@ -14698,6 +14652,38 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
unique_tags.insert(0, "[[audio_as_voice]]")
|
||||
final_response = final_response + "\n" + "\n".join(unique_tags)
|
||||
|
||||
if _session_was_split:
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent.session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent.session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Auto-generate session title after first exchange (non-blocking)
|
||||
if final_response and self._session_db:
|
||||
try:
|
||||
|
||||
+7
-69
@@ -3524,22 +3524,6 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
|
||||
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
|
||||
imported = _import_codex_cli_tokens()
|
||||
# Require BOTH tokens before adopting: persisting a payload without a
|
||||
# usable refresh_token would only break the next refresh cycle.
|
||||
if not (
|
||||
imported
|
||||
and str(imported.get("access_token", "") or "").strip()
|
||||
and str(imported.get("refresh_token", "") or "").strip()
|
||||
):
|
||||
return None
|
||||
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
|
||||
_save_codex_tokens(imported)
|
||||
return dict(imported)
|
||||
|
||||
|
||||
def refresh_codex_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
@@ -3676,34 +3660,11 @@ def _refresh_codex_auth_tokens(
|
||||
|
||||
Saves the new tokens to Hermes auth store automatically.
|
||||
"""
|
||||
try:
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except AuthError as exc:
|
||||
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
|
||||
# Codex OAuth token (per profile + top-level), separate from the Codex
|
||||
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
|
||||
# the Codex CLI (or another Hermes process) rotates the shared token,
|
||||
# this frozen copy's refresh_token goes stale and the refresh fails with
|
||||
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
|
||||
# Before surfacing that as a hard 401 to the turn, adopt the canonical
|
||||
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
|
||||
# idle profiles / desktop sessions recover automatically instead of
|
||||
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
|
||||
# keep relogin_required=False — the stored token is still valid there, so
|
||||
# we never self-heal those and re-raise unchanged.
|
||||
if not getattr(exc, "relogin_required", False):
|
||||
raise
|
||||
imported = _recover_codex_tokens_from_cli(
|
||||
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
|
||||
)
|
||||
if not imported:
|
||||
raise
|
||||
return imported
|
||||
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
updated_tokens = dict(tokens)
|
||||
updated_tokens["access_token"] = refreshed["access_token"]
|
||||
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
||||
@@ -3763,25 +3724,9 @@ def resolve_codex_runtime_credentials(
|
||||
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
|
||||
credential. See issue #32992.
|
||||
"""
|
||||
read_error: Optional[AuthError] = None
|
||||
try:
|
||||
data = _read_codex_tokens()
|
||||
except AuthError as exc:
|
||||
read_error = exc
|
||||
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
|
||||
"codex_auth_missing_access_token",
|
||||
"codex_auth_missing_refresh_token",
|
||||
"codex_auth_invalid_shape",
|
||||
}:
|
||||
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
|
||||
if imported:
|
||||
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
|
||||
else:
|
||||
data = None
|
||||
else:
|
||||
data = None
|
||||
|
||||
if data is None:
|
||||
except AuthError:
|
||||
pool_token = _pool_codex_access_token()
|
||||
if pool_token:
|
||||
base_url = (
|
||||
@@ -3796,14 +3741,7 @@ def resolve_codex_runtime_credentials(
|
||||
"last_refresh": None,
|
||||
"auth_mode": "chatgpt",
|
||||
}
|
||||
if read_error is not None:
|
||||
raise read_error
|
||||
raise AuthError(
|
||||
"No Codex credentials stored. Run `hermes auth` to authenticate.",
|
||||
provider="openai-codex",
|
||||
code="codex_auth_missing",
|
||||
relogin_required=True,
|
||||
)
|
||||
raise
|
||||
|
||||
tokens = dict(data["tokens"])
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
|
||||
+3
-29
@@ -1543,14 +1543,8 @@ def run_doctor(args):
|
||||
total = critical + high + moderate
|
||||
# Determine a scoped fix command for the remediation hint.
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
# Detection (`npm audit --workspace <name>`) is read-only and
|
||||
# safe, but `npm audit fix --workspace <name>` crashes on
|
||||
# current npm with "Cannot read properties of null (reading
|
||||
# 'edgesOut')" — an arborist bug with workspace-filtered
|
||||
# audit fix. The root-level `npm audit fix` can crash on the
|
||||
# same tree with "isDescendantOf", so do not hand the user a
|
||||
# manual fix command for these build-tool advisories.
|
||||
fix_cmd = None
|
||||
fix_scope = " ".join(audit_extra)
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
|
||||
elif audit_extra == ["--workspaces=false"]:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
|
||||
else:
|
||||
@@ -1558,30 +1552,10 @@ def run_doctor(args):
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
if fix_cmd:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
|
||||
)
|
||||
else:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — "
|
||||
"build-tool advisory; clears via lockfile bump"
|
||||
)
|
||||
check_warn(
|
||||
f"{label} deps",
|
||||
f"({vuln_detail})"
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
|
||||
)
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
# The web/ui-tui workspace advisories are in build-time
|
||||
# tooling (esbuild/vite, etc.), not runtime code that ships
|
||||
# to users. Manual npm remediation may error with a known
|
||||
# arborist crash (edgesOut / isDescendantOf) on this monorepo
|
||||
# tree — in that case it is an npm bug, not a Hermes one.
|
||||
check_info(
|
||||
" ^ build-time tooling (not runtime); if manual npm remediation "
|
||||
"errors with an arborist crash it's a known npm bug — clears "
|
||||
"via a lockfile bump"
|
||||
)
|
||||
issues.append(
|
||||
f"{label} has {total} npm "
|
||||
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
|
||||
|
||||
@@ -6675,40 +6675,6 @@ def _worker_terminal_timeout_env(
|
||||
return str(desired)
|
||||
|
||||
|
||||
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
|
||||
"""Return the assigned profile's effective CLI toolsets for a worker.
|
||||
|
||||
Dispatcher-spawned workers are launched from a long-lived gateway process,
|
||||
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
|
||||
assignee profile's CLI tool surface at dispatch time and pass it as an
|
||||
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
|
||||
root/active-profile config or a profile whose top-level ``toolsets`` entry
|
||||
is only the kanban orchestrator surface. ``model_tools`` still appends the
|
||||
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
|
||||
"""
|
||||
if not hermes_home:
|
||||
return None
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
token = set_hermes_home_override(hermes_home)
|
||||
try:
|
||||
cfg = load_config()
|
||||
toolsets = sorted(_get_platform_tools(cfg, "cli"))
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return toolsets or None
|
||||
except Exception as exc:
|
||||
_log.debug(
|
||||
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
|
||||
hermes_home,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _default_spawn(
|
||||
task: Task,
|
||||
workspace: str,
|
||||
@@ -6842,9 +6808,6 @@ def _default_spawn(
|
||||
cmd.extend(["--skills", sk])
|
||||
if task.model_override:
|
||||
cmd.extend(["-m", task.model_override])
|
||||
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
|
||||
if worker_toolsets:
|
||||
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
|
||||
cmd.extend([
|
||||
"chat",
|
||||
"-q", prompt,
|
||||
|
||||
@@ -26,7 +26,6 @@ from dataclasses import dataclass
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from hermes_cli.providers import (
|
||||
ProviderDef,
|
||||
custom_provider_slug,
|
||||
determine_api_mode,
|
||||
get_label,
|
||||
@@ -47,23 +46,6 @@ from agent.models_dev import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
|
||||
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
|
||||
base_url = str(current_base_url or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
return ProviderDef(
|
||||
id="custom",
|
||||
name="Custom endpoint",
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=(),
|
||||
base_url=base_url,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="model-config",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-agentic model warning
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -694,8 +676,6 @@ def switch_model(
|
||||
user_providers,
|
||||
custom_providers,
|
||||
)
|
||||
if pdef is None and explicit_provider.strip().lower() == "custom":
|
||||
pdef = _bare_custom_provider_def(current_base_url)
|
||||
if pdef is None:
|
||||
_switch_err = (
|
||||
f"Unknown provider '{explicit_provider}'. "
|
||||
@@ -901,8 +881,6 @@ def switch_model(
|
||||
|
||||
provider_changed = target_provider != current_provider
|
||||
provider_label = get_label(target_provider)
|
||||
if target_provider == "custom" and current_base_url:
|
||||
provider_label = "Custom endpoint"
|
||||
if target_provider.startswith("custom:"):
|
||||
custom_pdef = resolve_provider_full(
|
||||
target_provider,
|
||||
@@ -954,10 +932,6 @@ def switch_model(
|
||||
api_key = _ukey
|
||||
base_url = _user_pdef.base_url
|
||||
api_mode = ""
|
||||
elif target_provider == "custom" and current_base_url:
|
||||
api_key = current_api_key
|
||||
base_url = current_base_url
|
||||
api_mode = determine_api_mode(target_provider, base_url)
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
@@ -1774,43 +1748,6 @@ def list_authenticated_providers(
|
||||
if _pair[0] and _pair[1]:
|
||||
_section3_emitted_pairs.add(_pair)
|
||||
|
||||
# --- 3b. Active bare custom endpoint from model config ---
|
||||
# A config can still use the direct one-off form:
|
||||
# model.provider: custom
|
||||
# model.base_url: https://some-openai-compatible/v1
|
||||
# In that shape there is no named providers:/custom_providers row for the
|
||||
# picker to render, but the gateway only passes this current model slice to
|
||||
# list_authenticated_providers(). Surface the active endpoint explicitly so
|
||||
# /model does not look like it ignored config.yaml.
|
||||
_current_provider_norm = str(current_provider or "").strip().lower()
|
||||
if (
|
||||
_current_provider_norm == "custom"
|
||||
and current_base_url
|
||||
and "custom" not in seen_slugs
|
||||
and not any(
|
||||
isinstance(_cp, dict)
|
||||
and str(
|
||||
_cp.get("base_url", "")
|
||||
or _cp.get("url", "")
|
||||
or _cp.get("api", "")
|
||||
).strip().rstrip("/").lower()
|
||||
== str(current_base_url).strip().rstrip("/").lower()
|
||||
for _cp in (custom_providers or [])
|
||||
)
|
||||
):
|
||||
_models = [current_model] if current_model else []
|
||||
results.append({
|
||||
"slug": "custom",
|
||||
"name": "Custom endpoint",
|
||||
"is_current": True,
|
||||
"is_user_defined": True,
|
||||
"models": _models[:max_models] if max_models else _models,
|
||||
"total_models": len(_models),
|
||||
"source": "model-config",
|
||||
"api_url": str(current_base_url).strip().rstrip("/"),
|
||||
})
|
||||
seen_slugs.add("custom")
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
|
||||
+4
-10
@@ -1190,16 +1190,10 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
|
||||
can re-register manually later via the gateway start command,
|
||||
which goes through the same dispatch path.
|
||||
|
||||
Port selection: each supervised profile gateway loads its own
|
||||
``HERMES_HOME`` and binds the port resolved by ``gateway/config.py``
|
||||
from that profile's environment — ``API_SERVER_PORT`` (or
|
||||
``platforms.api_server.extra.port`` in the profile's
|
||||
``config.yaml``), defaulting to 8642. There is no ``[gateway] port``
|
||||
key and no Python-side allocator (PR #30136 review item I5 retired
|
||||
the SHA-256-derived range [9200, 9800) as dead code), so two
|
||||
profiles that both leave the port at its default will both try to
|
||||
bind 8642 — give each profile a distinct ``API_SERVER_PORT`` in its
|
||||
``.env``.
|
||||
Port selection is governed by the profile's ``config.yaml``
|
||||
(``[gateway] port = …``) — there is no Python-side allocator
|
||||
(PR #30136 review item I5 retired the SHA-256-derived range
|
||||
[9200, 9800) because it was dead code through the entire stack).
|
||||
|
||||
Host short-circuit: check ``detect_service_manager()`` first and
|
||||
return immediately if it isn't ``"s6"``. This keeps host
|
||||
|
||||
@@ -660,61 +660,6 @@ def has_named_custom_provider(requested_provider: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def find_custom_provider_identity(base_url: str) -> Optional[str]:
|
||||
"""Map an endpoint URL back to its canonical ``custom:<name>`` menu key.
|
||||
|
||||
Returns the ``custom:<normalized-name>`` slug of the first ``providers:``
|
||||
/ ``custom_providers:`` entry whose base_url matches, or ``None`` when no
|
||||
entry owns the URL.
|
||||
|
||||
Session persistence stores the agent's *resolved* provider, and for every
|
||||
named custom endpoint that is the literal string ``"custom"`` — the entry
|
||||
name is lost, and the api_key is deliberately never persisted. The
|
||||
endpoint URL is the one durable fact that survives the round-trip, so
|
||||
this reverse lookup lets persist/rebuild paths recover the entry identity
|
||||
(and with it key_env/api_key/api_mode resolution via
|
||||
:func:`_get_named_custom_provider`) instead of failing with
|
||||
``auth_unavailable`` or silently rebuilding with placeholder credentials.
|
||||
"""
|
||||
target = _normalize_base_url_for_match(base_url)
|
||||
if not target:
|
||||
return None
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
providers = config.get("providers")
|
||||
if isinstance(providers, dict):
|
||||
for ep_name, entry in providers.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_url = (
|
||||
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
|
||||
)
|
||||
if _normalize_base_url_for_match(entry_url) == target:
|
||||
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
|
||||
|
||||
try:
|
||||
custom_providers = get_compatible_custom_providers(config)
|
||||
except Exception:
|
||||
custom_providers = None
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
if _normalize_base_url_for_match(entry.get("base_url")) == target:
|
||||
return f"custom:{_normalize_custom_provider_name(name)}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_base_url_for_match(value) -> str:
|
||||
return str(value or "").strip().rstrip("/").lower()
|
||||
|
||||
|
||||
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
|
||||
extra_body = custom_provider.get("extra_body")
|
||||
if not isinstance(extra_body, dict) or not extra_body:
|
||||
|
||||
@@ -585,20 +585,15 @@ class S6ServiceManager:
|
||||
would instead look up ``$HERMES_HOME/profiles/default/`` — a
|
||||
completely different (and almost always nonexistent) profile.
|
||||
|
||||
Port selection: the gateway binds the port resolved by
|
||||
``gateway/config.py`` from the profile's own environment —
|
||||
``API_SERVER_PORT`` (or ``platforms.api_server.extra.port`` in
|
||||
that profile's ``config.yaml``), defaulting to 8642. There is
|
||||
no ``[gateway] port`` key and no Python-side allocator: because
|
||||
each supervised profile gateway loads its own ``HERMES_HOME``,
|
||||
two profiles that both leave the port unset will both try to
|
||||
bind 8642 — give each profile a distinct ``API_SERVER_PORT`` in
|
||||
its ``.env``. Previously this method took a ``port`` parameter
|
||||
that was passed in but never substituted into the rendered
|
||||
script (carried for "API parity" with a deterministic SHA-256
|
||||
allocator in ``hermes_cli.profiles._allocate_gateway_port``).
|
||||
PR #30136 review item I5 retired both the allocator and the
|
||||
parameter because they were dead code through the entire stack.
|
||||
Port selection: the gateway picks its bind port from the
|
||||
profile's ``config.yaml`` (``[gateway] port = ...``) — that
|
||||
is the single source of truth. Previously this method took a
|
||||
``port`` parameter that was passed in but never substituted
|
||||
into the rendered script (it was carried in for "API parity"
|
||||
with a deterministic SHA-256 allocator in
|
||||
``hermes_cli.profiles._allocate_gateway_port``). PR #30136
|
||||
review item I5 retired both the allocator and the parameter
|
||||
because they were dead code through the entire stack.
|
||||
"""
|
||||
import shlex
|
||||
lines = [
|
||||
|
||||
@@ -14,21 +14,6 @@ from typing import Callable
|
||||
from hermes_cli.subcommands._shared import add_accept_hooks_flag
|
||||
|
||||
|
||||
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
|
||||
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
|
||||
|
||||
Gateway service lifecycle commands operate on the gateway process, not a
|
||||
single messaging adapter. Photon briefly printed a per-platform start
|
||||
command during setup; keep that command parseable so users following the
|
||||
old hint don't get blocked by argparse before the gateway can start.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
dest="platform",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
|
||||
|
||||
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
|
||||
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
|
||||
# =========================================================================
|
||||
@@ -90,7 +75,6 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Kill ALL stale gateway processes across all profiles before starting",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_start)
|
||||
|
||||
# gateway stop
|
||||
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
|
||||
@@ -119,7 +103,6 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Kill ALL gateway processes across all profiles before restarting",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_restart)
|
||||
|
||||
# gateway status
|
||||
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
|
||||
@@ -135,7 +118,6 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Target the Linux system-level gateway service",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_status)
|
||||
|
||||
# gateway install
|
||||
gateway_install = gateway_subparsers.add_parser(
|
||||
|
||||
+33
-97
@@ -2535,7 +2535,6 @@ async def get_sessions(
|
||||
order: str = "created",
|
||||
source: str = None,
|
||||
exclude_sources: str = None,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
@@ -2559,11 +2558,9 @@ async def get_sessions(
|
||||
status_code=400,
|
||||
detail="order must be one of: created, recent",
|
||||
)
|
||||
profile_name: Optional[str] = None
|
||||
if profile:
|
||||
profile_name, _ = _cron_profile_home(profile)
|
||||
try:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
@@ -2597,16 +2594,11 @@ async def get_sessions(
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
if profile_name:
|
||||
s["profile"] = profile_name
|
||||
s["is_default_profile"] = profile_name == "default"
|
||||
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -2732,7 +2724,7 @@ async def get_profiles_sessions(
|
||||
|
||||
|
||||
@app.get("/api/sessions/search")
|
||||
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
|
||||
async def search_sessions(q: str = "", limit: int = 20):
|
||||
"""Search sessions by ID plus full-text message content using FTS5.
|
||||
|
||||
Direct session-id matches are surfaced first, then FTS message-content
|
||||
@@ -2746,7 +2738,8 @@ async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] =
|
||||
if not q or not q.strip():
|
||||
return {"results": []}
|
||||
try:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
safe_limit = max(1, min(int(limit or 20), 100))
|
||||
|
||||
@@ -2888,8 +2881,6 @@ async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] =
|
||||
return {"results": list(seen.values())}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions/search failed")
|
||||
raise HTTPException(status_code=500, detail="Search failed")
|
||||
@@ -6299,7 +6290,6 @@ def _session_latest_descendant(session_id: str):
|
||||
# reorder this block, move every route in it together.
|
||||
class BulkDeleteSessions(BaseModel):
|
||||
ids: List[str]
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/bulk-delete")
|
||||
@@ -6344,7 +6334,8 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
status_code=400,
|
||||
detail="ids must contain at most 500 entries",
|
||||
)
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_sessions(body.ids)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
@@ -6353,14 +6344,15 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
|
||||
|
||||
@app.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
async def count_empty_sessions_endpoint():
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
||||
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
||||
UI hides the affordance so users aren't presented with a button
|
||||
that does nothing. Cheap, single-COUNT query.
|
||||
"""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
return {"count": db.count_empty_sessions()}
|
||||
finally:
|
||||
@@ -6368,7 +6360,7 @@ async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
|
||||
|
||||
@app.delete("/api/sessions/empty")
|
||||
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
async def delete_empty_sessions_endpoint():
|
||||
"""Delete every empty (``message_count == 0``), ended,
|
||||
non-archived session in a single transaction.
|
||||
|
||||
@@ -6387,7 +6379,8 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
||||
the two delete endpoints' DB-vs-disk behaviour consistent.
|
||||
"""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_empty_sessions()
|
||||
return {"ok": True, "deleted": deleted}
|
||||
@@ -6396,13 +6389,15 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats(profile: Optional[str] = None):
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
@@ -6540,9 +6535,11 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
async def export_session_endpoint(session_id: str):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
@@ -6558,7 +6555,6 @@ async def export_session_endpoint(session_id: str, profile: Optional[str] = None
|
||||
class SessionPrune(BaseModel):
|
||||
older_than_days: int = 90
|
||||
source: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/prune")
|
||||
@@ -6566,10 +6562,11 @@ async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
|
||||
if body.older_than_days < 1:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions_dir = profile_home / "sessions"
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
removed = db.prune_sessions(
|
||||
older_than_days=body.older_than_days,
|
||||
source=(body.source or None),
|
||||
@@ -9615,10 +9612,11 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/analytics/usage")
|
||||
async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
async def get_usage_analytics(days: int = 30):
|
||||
from hermes_state import SessionDB
|
||||
from agent.insights import InsightsEngine
|
||||
|
||||
db = _open_session_db_for_profile(profile)
|
||||
db = SessionDB()
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
cur = db._conn.execute("""
|
||||
@@ -9683,13 +9681,15 @@ async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
|
||||
|
||||
@app.get("/api/analytics/models")
|
||||
async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
async def get_models_analytics(days: int = 30):
|
||||
"""Rich per-model analytics for the Models dashboard page.
|
||||
|
||||
Returns token/cost/session breakdown per model plus capability metadata
|
||||
from models.dev (context window, vision, tools, reasoning, etc.).
|
||||
"""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
@@ -9711,71 +9711,7 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
GROUP BY model, billing_provider
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
raw_rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
# Session rows can be created before the first billable provider call
|
||||
# finishes. If that early row records only the model name, and a later
|
||||
# row for the same model has real accounting + billing_provider, the
|
||||
# Models page used to show a duplicate "0 tokens / — API calls" card
|
||||
# next to the real provider card. Fold those session-only rows into
|
||||
# the single accounted provider row when the ownership is unambiguous.
|
||||
rows_by_model: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for row in raw_rows:
|
||||
rows_by_model.setdefault(row.get("model") or "", []).append(row)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for model_rows in rows_by_model.values():
|
||||
provider_rows = [r for r in model_rows if r.get("billing_provider")]
|
||||
if len(provider_rows) == 1:
|
||||
target = provider_rows[0]
|
||||
for row in model_rows:
|
||||
if row is target or row.get("billing_provider"):
|
||||
continue
|
||||
has_usage = any(
|
||||
(row.get(key) or 0) != 0
|
||||
for key in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"reasoning_tokens",
|
||||
"estimated_cost",
|
||||
"actual_cost",
|
||||
"api_calls",
|
||||
"tool_calls",
|
||||
)
|
||||
)
|
||||
if has_usage:
|
||||
continue
|
||||
target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0)
|
||||
target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0)
|
||||
total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0)
|
||||
sessions = target.get("sessions") or 0
|
||||
target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0
|
||||
rows.append(target)
|
||||
rows.extend(
|
||||
r for r in model_rows
|
||||
if r is not target
|
||||
and (r.get("billing_provider") or any(
|
||||
(r.get(key) or 0) != 0
|
||||
for key in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"reasoning_tokens",
|
||||
"estimated_cost",
|
||||
"actual_cost",
|
||||
"api_calls",
|
||||
"tool_calls",
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
rows.extend(model_rows)
|
||||
|
||||
rows.sort(
|
||||
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
models = []
|
||||
for row in rows:
|
||||
|
||||
@@ -46,7 +46,7 @@ talks to it over loopback.
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start
|
||||
hermes gateway start --platform photon
|
||||
```
|
||||
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
@@ -274,7 +274,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start")
|
||||
print(" Start the gateway: hermes gateway start --platform photon")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1171,20 +1171,6 @@ function Install-Repository {
|
||||
# agent-created dirs (e.g. tinker-atropos/) survive too.
|
||||
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
|
||||
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
|
||||
# A previously interrupted update can leave the index with
|
||||
# unmerged entries. In that state `git stash` aborts with
|
||||
# "could not write index" and the following `git checkout`
|
||||
# aborts with "you need to resolve your current index first"
|
||||
# -- the GUI "git checkout main failed (exit 1)" install
|
||||
# failure. Clear the conflict markers with `git reset` first:
|
||||
# working-tree changes are kept (and stashed just below); only
|
||||
# the index conflict state is dropped. Mirrors the `hermes
|
||||
# update` path (#4735).
|
||||
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
|
||||
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
|
||||
Write-Info "Clearing unmerged index entries from a previous conflict..."
|
||||
git -c windows.appendAtomically=false reset -q 2>$null
|
||||
}
|
||||
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
Write-Info "Local changes detected, stashing before update..."
|
||||
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
|
||||
|
||||
@@ -1111,19 +1111,6 @@ clone_repo() {
|
||||
|
||||
local autostash_ref=""
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
# A previously interrupted update can leave the index with
|
||||
# unmerged entries. In that state `git stash` aborts with
|
||||
# "could not write index" and the later `git checkout` aborts
|
||||
# with "you need to resolve your current index first", failing
|
||||
# the whole install at the repository stage. Clear the conflict
|
||||
# markers with `git reset` first -- this keeps working-tree
|
||||
# changes (they're still stashed just below) and only drops the
|
||||
# index-level conflict state. Mirrors the `hermes update` path
|
||||
# (#4735).
|
||||
if [ -n "$(git ls-files --unmerged)" ]; then
|
||||
log_info "Clearing unmerged index entries from a previous conflict..."
|
||||
git reset -q
|
||||
fi
|
||||
local stash_name
|
||||
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
|
||||
log_info "Local changes detected, stashing before update..."
|
||||
|
||||
@@ -45,11 +45,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"kenmege@yahoo.com": "Kenmege",
|
||||
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
|
||||
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
|
||||
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
|
||||
"zhang.hz6666@gmail.com": "HaozheZhang6",
|
||||
"barronlroth@gmail.com": "barronlroth",
|
||||
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
||||
"tomasz.panek@gmail.com": "tomekpanek",
|
||||
@@ -81,8 +78,6 @@ AUTHOR_MAP = {
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"hbentel@gmail.com": "hbentel",
|
||||
"JustinBao@outlook.com": "justinbao19",
|
||||
"kdunn926@gmail.com": "kdunn926",
|
||||
"mvanhorn@MacBook-Pro.local": "mvanhorn",
|
||||
"470766206@qq.com": "youjunxiaji",
|
||||
|
||||
@@ -1797,11 +1797,6 @@ class TestRegisterSessionMcpServers:
|
||||
state.agent.tools = []
|
||||
state.agent.valid_tool_names = set()
|
||||
state.agent._cached_system_prompt = "old prompt"
|
||||
state.agent._memory_manager = SimpleNamespace(
|
||||
get_all_tool_schemas=lambda: [
|
||||
{"name": "hindsight_recall", "description": "Recall", "parameters": {}}
|
||||
]
|
||||
)
|
||||
|
||||
server = McpServerStdio(
|
||||
name="srv",
|
||||
@@ -1812,7 +1807,6 @@ class TestRegisterSessionMcpServers:
|
||||
|
||||
fake_tools = [
|
||||
{"function": {"name": "mcp_srv_search"}},
|
||||
{"function": {"name": "memory"}},
|
||||
{"function": {"name": "terminal"}},
|
||||
]
|
||||
|
||||
@@ -1826,21 +1820,8 @@ class TestRegisterSessionMcpServers:
|
||||
quiet_mode=True,
|
||||
)
|
||||
assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
|
||||
assert state.agent.tools is fake_tools
|
||||
assert state.agent.tools[-1] == {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "hindsight_recall",
|
||||
"description": "Recall",
|
||||
"parameters": {},
|
||||
},
|
||||
}
|
||||
assert state.agent.valid_tool_names == {
|
||||
"hindsight_recall",
|
||||
"memory",
|
||||
"mcp_srv_search",
|
||||
"terminal",
|
||||
}
|
||||
assert state.agent.tools == fake_tools
|
||||
assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
|
||||
# _invalidate_system_prompt should have been called
|
||||
state.agent._invalidate_system_prompt.assert_called_once()
|
||||
|
||||
|
||||
@@ -161,35 +161,6 @@ class TestResolveAutoMainFirst:
|
||||
assert mock_resolve.call_args.args[0] == "anthropic"
|
||||
assert mock_resolve.call_args.args[1] == "runtime-model"
|
||||
|
||||
def test_runtime_base_url_passed_for_named_api_key_provider(self):
|
||||
"""Named API-key providers inherit the live session endpoint for aux work."""
|
||||
token_plan_url = "https://token-plan-sgp.xiaomimimo.com/v1"
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="config-model",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "mimo-v2.5-pro")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
_resolve_auto(main_runtime={
|
||||
"provider": "xiaomi",
|
||||
"model": "mimo-v2.5-pro",
|
||||
"base_url": token_plan_url,
|
||||
"api_key": "tp-test-key",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
|
||||
assert mock_resolve.call_args.args[0] == "xiaomi"
|
||||
assert mock_resolve.call_args.args[1] == "mimo-v2.5-pro"
|
||||
assert mock_resolve.call_args.kwargs["explicit_base_url"] == token_plan_url
|
||||
assert mock_resolve.call_args.kwargs["explicit_api_key"] == "tp-test-key"
|
||||
assert mock_resolve.call_args.kwargs["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
# ── Vision — resolve_vision_provider_client ─────────────────────────────────
|
||||
|
||||
|
||||
@@ -328,25 +328,6 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
|
||||
|
||||
def test_system_instruction_includes_role_field_and_stays_out_of_contents():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert request["systemInstruction"] == {
|
||||
"role": "system",
|
||||
"parts": [{"text": "You are a helpful assistant."}],
|
||||
}
|
||||
assert all(content.get("role") != "system" for content in request["contents"])
|
||||
|
||||
|
||||
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
|
||||
"""max_tokens=None must send the model's full output ceiling, not omit it.
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
|
||||
from agent.memory_manager import MemoryManager
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete test provider
|
||||
@@ -1321,25 +1320,38 @@ class TestMemoryToolToolsetGate:
|
||||
causing 10x latency on local models (Qwen3-30B: 1.7s → 42s) and
|
||||
tool-call loops on small models.
|
||||
|
||||
These tests exercise the shared gate used by agent init and ACP refreshes.
|
||||
The gate condition is:
|
||||
These tests mirror the gate logic in agent/agent_init.py around the
|
||||
memory provider tool injection block. The gate condition is:
|
||||
|
||||
enabled_toolsets is None → no filter, inject (backward compat)
|
||||
selected toolsets include memory → user opted in, inject
|
||||
"memory" in enabled_toolsets → user opted in, inject
|
||||
otherwise (incl. []) → skip injection
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _run_memory_injection(enabled_toolsets, memory_manager):
|
||||
"""Run the shared memory-tool injection helper against a fake agent."""
|
||||
fake_agent = SimpleNamespace(
|
||||
_memory_manager=memory_manager,
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
tools=[],
|
||||
valid_tool_names=set(),
|
||||
)
|
||||
inject_memory_provider_tools(fake_agent)
|
||||
return fake_agent.tools, fake_agent.valid_tool_names
|
||||
"""Simulate the gated memory-tool injection block from agent_init.py."""
|
||||
tools = []
|
||||
valid_tool_names = set()
|
||||
|
||||
if memory_manager and tools is not None and (
|
||||
enabled_toolsets is None or "memory" in enabled_toolsets
|
||||
):
|
||||
_existing = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing:
|
||||
continue
|
||||
tools.append({"type": "function", "function": _schema})
|
||||
if _tname:
|
||||
valid_tool_names.add(_tname)
|
||||
_existing.add(_tname)
|
||||
|
||||
return tools, valid_tool_names
|
||||
|
||||
def _mgr_with_tools(self, *tool_names):
|
||||
"""Build a MemoryManager whose providers expose the named tool schemas."""
|
||||
@@ -1364,13 +1376,6 @@ class TestMemoryToolToolsetGate:
|
||||
tools, names = self._run_memory_injection(["terminal", "memory", "web"], mgr)
|
||||
assert "fact_store" in names
|
||||
|
||||
def test_composite_toolset_with_memory_injects(self):
|
||||
"""Composite toolsets that include memory should inject provider tools."""
|
||||
mgr = self._mgr_with_tools("hindsight_recall")
|
||||
tools, names = self._run_memory_injection(["hermes-acp"], mgr)
|
||||
assert "hindsight_recall" in names
|
||||
assert any(t["function"]["name"] == "hindsight_recall" for t in tools)
|
||||
|
||||
def test_empty_toolsets_blocks_injection(self):
|
||||
"""`platform_toolsets: telegram: []` must suppress memory tools. (#5544)"""
|
||||
mgr = self._mgr_with_tools("fact_store")
|
||||
@@ -1379,7 +1384,7 @@ class TestMemoryToolToolsetGate:
|
||||
assert names == set()
|
||||
|
||||
def test_toolsets_without_memory_blocks_injection(self):
|
||||
"""Toolsets that don't include memory must suppress injection."""
|
||||
"""Toolset list that doesn't name 'memory' must suppress injection."""
|
||||
mgr = self._mgr_with_tools("fact_store")
|
||||
tools, names = self._run_memory_injection(["terminal", "web"], mgr)
|
||||
assert tools == []
|
||||
|
||||
@@ -397,52 +397,3 @@ class TestEnumNullStripping:
|
||||
assert db_type["type"] == "string"
|
||||
assert db_type["enum"] == ["mysql", "postgresql"], \
|
||||
"null/empty enum values must be stripped after anyOf collapse"
|
||||
|
||||
|
||||
class TestUnionTypeList:
|
||||
"""Moonshot sanitizer accepts JSON Schema union type arrays."""
|
||||
|
||||
def test_union_type_list_normalizes_to_first_concrete_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": ["number", "string"],
|
||||
"description": "Max results",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["limit"]["type"] == "number"
|
||||
|
||||
def test_union_type_list_skips_null_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": ["null", "string"]},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["name"]["type"] == "string"
|
||||
|
||||
def test_union_type_list_with_enum_does_not_crash_or_mutate_input(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["asc", "desc", None, ""],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
sort = out["properties"]["sort"]
|
||||
assert sort["type"] == "string"
|
||||
assert sort["enum"] == ["asc", "desc"]
|
||||
assert params["properties"]["sort"]["type"] == ["string", "null"]
|
||||
|
||||
@@ -877,18 +877,6 @@ class TestPromptBuilderConstants:
|
||||
# check that this test is calibrated correctly).
|
||||
assert "include MEDIA:" in PLATFORM_HINTS["telegram"]
|
||||
|
||||
def test_telegram_hint_encourages_rich_markdown(self):
|
||||
# Telegram Bot API 10.1 rich messages are default-on, so the hint must
|
||||
# encourage native structured markdown instead of forbidding tables.
|
||||
hint = PLATFORM_HINTS["telegram"]
|
||||
lowered = hint.lower()
|
||||
assert "Telegram has NO table syntax" not in hint
|
||||
assert "rich markdown" in lowered
|
||||
assert "table" in lowered
|
||||
assert "task list" in lowered
|
||||
assert "math" in lowered
|
||||
assert "include MEDIA:" in hint
|
||||
|
||||
def test_platform_hints_mattermost(self):
|
||||
hint = PLATFORM_HINTS["mattermost"]
|
||||
assert "Mattermost" in hint
|
||||
|
||||
@@ -71,14 +71,18 @@ class TestForceFullRedraw:
|
||||
"invalidate",
|
||||
]
|
||||
|
||||
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
|
||||
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must NOT erase screen or scrollback.
|
||||
|
||||
prompt_toolkit's built-in Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which uses the renderer's
|
||||
cached cursor position to move back to the live prompt origin before
|
||||
erase_down(). If Hermes resets the renderer first, that cursor position
|
||||
is lost and stale prompt glyphs can remain after a narrow resize.
|
||||
The startup banner lives in normal terminal scrollback (printed
|
||||
before prompt_toolkit owns the chrome). Clearing scrollback on
|
||||
SIGWINCH removes it and ``_replay_output_history`` cannot
|
||||
reconstruct it. The fix is to only reset the renderer cache and
|
||||
let ``original_on_resize`` recalculate layout.
|
||||
|
||||
Additionally, ``_status_bar_suppressed_after_resize`` must be set
|
||||
so the input rules and status bar hide until the next user input,
|
||||
preventing duplicated-bar artifacts on column shrink (#19280).
|
||||
"""
|
||||
app = MagicMock()
|
||||
events = []
|
||||
@@ -90,9 +94,11 @@ class TestForceFullRedraw:
|
||||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
assert events == ["original_resize"]
|
||||
app.renderer.reset.assert_not_called()
|
||||
app.invalidate.assert_not_called()
|
||||
assert events == [
|
||||
"renderer_reset",
|
||||
"invalidate",
|
||||
"original_resize",
|
||||
]
|
||||
# Must NOT clear the screen or scrollback — those destroy the banner.
|
||||
app.renderer.output.erase_screen.assert_not_called()
|
||||
app.renderer.output.write_raw.assert_not_called()
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@@ -105,24 +104,91 @@ class TestCLIStatusBar:
|
||||
assert "-1" not in text
|
||||
assert "0/200K" in text
|
||||
|
||||
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
|
||||
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
|
||||
# is inserted by BeforeInput only on logical line 0. At three terminal
|
||||
# cells, "⚔ " leaves one cell for the first input character, but
|
||||
# wrapped continuation rows use the full three cells. Estimating every
|
||||
# wrapped row as one-cell wide over-allocates the TextArea and can leave
|
||||
# stale prompt/input cells visible after resize.
|
||||
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
|
||||
|
||||
def test_input_height_counts_wide_characters_using_cell_width(self):
|
||||
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
|
||||
# which wraps to two rows at 14 terminal columns.
|
||||
assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2
|
||||
cli_obj = _make_cli()
|
||||
|
||||
def test_input_height_clamps_zero_width_to_one_cell(self):
|
||||
# Some terminals briefly report zero columns during resize. Treat that
|
||||
# as a one-cell terminal rather than falling back to a fake wide width.
|
||||
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
||||
assert _input_height() == 2
|
||||
|
||||
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
assert _input_height() == 2
|
||||
mock_shutil.assert_not_called()
|
||||
|
||||
def test_build_status_bar_text_no_cost_in_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
"""Regression tests for #35809 — compression-exhaustion auto-reset loop.
|
||||
|
||||
After compression is exhausted the gateway auto-resets the session so the
|
||||
next message starts on a fresh, empty conversation (#9893 / #10063). That
|
||||
guarantee regressed once the Telegram topic-binding heal landed
|
||||
(#20470 / #29712 / #33414):
|
||||
|
||||
1. Compression rotates ``session_entry.session_id`` to an oversized
|
||||
compressed *child* session mid-turn and the agent-result sync rewrites
|
||||
the ``(chat_id, thread_id) -> child`` topic binding.
|
||||
2. ``reset_session`` swaps in a clean, parentless session — but its return
|
||||
value was discarded and the topic binding was left pointing at the
|
||||
bloated child.
|
||||
3. On the next inbound message in that topic, the binding-heal walk
|
||||
``switch_session``'d the freshly-reset lane *back* onto the bloated
|
||||
child, ``load_transcript`` reloaded the oversized transcript, and
|
||||
compression exhaustion re-fired — a new session id every loop.
|
||||
|
||||
The fix captures the fresh entry from ``reset_session`` and re-syncs the
|
||||
topic binding to it (a no-op on non-topic lanes).
|
||||
|
||||
Two tests:
|
||||
|
||||
* ``TestAutoResetBlockReSyncsBinding`` — an AST invariant on
|
||||
``gateway/run.py`` (mirrors ``test_compression_session_id_persistence.py``):
|
||||
the compression-exhausted auto-reset block must capture
|
||||
``reset_session(...)`` and call ``_sync_telegram_topic_binding`` afterward.
|
||||
This is the load-bearing regression pin.
|
||||
* ``TestAutoResetLoadsCleanContext`` — a behavioral contract on the real
|
||||
``SessionStore``: after ``reset_session`` the next turn loads an EMPTY
|
||||
transcript for the new session_id, never the bloated child's transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
from gateway import run as gateway_run
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.session import SessionSource, SessionStore
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST invariant: the auto-reset block re-syncs the topic binding
|
||||
# ---------------------------------------------------------------------------
|
||||
def _find_compression_exhausted_reset_block() -> ast.If:
|
||||
"""Return the ``if agent_result.get('compression_exhausted') ...`` block."""
|
||||
tree = ast.parse(inspect.getsource(gateway_run))
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
consts = [
|
||||
n.value
|
||||
for n in ast.walk(node.test)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, str)
|
||||
]
|
||||
# Identify the auto-reset branch by the literal passed to .get(...).
|
||||
if "compression_exhausted" in consts:
|
||||
# Only the branch that actually performs the reset, not the
|
||||
# earlier classifier that merely reads the flag into a bool.
|
||||
calls = {
|
||||
sub.func.attr
|
||||
for sub in ast.walk(node)
|
||||
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
|
||||
}
|
||||
if "reset_session" in calls:
|
||||
return node
|
||||
raise AssertionError(
|
||||
"Could not locate the compression-exhausted auto-reset block "
|
||||
"(if agent_result.get('compression_exhausted') ... reset_session) "
|
||||
"in gateway/run.py — the structure changed or the AST walker is stale."
|
||||
)
|
||||
|
||||
|
||||
class TestAutoResetBlockReSyncsBinding:
|
||||
def test_reset_session_return_is_captured(self):
|
||||
"""``reset_session`` must be assigned, not called-and-discarded —
|
||||
the fresh entry is needed to re-point the binding and drop the stale
|
||||
reference to the bloated compressed child (#35809)."""
|
||||
block = _find_compression_exhausted_reset_block()
|
||||
captured = False
|
||||
for stmt in ast.walk(block):
|
||||
if isinstance(stmt, ast.Assign):
|
||||
val = stmt.value
|
||||
if (
|
||||
isinstance(val, ast.Call)
|
||||
and isinstance(val.func, ast.Attribute)
|
||||
and val.func.attr == "reset_session"
|
||||
):
|
||||
captured = True
|
||||
assert captured, (
|
||||
"gateway/run.py auto-reset block calls reset_session() but discards "
|
||||
"its return value. The fresh SessionEntry must be captured so the "
|
||||
"topic binding can be re-pointed at it; otherwise the next message "
|
||||
"resolves back to the bloated compressed child (#35809)."
|
||||
)
|
||||
|
||||
def test_topic_binding_is_resynced_after_reset(self):
|
||||
"""The block must re-sync the topic binding so the next inbound message
|
||||
cannot ``switch_session`` back onto the bloated compressed child."""
|
||||
block = _find_compression_exhausted_reset_block()
|
||||
sync_calls = [
|
||||
sub
|
||||
for sub in ast.walk(block)
|
||||
if isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "_sync_telegram_topic_binding"
|
||||
]
|
||||
assert sync_calls, (
|
||||
"gateway/run.py auto-reset block does not call "
|
||||
"_sync_telegram_topic_binding after reset_session. Without it the "
|
||||
"(chat_id, thread_id) -> bloated-child binding survives the reset "
|
||||
"and the binding-heal walk re-anchors the fresh lane onto the "
|
||||
"oversized compressed transcript, re-triggering the loop (#35809)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Behavioral contract: reset yields a clean next-turn transcript
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_store(tmp_path):
|
||||
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
|
||||
# Isolate the SQLite transcript store so we exercise per-session_id
|
||||
# transcripts without touching the developer's real state.db.
|
||||
store._db = SessionDB(db_path=tmp_path / "state.db")
|
||||
return store
|
||||
|
||||
|
||||
def _make_source():
|
||||
return SessionSource(platform=Platform.TELEGRAM, chat_id="123", user_id="u1")
|
||||
|
||||
|
||||
def _bloat(n):
|
||||
# Stand-in for the oversized, post-compression "child" transcript that
|
||||
# could not be compressed any further (#35809).
|
||||
return [{"role": "user", "content": "x" * 2000} for _ in range(n)]
|
||||
|
||||
|
||||
class TestAutoResetLoadsCleanContext:
|
||||
"""#35809: after the gateway auto-resets a session because compression
|
||||
was exhausted, the NEXT turn must load an EMPTY transcript for the new
|
||||
session_id — never the bloated compressed-child transcript."""
|
||||
|
||||
def test_next_turn_transcript_is_empty_after_auto_reset(self, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
|
||||
entry = store.get_or_create_session(source)
|
||||
session_key = entry.session_key
|
||||
bloated_sid = entry.session_id
|
||||
store._db.create_session(
|
||||
session_id=bloated_sid, source="telegram", user_id="u1"
|
||||
)
|
||||
store._db.replace_messages(bloated_sid, _bloat(120))
|
||||
assert len(store.load_transcript(bloated_sid)) == 120 # precondition
|
||||
|
||||
new_entry = store.reset_session(session_key)
|
||||
assert new_entry is not None
|
||||
assert new_entry.session_id != bloated_sid
|
||||
|
||||
resolved = store.get_or_create_session(source)
|
||||
assert resolved.session_id == new_entry.session_id
|
||||
loaded = store.load_transcript(resolved.session_id)
|
||||
|
||||
assert loaded == [], (
|
||||
f"Auto-reset must yield an empty context, got {len(loaded)} "
|
||||
f"messages — the bloated compressed child leaked into the new session."
|
||||
)
|
||||
# The old transcript is still searchable, not destroyed.
|
||||
assert len(store.load_transcript(bloated_sid)) == 120
|
||||
|
||||
def test_clean_context_survives_gateway_restart(self, tmp_path):
|
||||
"""The fresh, empty session must still be the one loaded after a
|
||||
gateway restart (sessions.json + state.db round-trip)."""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
entry = store.get_or_create_session(source)
|
||||
bloated_sid = entry.session_id
|
||||
store._db.create_session(
|
||||
session_id=bloated_sid, source="telegram", user_id="u1"
|
||||
)
|
||||
store._db.replace_messages(bloated_sid, _bloat(120))
|
||||
|
||||
new_entry = store.reset_session(entry.session_key)
|
||||
new_sid = new_entry.session_id
|
||||
|
||||
# Simulate restart: drop in-memory index, reload from disk.
|
||||
store._loaded = False
|
||||
store._entries.clear()
|
||||
|
||||
reloaded = store.get_or_create_session(source)
|
||||
assert reloaded.session_id == new_sid
|
||||
assert store.load_transcript(reloaded.session_id) == []
|
||||
@@ -13,14 +13,12 @@ from gateway.session import SessionSource
|
||||
SESSION_KEY = "agent:main:telegram:dm:12345"
|
||||
|
||||
|
||||
class _SessionStore:
|
||||
class _SaveTrackingSessionStore:
|
||||
def __init__(self):
|
||||
self.entry = SimpleNamespace(
|
||||
session_key=SESSION_KEY,
|
||||
session_id="session-before-compression",
|
||||
)
|
||||
self.entry = SimpleNamespace(session_id="session-before-compression")
|
||||
self._entries = {SESSION_KEY: self.entry}
|
||||
self.save_calls = 0
|
||||
self.topic_sync_calls = []
|
||||
|
||||
def _save(self):
|
||||
self.save_calls += 1
|
||||
@@ -38,13 +36,22 @@ class _CompressionThenFailureAgent:
|
||||
self.session_prompt_tokens = 4321
|
||||
self.session_completion_tokens = 0
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, **_kwargs):
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None):
|
||||
self.session_id = "session-after-compression"
|
||||
return {
|
||||
"failed": True,
|
||||
"error": "APIConnectionError: Codex auxiliary Responses stream exceeded 120.0s total timeout",
|
||||
"error": (
|
||||
"APIConnectionError: Codex auxiliary Responses stream exceeded "
|
||||
"120.0s total timeout"
|
||||
),
|
||||
"messages": [
|
||||
{"role": "user", "content": "[compressed summary]"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[Context compressed: previous long transcript was "
|
||||
"summarized before retry]"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"api_calls": 1,
|
||||
@@ -54,7 +61,7 @@ class _CompressionThenFailureAgent:
|
||||
pass
|
||||
|
||||
|
||||
class _StreamConsumer:
|
||||
class _ImmediateStreamConsumer:
|
||||
final_response_sent = False
|
||||
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
@@ -67,87 +74,116 @@ class _StreamConsumer:
|
||||
pass
|
||||
|
||||
|
||||
class _Adapter:
|
||||
class _QuietAdapter:
|
||||
SUPPORTS_MESSAGE_EDITING = True
|
||||
_pending_messages = {}
|
||||
|
||||
def get_pending_message(self, _session_key):
|
||||
return None
|
||||
|
||||
async def send_typing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def stop_typing(self, *_args, **_kwargs):
|
||||
return None
|
||||
def _install_fake_agent(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CompressionThenFailureAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
|
||||
def _runner(session_store):
|
||||
def _make_runner(session_store):
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {Platform.TELEGRAM: _Adapter()}
|
||||
runner.config = SimpleNamespace(streaming=None, group_sessions_per_user=True, thread_sessions_per_user=False)
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False, emit=AsyncMock())
|
||||
runner.session_store = session_store
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_telegram_topic_binding_by_session.return_value = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._pending_skills_reload_notes = {}
|
||||
runner._prefill_messages = []
|
||||
runner.adapters = {
|
||||
Platform.TELEGRAM: _QuietAdapter(),
|
||||
}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._pending_skills_reload_notes = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._session_model_overrides = {}
|
||||
runner._draining = False
|
||||
runner.config = SimpleNamespace(streaming=None)
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False, emit=AsyncMock())
|
||||
runner.session_store = session_store
|
||||
runner._get_proxy_url = lambda: None
|
||||
runner._resolve_session_agent_runtime = lambda **_kwargs: (
|
||||
"gpt-5.4",
|
||||
{"provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", "api_key": "token"},
|
||||
{
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "token",
|
||||
},
|
||||
)
|
||||
runner._resolve_session_reasoning_config = lambda **_kwargs: None
|
||||
runner._resolve_turn_agent_config = lambda message, model, runtime: {"model": model, "runtime": runtime}
|
||||
runner._resolve_turn_agent_config = lambda message, model, runtime: {
|
||||
"model": model,
|
||||
"runtime": runtime,
|
||||
}
|
||||
runner._load_service_tier = lambda: None
|
||||
runner._agent_config_signature = lambda *_args, **_kwargs: ("sig",)
|
||||
runner._extract_cache_busting_config = lambda _config: ()
|
||||
runner._thread_metadata_for_source = lambda *_args, **_kwargs: None
|
||||
runner._sync_telegram_topic_binding = MagicMock()
|
||||
runner._is_telegram_topic_lane = lambda _source: False
|
||||
runner._sync_telegram_topic_binding = lambda source, entry, *, reason: session_store.topic_sync_calls.append(
|
||||
(source, entry.session_id, reason)
|
||||
)
|
||||
runner._release_running_agent_state = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
def test_failed_turn_still_syncs_compression_session_split(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CompressionThenFailureAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
"""A post-compression API failure must not leave the session store on the
|
||||
stale pre-compression transcript.
|
||||
"""
|
||||
_install_fake_agent(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
|
||||
monkeypatch.setenv("HERMES_AGENT_TIMEOUT", "0")
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr("gateway.stream_consumer.GatewayStreamConsumer", _StreamConsumer)
|
||||
monkeypatch.setattr(
|
||||
"gateway.stream_consumer.GatewayStreamConsumer",
|
||||
_ImmediateStreamConsumer,
|
||||
)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda *_args, **_kwargs: {"core"})
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"_get_platform_tools",
|
||||
lambda *_args, **_kwargs: {"core"},
|
||||
)
|
||||
|
||||
session_store = _SessionStore()
|
||||
runner = _runner(session_store)
|
||||
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="user-1")
|
||||
session_store = _SaveTrackingSessionStore()
|
||||
runner = _make_runner(session_store)
|
||||
|
||||
result = asyncio.run(
|
||||
asyncio.wait_for(
|
||||
runner._run_agent(
|
||||
message="continue",
|
||||
context_prompt="",
|
||||
history=[{"role": "user", "content": "old question"}],
|
||||
source=source,
|
||||
history=[
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
],
|
||||
source=_source(),
|
||||
session_id="session-before-compression",
|
||||
session_key=SESSION_KEY,
|
||||
),
|
||||
timeout=2,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert result["failed"] is True
|
||||
@@ -155,6 +191,6 @@ def test_failed_turn_still_syncs_compression_session_split(monkeypatch):
|
||||
assert result["history_offset"] == 0
|
||||
assert session_store.entry.session_id == "session-after-compression"
|
||||
assert session_store.save_calls == 1
|
||||
runner._sync_telegram_topic_binding.assert_called_once_with(
|
||||
source, session_store.entry, reason="agent-run-compression"
|
||||
)
|
||||
assert session_store.topic_sync_calls == [
|
||||
(_source(), "session-after-compression", "agent-result-compression")
|
||||
]
|
||||
|
||||
@@ -27,8 +27,17 @@ RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n-
|
||||
|
||||
|
||||
def _make_adapter(extra=None):
|
||||
"""Build a TelegramAdapter with a mock bot wired for the rich path."""
|
||||
config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {})
|
||||
"""Build a TelegramAdapter with a mock bot wired for the rich path.
|
||||
|
||||
Rich messages are opt-in (default off) while the Bot API 10.1 endpoint
|
||||
is validated live, so tests that exercise the rich path enable it
|
||||
explicitly here; opt-out tests pass their own ``extra``.
|
||||
"""
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="fake-token",
|
||||
extra={"rich_messages": True} if extra is None else extra,
|
||||
)
|
||||
adapter = TelegramAdapter(config)
|
||||
bot = MagicMock()
|
||||
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
|
||||
@@ -67,16 +76,24 @@ async def test_rich_happy_path_sends_raw_markdown():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_rich_messages_config_is_ignored():
|
||||
async def test_rich_opt_out_uses_legacy():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
|
||||
result = await adapter.send("12345", RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
# The legacy toggle was removed; stale config entries must not disable the
|
||||
# rich path.
|
||||
adapter._bot.do_api_request.assert_awaited_once()
|
||||
adapter._bot.send_message.assert_not_called()
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_opt_out_accepts_string_false():
|
||||
adapter = _make_adapter(extra={"rich_messages": "false"})
|
||||
|
||||
await adapter.send("12345", RICH_CONTENT)
|
||||
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -248,9 +265,13 @@ async def test_notification_opt_in_drops_disable_flag():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_gate_tolerates_minimal_bot_without_raw_endpoint():
|
||||
"""A bot without an async do_api_request falls through to the legacy path."""
|
||||
async def test_rich_gate_tolerates_missing_enabled_attr():
|
||||
"""Adapters missing _rich_messages_enabled (object.__new__ in some tests)
|
||||
must not raise — the gate reads it via getattr(default=True), and a bot
|
||||
without an async do_api_request falls through to the legacy path."""
|
||||
adapter = _make_adapter()
|
||||
del adapter._rich_messages_enabled # simulate object.__new__ construction
|
||||
# SimpleNamespace bot has no do_api_request -> _bot_supports_rich() False.
|
||||
adapter._bot = SimpleNamespace(
|
||||
send_message=AsyncMock(return_value=SimpleNamespace(message_id=42)),
|
||||
send_chat_action=AsyncMock(),
|
||||
@@ -316,6 +337,17 @@ async def test_rich_draft_transient_failure_does_not_latch_off():
|
||||
assert adapter._rich_draft_disabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_draft_opt_out_uses_legacy():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
|
||||
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message_draft.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_draft_oversized_uses_legacy():
|
||||
adapter = _make_adapter()
|
||||
|
||||
@@ -76,7 +76,6 @@ def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkey
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
|
||||
|
||||
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
|
||||
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
|
||||
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
|
||||
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
|
||||
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
|
||||
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
|
||||
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
|
||||
ones (e.g. 429 quota, where the stored token is still valid).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
|
||||
|
||||
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
|
||||
|
||||
|
||||
def test_self_heals_on_stale_refresh_token(monkeypatch):
|
||||
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
|
||||
saved = {}
|
||||
fresh = {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"last_refresh": "2026-06-12T00:00:00Z",
|
||||
}
|
||||
|
||||
def _rejected(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token rejected",
|
||||
provider="openai-codex",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
out = _refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert out["access_token"] == "fresh-access"
|
||||
assert out["refresh_token"] == "fresh-refresh"
|
||||
# the recovered token was persisted to the Hermes auth store
|
||||
assert saved["access_token"] == "fresh-access"
|
||||
|
||||
|
||||
def test_does_not_self_heal_on_rate_limit(monkeypatch):
|
||||
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
|
||||
import_calls = {"n": 0}
|
||||
|
||||
def _rate_limited(*_a, **_k):
|
||||
raise AuthError(
|
||||
"quota exhausted",
|
||||
provider="openai-codex",
|
||||
code="codex_rate_limited",
|
||||
relogin_required=False,
|
||||
)
|
||||
|
||||
def _import_spy():
|
||||
import_calls["n"] += 1
|
||||
return {"access_token": "should-not-be-used"}
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "codex_rate_limited"
|
||||
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure
|
||||
|
||||
|
||||
def test_reraises_when_codex_cli_token_absent(monkeypatch):
|
||||
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""
|
||||
|
||||
def _reused(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token reused",
|
||||
provider="openai-codex",
|
||||
code="refresh_token_reused",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "refresh_token_reused"
|
||||
|
||||
|
||||
def test_happy_path_unchanged(monkeypatch):
|
||||
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
|
||||
saved = {}
|
||||
import_calls = {"n": 0}
|
||||
|
||||
def _import_spy():
|
||||
import_calls["n"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"refresh_codex_oauth_pure",
|
||||
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
|
||||
)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)
|
||||
|
||||
assert out["access_token"] == "rotated"
|
||||
assert out["refresh_token"] == "rotated-r"
|
||||
assert saved["access_token"] == "rotated"
|
||||
assert import_calls["n"] == 0 # happy path must not consult ~/.codex
|
||||
|
||||
|
||||
def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
|
||||
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
|
||||
re-raise rather than persist a half-token that would break the next refresh."""
|
||||
saved = {}
|
||||
|
||||
def _rejected(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token rejected",
|
||||
provider="openai-codex",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "invalid_grant"
|
||||
assert saved == {} # nothing was persisted
|
||||
|
||||
|
||||
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
|
||||
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex"
|
||||
hermes_home.mkdir()
|
||||
codex_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"refresh_token": "stale-refresh"},
|
||||
"last_refresh": "2026-06-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}))
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
|
||||
assert resolved["api_key"] == "fresh-access"
|
||||
assert resolved["source"] == "hermes-auth-store"
|
||||
stored = json.loads((hermes_home / "auth.json").read_text())
|
||||
tokens = stored["providers"]["openai-codex"]["tokens"]
|
||||
assert tokens["access_token"] == "fresh-access"
|
||||
assert tokens["refresh_token"] == "fresh-refresh"
|
||||
|
||||
|
||||
def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
|
||||
"""Missing access_token must not be masked by a malformed Codex CLI import."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex"
|
||||
hermes_home.mkdir()
|
||||
codex_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"refresh_token": "stale-refresh"},
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}))
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {"access_token": "fresh-only"},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
resolve_codex_runtime_credentials()
|
||||
|
||||
assert ei.value.code == "codex_auth_missing_access_token"
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Unit tests for find_custom_provider_identity (base_url → custom:<name>).
|
||||
|
||||
Reverse lookup used by tui_gateway session persistence to recover a named
|
||||
``providers:`` / ``custom_providers:`` entry from the only durable fact the
|
||||
session row keeps once the provider has been resolved to the literal string
|
||||
"custom": the endpoint URL. See
|
||||
tests/tui_gateway/test_custom_provider_session_persistence.py for the
|
||||
end-to-end persist/resume round-trip.
|
||||
"""
|
||||
|
||||
import hermes_cli.runtime_provider as rp
|
||||
|
||||
|
||||
def test_matches_legacy_custom_providers_list(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "MiMo v2.5 Pro", "base_url": "https://api.mimo.example/v1"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("https://api.mimo.example/v1")
|
||||
== "custom:mimo-v2.5-pro"
|
||||
)
|
||||
|
||||
|
||||
def test_matches_providers_dict_by_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
|
||||
== "custom:local"
|
||||
)
|
||||
|
||||
|
||||
def test_match_ignores_trailing_slash_and_case(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("http://localhost:8000/v1")
|
||||
== "custom:local"
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "other", "base_url": "https://elsewhere.example/v1"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
|
||||
|
||||
|
||||
def test_empty_base_url_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
|
||||
)
|
||||
assert rp.find_custom_provider_identity("") is None
|
||||
assert rp.find_custom_provider_identity(None) is None
|
||||
|
||||
|
||||
def test_identity_resolves_back_through_named_lookup(monkeypatch):
|
||||
"""The returned slug must be accepted by _get_named_custom_provider —
|
||||
that is the whole point of persisting it."""
|
||||
config = {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "mimo-v2.5-pro",
|
||||
"base_url": "https://api.mimo.example/v1",
|
||||
"api_key": "sk-entry",
|
||||
}
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(rp, "load_config", lambda: config)
|
||||
|
||||
slug = rp.find_custom_provider_identity("https://api.mimo.example/v1")
|
||||
assert slug == "custom:mimo-v2.5-pro"
|
||||
|
||||
entry = rp._get_named_custom_provider(slug)
|
||||
assert entry is not None
|
||||
assert entry["base_url"] == "https://api.mimo.example/v1"
|
||||
assert entry["api_key"] == "sk-entry"
|
||||
@@ -1406,72 +1406,3 @@ class TestDoctorStaleMaxIterationsDrift:
|
||||
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
|
||||
)
|
||||
assert "shadows" not in out
|
||||
|
||||
|
||||
def test_npm_audit_fix_hint_avoids_crashing_workspace_flag(monkeypatch, tmp_path):
|
||||
"""`hermes doctor` must not hand users `npm audit fix --workspace <name>`:
|
||||
that exact form crashes npm with "Cannot read properties of null (reading
|
||||
'edgesOut')" (an arborist bug with workspace-filtered audit fix).
|
||||
|
||||
It must not recommend root-level `npm audit fix` for workspace advisories
|
||||
either: current npm can crash there too with "Cannot read properties of null
|
||||
(reading 'isDescendantOf')" on this tree. The safe guidance is that these
|
||||
build-tool advisories clear via the lockfile/package bump.
|
||||
|
||||
Regression for user reports where doctor flagged the web/ui-tui workspaces
|
||||
and the suggested fix command errored out.
|
||||
"""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
project = tmp_path / "project"
|
||||
(project / "node_modules").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
|
||||
# Only npm is "installed" — keeps the rest of run_doctor's external checks
|
||||
# quiet without affecting the npm-audit branch under test.
|
||||
monkeypatch.setattr(
|
||||
doctor_mod.shutil, "which", lambda cmd: "/usr/bin/npm" if cmd == "npm" else None
|
||||
)
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
if "audit" in cmd and "--workspace" in cmd:
|
||||
payload = (
|
||||
'{"metadata": {"vulnerabilities": '
|
||||
'{"critical": 0, "high": 2, "moderate": 0}}}'
|
||||
)
|
||||
return SimpleNamespace(returncode=1, stdout=payload, stderr="")
|
||||
if "audit" in cmd:
|
||||
payload = (
|
||||
'{"metadata": {"vulnerabilities": '
|
||||
'{"critical": 0, "high": 0, "moderate": 0}}}'
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout=payload, stderr="")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
# The workspace vulnerability is still reported ...
|
||||
assert "web workspace" in out
|
||||
# ... but the remediation must NOT use the npm-crashing per-workspace form
|
||||
# (`npm audit fix --workspace web` / `--workspace ui-tui`).
|
||||
assert "npm audit fix --workspace web" not in out
|
||||
assert "npm audit fix --workspace ui-tui" not in out
|
||||
# ... and it must not point at the root-level form either: npm can crash
|
||||
# there too with `isDescendantOf` on this monorepo tree.
|
||||
assert "npm audit fix" not in out
|
||||
# ... and explains the workspace advisories are build-time tooling whose
|
||||
# manual remediation may hit a known npm arborist crash, so the user isn't
|
||||
# left thinking a crashing command means a broken Hermes install.
|
||||
assert "build-time tooling" in out
|
||||
assert "known npm bug" in out
|
||||
assert "lockfile bump" in out
|
||||
|
||||
@@ -274,20 +274,6 @@ def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkey
|
||||
assert calls == [False]
|
||||
|
||||
|
||||
def test_gateway_start_ignores_legacy_platform_selector(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(system))
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False, all=False, platform="photon")
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [False]
|
||||
|
||||
|
||||
def test_gateway_restart_on_windows_without_service_uses_detached_backend(monkeypatch):
|
||||
"""Windows manual restart must not fall back to foreground run_gateway().
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def _make_task(kb, *, assignee: str):
|
||||
return kb.Task(
|
||||
id="t_spawn_tools",
|
||||
title="spawn tools",
|
||||
body=None,
|
||||
assignee=assignee,
|
||||
status="running",
|
||||
priority=0,
|
||||
created_by="test",
|
||||
created_at=1,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="dir",
|
||||
workspace_path=None,
|
||||
claim_lock="lock",
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
current_run_id=7,
|
||||
)
|
||||
|
||||
|
||||
def test_default_spawn_pins_assignee_profile_cli_toolsets(monkeypatch, tmp_path):
|
||||
"""Manual profile assignment should keep that profile's CLI tools.
|
||||
|
||||
Regression guard for dispatcher-spawned workers that boot with
|
||||
HERMES_KANBAN_TASK: the worker must not collapse to only kanban lifecycle
|
||||
tools when the assigned profile's top-level ``toolsets`` is the default
|
||||
composite. The spawned CLI gets an explicit --toolsets pin resolved from
|
||||
platform_toolsets.cli; model_tools appends task-scoped kanban tools later.
|
||||
"""
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "elias"
|
||||
profile.mkdir(parents=True)
|
||||
profile.joinpath("config.yaml").write_text(
|
||||
"""
|
||||
platform_toolsets:
|
||||
cli:
|
||||
- clarify
|
||||
- code_execution
|
||||
- delegation
|
||||
- file
|
||||
- memory
|
||||
- session_search
|
||||
- skills
|
||||
- terminal
|
||||
- web
|
||||
toolsets:
|
||||
- hermes-cli
|
||||
agent:
|
||||
disabled_toolsets: []
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
root.joinpath("config.yaml").write_text("toolsets:\n - kanban\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
captured["cmd"] = list(cmd)
|
||||
captured["env"] = dict(kwargs.get("env") or {})
|
||||
captured["cwd"] = kwargs.get("cwd")
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
|
||||
|
||||
assert pid == 4242
|
||||
assert captured["env"]["HERMES_HOME"] == str(profile)
|
||||
assert captured["env"]["HERMES_KANBAN_TASK"] == "t_spawn_tools"
|
||||
assert "--toolsets" in captured["cmd"]
|
||||
pinned = captured["cmd"][captured["cmd"].index("--toolsets") + 1].split(",")
|
||||
for required in ("terminal", "web", "file", "skills", "code_execution", "delegation"):
|
||||
assert required in pinned
|
||||
|
||||
|
||||
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "elias"
|
||||
profile.mkdir(parents=True)
|
||||
root.joinpath("config.yaml").write_text("platform_toolsets:\n cli:\n - kanban\n", encoding="utf-8")
|
||||
profile.joinpath("config.yaml").write_text(
|
||||
"""
|
||||
platform_toolsets:
|
||||
cli:
|
||||
- terminal
|
||||
- web
|
||||
toolsets:
|
||||
- hermes-cli
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
resolved = kb._resolve_worker_cli_toolsets(str(profile))
|
||||
|
||||
assert resolved is not None
|
||||
assert "terminal" in resolved
|
||||
assert "web" in resolved
|
||||
assert "kanban" in resolved # recovered worker lifecycle surface
|
||||
assert resolved != ["kanban"]
|
||||
@@ -65,61 +65,6 @@ def test_resolve_provider_full_finds_named_custom_provider():
|
||||
assert resolved.source == "user-config"
|
||||
|
||||
|
||||
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
|
||||
"""Bare model.provider=custom + model.base_url should still populate /model.
|
||||
|
||||
Users can configure a one-off OpenAI-compatible endpoint directly under
|
||||
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
|
||||
The gateway picker receives only the current model/base_url slice, so it
|
||||
must surface that active endpoint rather than looking like config was
|
||||
ignored.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
current_base_url="https://www.ccsub.net/v1",
|
||||
current_model="gpt-4o",
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
|
||||
assert bare_custom is not None
|
||||
assert bare_custom["name"] == "Custom endpoint"
|
||||
assert bare_custom["is_current"] is True
|
||||
assert bare_custom["is_user_defined"] is True
|
||||
assert bare_custom["models"] == ["gpt-4o"]
|
||||
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
|
||||
|
||||
|
||||
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
|
||||
"""Picker selections for bare custom endpoints should route to current base_url."""
|
||||
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
|
||||
|
||||
result = switch_model(
|
||||
raw_input="gpt-4o-mini",
|
||||
current_provider="custom",
|
||||
current_model="gpt-4o",
|
||||
current_base_url="https://www.ccsub.net/v1",
|
||||
current_api_key="sk-test",
|
||||
explicit_provider="custom",
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.target_provider == "custom"
|
||||
assert result.provider_label == "Custom endpoint"
|
||||
assert result.new_model == "gpt-4o-mini"
|
||||
assert result.base_url == "https://www.ccsub.net/v1"
|
||||
assert result.api_key == "sk-test"
|
||||
|
||||
|
||||
def test_is_aggregator_recognizes_named_custom_provider():
|
||||
assert providers_mod.is_aggregator("custom:hpc-ai") is True
|
||||
assert providers_mod.is_aggregator("custom:litellm") is True
|
||||
|
||||
@@ -81,12 +81,3 @@ def test_gateway_accept_hooks_flag():
|
||||
p = _gateway_parser()
|
||||
ns = p.parse_args(["gateway", "run", "--accept-hooks"])
|
||||
assert ns.accept_hooks is True
|
||||
|
||||
|
||||
def test_gateway_lifecycle_accepts_legacy_platform_flag():
|
||||
p = _gateway_parser()
|
||||
for action in ("start", "restart", "status"):
|
||||
ns = p.parse_args(["gateway", action, "--platform", "photon"])
|
||||
assert ns.gateway_command == action
|
||||
assert ns.platform == "photon"
|
||||
assert ns.func is _h_gateway
|
||||
|
||||
@@ -520,87 +520,6 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.get("/api/profiles/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_sessions_endpoint_reads_requested_profile(self):
|
||||
"""The machine dashboard's global profile switcher must retarget
|
||||
the Sessions page, not just config/skills/model pages."""
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
worker_home = profiles_mod.get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True)
|
||||
|
||||
default_db = SessionDB()
|
||||
try:
|
||||
default_db.create_session(session_id="default-only", source="cli")
|
||||
default_db.append_message("default-only", role="user", content="default")
|
||||
finally:
|
||||
default_db.close()
|
||||
|
||||
worker_db = SessionDB(db_path=worker_home / "state.db")
|
||||
try:
|
||||
worker_db.create_session(session_id="worker-only", source="cli")
|
||||
worker_db.append_message("worker-only", role="user", content="worker")
|
||||
finally:
|
||||
worker_db.close()
|
||||
|
||||
resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
ids = {s["id"] for s in data["sessions"]}
|
||||
assert "worker-only" in ids
|
||||
assert "default-only" not in ids
|
||||
row = next(s for s in data["sessions"] if s["id"] == "worker-only")
|
||||
assert row["profile"] == "worker"
|
||||
assert row["is_default_profile"] is False
|
||||
|
||||
stats = self.client.get("/api/sessions/stats?profile=worker").json()
|
||||
assert stats["total"] == 1
|
||||
assert stats["messages"] == 1
|
||||
|
||||
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
|
||||
assert [m["content"] for m in messages["messages"]] == ["worker"]
|
||||
|
||||
def test_analytics_endpoints_read_requested_profile(self):
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
worker_home = profiles_mod.get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True)
|
||||
|
||||
default_db = SessionDB()
|
||||
try:
|
||||
default_db.create_session(session_id="default-usage", source="cli", model="default/model")
|
||||
default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5)
|
||||
finally:
|
||||
default_db.close()
|
||||
|
||||
worker_db = SessionDB(db_path=worker_home / "state.db")
|
||||
try:
|
||||
worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model")
|
||||
worker_db.update_token_counts(
|
||||
"worker-usage",
|
||||
input_tokens=123,
|
||||
output_tokens=45,
|
||||
billing_provider="worker-provider",
|
||||
)
|
||||
finally:
|
||||
worker_db.close()
|
||||
|
||||
usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json()
|
||||
assert usage["totals"]["total_sessions"] == 1
|
||||
assert usage["totals"]["total_input"] == 123
|
||||
assert [m["model"] for m in usage["by_model"]] == ["worker/model"]
|
||||
|
||||
models = self.client.get("/api/analytics/models?days=7&profile=worker").json()
|
||||
assert models["totals"]["distinct_models"] == 1
|
||||
assert models["totals"]["total_input"] == 123
|
||||
assert models["models"][0]["model"] == "worker/model"
|
||||
assert models["models"][0]["provider"] == "worker-provider"
|
||||
|
||||
default_usage = self.client.get("/api/analytics/usage?days=7").json()
|
||||
assert default_usage["totals"]["total_input"] == 10
|
||||
assert default_usage["totals"]["total_output"] == 5
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
@@ -3277,56 +3196,6 @@ class TestNewEndpoints:
|
||||
"top_skills": [],
|
||||
}
|
||||
|
||||
def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(self):
|
||||
"""Session-only model rows should not render as duplicate zero-token cards.
|
||||
|
||||
Direct-provider-on-OpenRouter sessions can leave one row with only
|
||||
``model`` populated and another row with token/API accounting plus
|
||||
``billing_provider``. The Models dashboard should show one provider
|
||||
card, not a real card plus a misleading duplicate empty card.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(
|
||||
session_id="deepseek-session-only",
|
||||
source="cli",
|
||||
model="deepseek/deepseek-v4-flash",
|
||||
)
|
||||
db.create_session(
|
||||
session_id="deepseek-accounted",
|
||||
source="cli",
|
||||
model="deepseek/deepseek-v4-flash",
|
||||
)
|
||||
db.update_token_counts(
|
||||
"deepseek-accounted",
|
||||
input_tokens=20_000,
|
||||
output_tokens=7_100,
|
||||
billing_provider="openrouter",
|
||||
api_call_count=9,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.get("/api/analytics/models?days=7")
|
||||
assert resp.status_code == 200
|
||||
|
||||
models = resp.json()["models"]
|
||||
deepseek_rows = [
|
||||
row for row in models
|
||||
if row["model"] == "deepseek/deepseek-v4-flash"
|
||||
]
|
||||
|
||||
assert len(deepseek_rows) == 1
|
||||
row = deepseek_rows[0]
|
||||
assert row["provider"] == "openrouter"
|
||||
assert row["sessions"] == 2
|
||||
assert row["input_tokens"] == 20_000
|
||||
assert row["output_tokens"] == 7_100
|
||||
assert row["api_calls"] == 9
|
||||
assert row["avg_tokens_per_session"] == 13_550
|
||||
|
||||
def test_analytics_usage_includes_skill_breakdown(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ never clobbers a hand-tuned allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
@@ -69,44 +67,3 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Home",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"ensure_spectrum_enabled",
|
||||
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "secret_123",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+15551234567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Start the gateway: hermes gateway start" in out
|
||||
assert "--platform photon" not in out
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Regression: installer fails when the existing checkout has an unmerged index.
|
||||
|
||||
A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index
|
||||
entries (files in a conflicted, "needs merge" state). In that state the update
|
||||
path's ``git stash`` aborts with "could not write index" and the following
|
||||
``git checkout <branch>`` aborts with "you need to resolve your current index
|
||||
first" -- surfacing to GUI/bootstrap users as ``git checkout main failed
|
||||
(exit 1)`` and failing the whole install at the repository stage.
|
||||
|
||||
The ``hermes update`` Python path already clears the conflict with ``git reset``
|
||||
before stashing (#4735); both installer scripts must do the same.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("git") is None or shutil.which("bash") is None,
|
||||
reason="needs git and bash",
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
cwd=cwd,
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _extract_autostash_block() -> str:
|
||||
"""Pull the autostash if-block from install.sh's update_repo()."""
|
||||
text = INSTALL_SH.read_text()
|
||||
m = re.search(
|
||||
r'local autostash_ref="".*?\n fi\n',
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m is not None, "autostash block not found in install.sh"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def _make_unmerged_repo(repo: Path) -> None:
|
||||
"""Leave ``repo`` with a conflicted (unmerged) index, as an interrupted
|
||||
update would."""
|
||||
_git(repo, "init")
|
||||
(repo / "f.txt").write_text("base\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "base")
|
||||
# Capture the default branch name only after the first commit exists
|
||||
# (rev-parse on an unborn HEAD errors).
|
||||
start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||
|
||||
_git(repo, "checkout", "-b", "feature")
|
||||
(repo / "f.txt").write_text("feature side\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "feature")
|
||||
|
||||
_git(repo, "checkout", start)
|
||||
(repo / "f.txt").write_text("main side\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "mainside")
|
||||
|
||||
# Conflicting merge — exits non-zero and leaves the index unmerged.
|
||||
_git(repo, "merge", "feature", check=False)
|
||||
|
||||
|
||||
@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo
|
||||
def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "hermes-agent"
|
||||
repo.mkdir()
|
||||
_make_unmerged_repo(repo)
|
||||
|
||||
# Sanity: this is exactly the state that breaks `git stash` / `git checkout`.
|
||||
assert _git(repo, "ls-files", "--unmerged").stdout.strip(), (
|
||||
"test setup failed to produce an unmerged index"
|
||||
)
|
||||
|
||||
block = _extract_autostash_block()
|
||||
script = (
|
||||
"set -e\n"
|
||||
'log_info() { echo "INFO: $*"; }\n'
|
||||
"run() {\n"
|
||||
f"{block}"
|
||||
"}\n"
|
||||
"run\n"
|
||||
"echo BLOCK_OK\n"
|
||||
)
|
||||
res = subprocess.run(
|
||||
["bash", "-c", script], cwd=repo, capture_output=True, text=True
|
||||
)
|
||||
|
||||
# The block must complete (previously `git stash` failed with "could not
|
||||
# write index" on the unmerged tree).
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert "BLOCK_OK" in res.stdout
|
||||
assert "Clearing unmerged index entries" in res.stdout
|
||||
|
||||
# The conflict state is gone ...
|
||||
assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", (
|
||||
"unmerged entries should have been cleared"
|
||||
)
|
||||
# ... and the local changes were preserved in a stash, not discarded.
|
||||
assert _git(repo, "stash", "list").stdout.strip(), (
|
||||
"local changes should be preserved in a stash"
|
||||
)
|
||||
|
||||
|
||||
def test_install_ps1_clears_unmerged_index_before_stash() -> None:
|
||||
"""install.ps1 must clear an unmerged index before stash/checkout, and do
|
||||
so *before* the stash push (order matters — the fix is a no-op otherwise)."""
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "ls-files --unmerged" in text, (
|
||||
"install.ps1 must detect an unmerged index before updating"
|
||||
)
|
||||
idx_unmerged = text.index("ls-files --unmerged")
|
||||
idx_reset = text.index("reset -q", idx_unmerged)
|
||||
idx_stash = text.index("stash push --include-untracked")
|
||||
assert idx_unmerged < idx_stash, (
|
||||
"the unmerged-index clear must run before `git stash push`"
|
||||
)
|
||||
assert idx_reset < idx_stash, "`git reset` must run before `git stash push`"
|
||||
|
||||
|
||||
def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None:
|
||||
"""Same ordering contract for install.sh's source."""
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "ls-files --unmerged" in text
|
||||
idx_unmerged = text.index("ls-files --unmerged")
|
||||
idx_stash = text.index("stash push --include-untracked")
|
||||
assert idx_unmerged < idx_stash
|
||||
@@ -847,41 +847,6 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_keeps_reasoning_only_assistant_turn():
|
||||
# A thinking-only assistant turn (reasoning present, no visible text) is
|
||||
# persisted and recallable, but was dropped from the resumed session view
|
||||
# as "empty" -- so it vanished while the agent could still recall it from
|
||||
# the transcript. Keep it (with reasoning) so the desktop "Thinking…"
|
||||
# disclosure renders. (#44022)
|
||||
history = [
|
||||
{"role": "user", "content": "think about this"},
|
||||
{"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"},
|
||||
{"role": "assistant", "content": "here is the answer"},
|
||||
]
|
||||
|
||||
assert server._history_to_messages(history) == [
|
||||
{"role": "user", "text": "think about this"},
|
||||
{"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"},
|
||||
{"role": "assistant", "text": "here is the answer"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
|
||||
# A genuinely empty assistant turn (no text, no reasoning, no tool calls)
|
||||
# remains filtered out -- the fix only spares reasoning-bearing turns.
|
||||
history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "reasoning": ""},
|
||||
{"role": "assistant", "content": " "},
|
||||
{"role": "assistant", "content": "real reply"},
|
||||
]
|
||||
|
||||
assert server._history_to_messages(history) == [
|
||||
{"role": "user", "text": "hi"},
|
||||
{"role": "assistant", "text": "real reply"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_renders_multimodal_content():
|
||||
# bb/gui preserves image URLs in the resume payload so the desktop
|
||||
# renderer's extractEmbeddedImages can pull them back out and display
|
||||
@@ -1006,35 +971,6 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
|
||||
assert server._sessions[runtime_sid]["model_override"] == captured["model_override"]
|
||||
|
||||
|
||||
def test_stored_session_runtime_overrides_skips_bare_billing_provider():
|
||||
"""A bare billing bucket ("custom"/"auto"/"openrouter") must not be restored as the
|
||||
provider identity on resume. A custom endpoint that never used `/model` persists only
|
||||
`billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
|
||||
configured" (agent_init treats it as non-routable). A real provider, or an explicit
|
||||
`model_config.provider`, is still restored.
|
||||
"""
|
||||
# Bare "custom" bucket, no explicit model_config.provider: no provider override restored.
|
||||
ov = server._stored_session_runtime_overrides({"model": "my-model", "billing_provider": "custom"})
|
||||
assert "provider_override" not in ov
|
||||
assert ov["model_override"]["provider"] is None
|
||||
|
||||
for bare in ("auto", "openrouter", "custom"):
|
||||
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": bare})
|
||||
assert "provider_override" not in ov
|
||||
|
||||
# A real provider in billing_provider is still restored.
|
||||
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": "anthropic"})
|
||||
assert ov["provider_override"] == "anthropic"
|
||||
assert ov["model_override"]["provider"] == "anthropic"
|
||||
|
||||
# An explicit routable provider in model_config wins over the bare billing bucket.
|
||||
ov = server._stored_session_runtime_overrides(
|
||||
{"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
|
||||
)
|
||||
assert ov["provider_override"] == "custom:myendpoint"
|
||||
assert ov["model_override"]["provider"] == "custom:myendpoint"
|
||||
|
||||
|
||||
def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
|
||||
updates = {}
|
||||
|
||||
|
||||
@@ -69,24 +69,9 @@ class TestWriteDenyExactPaths:
|
||||
|
||||
def test_shell_profiles(self):
|
||||
home = str(Path.home())
|
||||
for name in [
|
||||
".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile",
|
||||
".zshenv", ".zlogin", ".bash_login",
|
||||
]:
|
||||
for name in [".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile"]:
|
||||
assert _is_write_denied(os.path.join(home, name)) is True, f"{name} should be denied"
|
||||
|
||||
def test_global_git_config_paths(self):
|
||||
home = str(Path.home())
|
||||
for path in [
|
||||
os.path.join(home, ".gitconfig"),
|
||||
os.path.join(home, ".config", "git", "config"),
|
||||
os.path.join(home, ".config", "git", "hooks", "pre-commit"),
|
||||
]:
|
||||
assert _is_write_denied(path) is True, f"{path} should be denied"
|
||||
|
||||
def test_project_git_config_allowed(self):
|
||||
assert _is_write_denied("/tmp/someproject/.git/config") is False
|
||||
|
||||
def test_package_manager_configs(self):
|
||||
home = str(Path.home())
|
||||
for name in [".npmrc", ".pypirc", ".pgpass"]:
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"""Session persistence must not strip a custom provider's identity.
|
||||
|
||||
``_runtime_model_config`` persists the live agent's RESOLVED provider into
|
||||
the session row's ``model_config`` JSON. For any named ``providers:`` /
|
||||
``custom_providers:`` entry (e.g. one called "mimo-v2.5-pro"),
|
||||
``agent.provider`` is the literal string "custom", so the entry name was
|
||||
lost — and the api_key is deliberately never persisted. On ``session.resume``
|
||||
or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed
|
||||
provider="custom" back into ``_make_agent`` →
|
||||
``resolve_runtime_provider(requested="custom")``, which cannot match an entry
|
||||
named "mimo-v2.5-pro". Depending on config the rebuild either raised
|
||||
"No LLM provider configured. Run `hermes model`..." (resume failed) or
|
||||
silently resolved placeholder credentials ("no-key-required") against the
|
||||
patched-back base_url.
|
||||
|
||||
Fix: persist the REQUESTED/entry identity — ``_runtime_model_config`` maps
|
||||
the agent's base_url back to the canonical ``custom:<name>`` menu key via
|
||||
``find_custom_provider_identity``; ``_make_agent`` performs the same
|
||||
recovery for rows persisted before the fix (and falls back to handing the
|
||||
stored base_url to the direct-alias branch when no entry matches).
|
||||
|
||||
Related investigation: GH #44070 / PR #44099 (credential-pool base_url
|
||||
pinning); same family of resolved-vs-requested identity loss.
|
||||
"""
|
||||
|
||||
import json
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import hermes_cli.runtime_provider as rp
|
||||
|
||||
MIMO_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
MIMO_KEY = "sk-mimo-entry-key"
|
||||
|
||||
LEGACY_LIST_CONFIG = {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "mimo-v2.5-pro",
|
||||
"base_url": MIMO_URL,
|
||||
"api_key": MIMO_KEY,
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
PROVIDERS_DICT_CONFIG = {
|
||||
"providers": {
|
||||
"mimo-v2.5-pro": {
|
||||
"api": MIMO_URL,
|
||||
"api_key": MIMO_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _custom_agent(base_url=MIMO_URL):
|
||||
return types.SimpleNamespace(
|
||||
model="mimo-v2.5-pro",
|
||||
provider="custom",
|
||||
base_url=base_url,
|
||||
api_mode="chat_completions",
|
||||
reasoning_config=None,
|
||||
service_tier=None,
|
||||
)
|
||||
|
||||
|
||||
class TestRuntimeModelConfigPersistsEntryIdentity:
|
||||
def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch):
|
||||
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
|
||||
|
||||
from tui_gateway.server import _runtime_model_config
|
||||
|
||||
config = _runtime_model_config(_custom_agent())
|
||||
|
||||
assert config["provider"] == "custom:mimo-v2.5-pro"
|
||||
assert config["base_url"] == MIMO_URL
|
||||
# Credentials must keep coming from config/provider resolution,
|
||||
# never from the session DB.
|
||||
assert "api_key" not in config
|
||||
|
||||
def test_persists_menu_key_for_providers_dict_entry(self, monkeypatch):
|
||||
monkeypatch.setattr(rp, "load_config", lambda: PROVIDERS_DICT_CONFIG)
|
||||
|
||||
from tui_gateway.server import _runtime_model_config
|
||||
|
||||
config = _runtime_model_config(_custom_agent())
|
||||
|
||||
assert config["provider"] == "custom:mimo-v2.5-pro"
|
||||
|
||||
def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch):
|
||||
monkeypatch.setattr(rp, "load_config", lambda: {})
|
||||
|
||||
from tui_gateway.server import _runtime_model_config
|
||||
|
||||
config = _runtime_model_config(_custom_agent())
|
||||
|
||||
assert config["provider"] == "custom"
|
||||
|
||||
def test_non_custom_provider_untouched(self, monkeypatch):
|
||||
def _boom():
|
||||
raise AssertionError("identity lookup must not run for built-ins")
|
||||
|
||||
monkeypatch.setattr(rp, "load_config", _boom)
|
||||
|
||||
from tui_gateway.server import _runtime_model_config
|
||||
|
||||
agent = _custom_agent()
|
||||
agent.provider = "anthropic"
|
||||
agent.base_url = "https://api.anthropic.com"
|
||||
|
||||
assert _runtime_model_config(agent)["provider"] == "anthropic"
|
||||
|
||||
|
||||
def _make_agent_with_override(override, monkeypatch, config):
|
||||
"""Run _make_agent through the REAL resolve_runtime_provider against a
|
||||
patched config, returning the kwargs AIAgent was constructed with."""
|
||||
monkeypatch.setattr(rp, "load_config", lambda: config)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
# Keep credential-pool resolution off the developer's real HERMES home.
|
||||
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
|
||||
|
||||
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "unused"}}
|
||||
with (
|
||||
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch("tui_gateway.server._load_reasoning_config", return_value=None),
|
||||
patch("tui_gateway.server._load_service_tier", return_value=None),
|
||||
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-custom", "key-custom", model_override=override)
|
||||
|
||||
return mock_agent.call_args.kwargs
|
||||
|
||||
|
||||
class TestResumeRoundTrip:
|
||||
def test_round_trip_restores_entry_credentials(self, monkeypatch):
|
||||
"""persist → stored-overrides → _make_agent resolves the entry's
|
||||
api_key again (the exact path that raised "No LLM provider
|
||||
configured" before the fix)."""
|
||||
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
|
||||
|
||||
from tui_gateway.server import (
|
||||
_runtime_model_config,
|
||||
_stored_session_runtime_overrides,
|
||||
)
|
||||
|
||||
model_config = _runtime_model_config(_custom_agent())
|
||||
row = {
|
||||
"model": "mimo-v2.5-pro",
|
||||
"model_config": json.dumps(model_config),
|
||||
}
|
||||
overrides = _stored_session_runtime_overrides(row)
|
||||
assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro"
|
||||
|
||||
kwargs = _make_agent_with_override(
|
||||
overrides["model_override"], monkeypatch, LEGACY_LIST_CONFIG
|
||||
)
|
||||
|
||||
assert kwargs["provider"] == "custom"
|
||||
assert kwargs["base_url"] == MIMO_URL
|
||||
assert kwargs["api_key"] == MIMO_KEY
|
||||
|
||||
def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch):
|
||||
"""Rows persisted BEFORE the fix stored provider="custom"; the
|
||||
rebuild must recover the entry identity from the stored base_url."""
|
||||
override = {
|
||||
"model": "mimo-v2.5-pro",
|
||||
"provider": "custom",
|
||||
"base_url": MIMO_URL,
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG)
|
||||
|
||||
assert kwargs["base_url"] == MIMO_URL
|
||||
assert kwargs["api_key"] == MIMO_KEY
|
||||
|
||||
def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):
|
||||
"""No config entry owns the stored URL: the direct-alias branch must
|
||||
still receive the base_url so resolution targets the session's
|
||||
endpoint instead of raising auth_unavailable."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
override = {
|
||||
"model": "local-model",
|
||||
"provider": "custom",
|
||||
"base_url": "http://127.0.0.1:8000/v1",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
kwargs = _make_agent_with_override(override, monkeypatch, {})
|
||||
|
||||
assert kwargs["provider"] == "custom"
|
||||
assert kwargs["base_url"] == "http://127.0.0.1:8000/v1"
|
||||
assert kwargs["api_key"] == "no-key-required"
|
||||
@@ -252,7 +252,7 @@ THREAT_PATTERNS = [
|
||||
(r'\bcrontab\b',
|
||||
"persistence_cron", "medium", "persistence",
|
||||
"modifies cron jobs"),
|
||||
(r'\.(bashrc|zshrc|zshenv|profile|bash_profile|bash_login|zprofile|zlogin)\b',
|
||||
(r'\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b',
|
||||
"shell_rc_mod", "medium", "persistence",
|
||||
"references shell startup file"),
|
||||
(r'authorized_keys',
|
||||
|
||||
+12
-74
@@ -1477,11 +1477,6 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
|
||||
return model, None
|
||||
|
||||
|
||||
# Bare billing buckets are not routable provider identities (kept in parity with the
|
||||
# provider gate in agent_init). Restoring one as a session provider override breaks resume.
|
||||
_BARE_BILLING_PROVIDERS = {"auto", "openrouter", "custom"}
|
||||
|
||||
|
||||
def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
||||
"""Return runtime fields persisted with a stored session.
|
||||
|
||||
@@ -1508,18 +1503,12 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
||||
|
||||
overrides: dict = {}
|
||||
model = str(row.get("model") or model_config.get("model") or "").strip()
|
||||
# ``billing_provider`` is only the billing bucket — for a custom endpoint it is the
|
||||
# bare class ``"custom"``, which agent_init treats as non-routable, so restoring it as
|
||||
# the provider override makes ``session.resume`` fail with "No LLM provider configured".
|
||||
# Only restore an explicit provider; otherwise leave it unset so resume falls back to
|
||||
# the configured default, matching the working CLI path.
|
||||
explicit_provider = str(model_config.get("provider") or "").strip()
|
||||
billing_provider = str(
|
||||
model_config.get("billing_provider") or row.get("billing_provider") or ""
|
||||
provider = str(
|
||||
model_config.get("provider")
|
||||
or model_config.get("billing_provider")
|
||||
or row.get("billing_provider")
|
||||
or ""
|
||||
).strip()
|
||||
provider = explicit_provider
|
||||
if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
|
||||
provider = billing_provider
|
||||
base_url = str(model_config.get("base_url") or "").strip()
|
||||
api_mode = str(model_config.get("api_mode") or "").strip()
|
||||
reasoning_config = model_config.get("reasoning_config")
|
||||
@@ -1559,25 +1548,6 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:
|
||||
if model:
|
||||
config["model"] = model
|
||||
if provider:
|
||||
if provider == "custom" and base_url:
|
||||
# ``agent.provider`` is the RESOLVED provider, and for any named
|
||||
# ``providers:`` / ``custom_providers:`` entry that is the literal
|
||||
# string "custom" — persisting it loses the entry identity, so a
|
||||
# later resume/rebuild cannot re-resolve the entry's credentials
|
||||
# (the api_key is deliberately never persisted; see
|
||||
# _stored_session_runtime_overrides). Recover the canonical
|
||||
# ``custom:<name>`` menu key from the endpoint URL so
|
||||
# resolve_runtime_provider() can find the entry again.
|
||||
try:
|
||||
from hermes_cli.runtime_provider import (
|
||||
find_custom_provider_identity,
|
||||
)
|
||||
|
||||
provider = find_custom_provider_identity(base_url) or provider
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"custom provider identity lookup failed", exc_info=True
|
||||
)
|
||||
config["provider"] = provider
|
||||
if base_url:
|
||||
config["base_url"] = base_url
|
||||
@@ -3329,30 +3299,9 @@ def _make_agent(
|
||||
override_base_url = model_override.get("base_url")
|
||||
override_api_key = model_override.get("api_key")
|
||||
override_api_mode = model_override.get("api_mode")
|
||||
resolve_kwargs = {}
|
||||
if (
|
||||
override_base_url
|
||||
and str(requested_provider or "").strip().lower() == "custom"
|
||||
):
|
||||
# Session rows persisted before the custom-provider identity fix
|
||||
# (see _runtime_model_config) stored the resolved provider
|
||||
# "custom", which _get_named_custom_provider cannot match back to
|
||||
# a named ``providers:`` / ``custom_providers:`` entry — the
|
||||
# rebuild then either raised auth_unavailable or silently
|
||||
# resolved placeholder credentials against the patched-back
|
||||
# base_url. Recover the entry identity from the persisted
|
||||
# base_url; failing that, hand the base_url to the direct-alias
|
||||
# branch so pool/env credentials can still be resolved for it.
|
||||
from hermes_cli.runtime_provider import find_custom_provider_identity
|
||||
|
||||
recovered = find_custom_provider_identity(override_base_url)
|
||||
if recovered:
|
||||
requested_provider = recovered
|
||||
resolve_kwargs["explicit_base_url"] = override_base_url
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=requested_provider,
|
||||
target_model=model or None,
|
||||
**resolve_kwargs,
|
||||
)
|
||||
# The switch already resolved concrete credentials/endpoint; honor them
|
||||
# so a custom/named endpoint survives the rebuild even if global
|
||||
@@ -3708,27 +3657,16 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||
)
|
||||
continue
|
||||
# An assistant turn may carry only reasoning/thinking content with no
|
||||
# visible text (extended-thinking turns, thinking-only recovery
|
||||
# responses). Such a turn is persisted with its reasoning fields and is
|
||||
# recallable from the transcript, but dropping it here as "empty" makes
|
||||
# it vanish from the resumed/reloaded session view while the desktop's
|
||||
# reasoning disclosure has nothing to render. Keep it when it carries
|
||||
# reasoning so the "Thinking…" block still shows. (#44022)
|
||||
reasoning_keys = (
|
||||
"reasoning",
|
||||
"reasoning_content",
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
)
|
||||
has_reasoning = role == "assistant" and any(
|
||||
m.get(key) for key in reasoning_keys
|
||||
)
|
||||
if not content_text.strip() and not has_reasoning:
|
||||
if not content_text.strip():
|
||||
continue
|
||||
msg = {"role": role, "text": content_text}
|
||||
if role == "assistant":
|
||||
for key in reasoning_keys:
|
||||
for key in (
|
||||
"reasoning",
|
||||
"reasoning_content",
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
):
|
||||
if key in m and m.get(key) is not None:
|
||||
msg[key] = m.get(key)
|
||||
messages.append(msg)
|
||||
|
||||
+33
-62
@@ -59,12 +59,11 @@ export function getManagementProfile(): string {
|
||||
}
|
||||
|
||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
|
||||
// pairing, telegram onboarding, cron (which has its own per-job profile
|
||||
// params), profiles themselves — is machine-global or self-scoped and must
|
||||
// NOT be rewritten.
|
||||
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
|
||||
// telegram onboarding, cron (which has its own per-job profile params),
|
||||
// profiles themselves — is machine-global or self-scoped and must NOT be
|
||||
// rewritten.
|
||||
const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/analytics",
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
@@ -303,11 +302,6 @@ function profileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
function appendProfileParam(url: string, profile?: string): string {
|
||||
if (!profile || url.includes("profile=")) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}profile=${encodeURIComponent(profile)}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
@@ -342,64 +336,47 @@ export const api = {
|
||||
window.location.assign("/login");
|
||||
return r;
|
||||
}),
|
||||
getSessions: (limit = 20, offset = 0, profile = getManagementProfile()) =>
|
||||
fetchJSON<PaginatedSessions>(
|
||||
appendProfileParam(`/api/sessions?limit=${limit}&offset=${offset}`, profile),
|
||||
),
|
||||
getSessionMessages: (id: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<SessionMessagesResponse>(
|
||||
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile),
|
||||
),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
getSessionLatestDescendant: (id: string) =>
|
||||
fetchJSON<SessionLatestDescendantResponse>(
|
||||
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
|
||||
),
|
||||
deleteSession: (id: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<{ ok: boolean }>(
|
||||
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
getEmptySessionsCount: (profile = getManagementProfile()) =>
|
||||
fetchJSON<{ count: number }>(
|
||||
appendProfileParam("/api/sessions/empty/count", profile),
|
||||
),
|
||||
deleteEmptySessions: (profile = getManagementProfile()) =>
|
||||
fetchJSON<{ ok: boolean; deleted: number }>(
|
||||
appendProfileParam("/api/sessions/empty", profile),
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
bulkDeleteSessions: (ids: string[], profile = getManagementProfile()) =>
|
||||
deleteSession: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getEmptySessionsCount: () =>
|
||||
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
|
||||
deleteEmptySessions: () =>
|
||||
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
bulkDeleteSessions: (ids: string[]) =>
|
||||
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids, profile: profile || undefined }),
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
renameSession: (id: string, title: string, profile = getManagementProfile()) =>
|
||||
renameSession: (id: string, title: string) =>
|
||||
fetchJSON<{ ok: boolean; title: string }>(
|
||||
`/api/sessions/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, profile: profile || undefined }),
|
||||
body: JSON.stringify({ title }),
|
||||
},
|
||||
),
|
||||
getSessionStats: (profile = getManagementProfile()) =>
|
||||
fetchJSON<SessionStoreStats>(appendProfileParam("/api/sessions/stats", profile)),
|
||||
exportSessionUrl: (id: string, profile = getManagementProfile()) =>
|
||||
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile),
|
||||
pruneSessions: (
|
||||
older_than_days: number,
|
||||
source?: string,
|
||||
profile = getManagementProfile(),
|
||||
) =>
|
||||
getSessionStats: () => fetchJSON<SessionStoreStats>("/api/sessions/stats"),
|
||||
exportSessionUrl: (id: string) =>
|
||||
`/api/sessions/${encodeURIComponent(id)}/export`,
|
||||
pruneSessions: (older_than_days: number, source?: string) =>
|
||||
fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ older_than_days, source, profile: profile || undefined }),
|
||||
body: JSON.stringify({ older_than_days, source }),
|
||||
}),
|
||||
listFiles: (path?: string) => {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
@@ -435,14 +412,10 @@ export const api = {
|
||||
if (params.component && params.component !== "all") qs.set("component", params.component);
|
||||
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
||||
},
|
||||
getAnalytics: (days: number, profile = getManagementProfile()) =>
|
||||
fetchJSON<AnalyticsResponse>(
|
||||
appendProfileParam(`/api/analytics/usage?days=${days}`, profile),
|
||||
),
|
||||
getModelsAnalytics: (days: number, profile = getManagementProfile()) =>
|
||||
fetchJSON<ModelsAnalyticsResponse>(
|
||||
appendProfileParam(`/api/analytics/models?days=${days}`, profile),
|
||||
),
|
||||
getAnalytics: (days: number) =>
|
||||
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
||||
getModelsAnalytics: (days: number) =>
|
||||
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
@@ -707,10 +680,8 @@ export const api = {
|
||||
),
|
||||
|
||||
// Session search (FTS5)
|
||||
searchSessions: (q: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<SessionSearchResponse>(
|
||||
appendProfileParam(`/api/sessions/search?q=${encodeURIComponent(q)}`, profile),
|
||||
),
|
||||
searchSessions: (q: string) =>
|
||||
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
|
||||
@@ -205,29 +205,6 @@ docker exec hermes hermes profile delete coder
|
||||
|
||||
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
|
||||
|
||||
### Reaching more than one profile from outside the container
|
||||
|
||||
Two different surfaces reach a profile's gateway from outside, and they behave differently — don't conflate them:
|
||||
|
||||
**Hermes Desktop (and the web dashboard).** The Desktop app's **Remote Gateway** connection talks to a `hermes dashboard` backend (default **port 9119**, enabled by `HERMES_DASHBOARD=1`) — *not* the OpenAI API server. One dashboard backend serves **every** co-located profile: the app's profile switcher sends the target profile with each request and the backend opens that profile's `HERMES_HOME` on disk. So you do **not** need a second port — or a second connection — per profile for Desktop; one `:9119` connection covers them all through the switcher.
|
||||
|
||||
**OpenAI-compatible API clients (Open WebUI, LobeChat, `/v1/...`).** These talk to each profile's **API server**, which binds **port 8642 for every profile** (resolved from `API_SERVER_PORT` / `platforms.api_server.extra.port` — there is no auto-allocation and no `config.yaml`/`gateway.port` key). If you want a client to reach a *specific* second profile, give that profile a distinct `API_SERVER_PORT` in **its own** `.env`, otherwise its gateway tries to bind 8642 too and conflicts with the default profile:
|
||||
|
||||
```sh
|
||||
# Create the profile (registers its gateway-<name> s6 slot)
|
||||
docker exec hermes hermes profile create work
|
||||
|
||||
# Point its API server at a free port (write to the profile's own .env)
|
||||
cat >> /opt/data/profiles/work/.env <<'EOF'
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_PORT=8643
|
||||
EOF
|
||||
|
||||
docker exec hermes hermes -p work gateway restart
|
||||
```
|
||||
|
||||
Keep `API_SERVER_PORT` in each profile's **own** `.env`, never in the container-wide `environment:` block — a global value would force every profile onto the same port and they would collide. With bridge networking, publish the extra port in `docker-compose.yml` (`- "8643:8643"`); with `network_mode: host` it is already reachable on the host. The default profile's 8642 connection is untouched.
|
||||
|
||||
### Why one container with many profiles, not many containers
|
||||
|
||||
Before the s6 migration, "one container per profile" was the recommended pattern because there was no in-container supervisor to manage multiple gateways. With s6 as PID 1, that's no longer necessary, and the single-container layout is simpler in almost every dimension:
|
||||
|
||||
@@ -145,7 +145,7 @@ BlueBubbles iMessage channel uses.
|
||||
## Start the gateway
|
||||
|
||||
```bash
|
||||
hermes gateway start
|
||||
hermes gateway start --platform photon
|
||||
```
|
||||
|
||||
You'll see something like:
|
||||
|
||||
@@ -900,11 +900,19 @@ gateway:
|
||||
|
||||
## Rendering: Rich Messages, Tables and Link Previews
|
||||
|
||||
**Rich Messages (Bot API 10.1).** Final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message.
|
||||
**Rich Messages (Bot API 10.1).** When enabled, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. This is **opt-in** (default off) while the new endpoint is validated; enable it per platform:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
rich_messages: true
|
||||
```
|
||||
|
||||
The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
|
||||
|
||||
**MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
|
||||
**MarkdownV2 fallback.** When the rich path is disabled or unavailable, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
|
||||
|
||||
- **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 2–4 columns and short cells.
|
||||
- **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.
|
||||
|
||||
+10
-2
@@ -877,11 +877,19 @@ gateway:
|
||||
|
||||
## 渲染:富消息、表格和链接预览
|
||||
|
||||
**富消息(Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
|
||||
**富消息(Bot API 10.1)。** 启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能为**选择性启用**(默认关闭),在新端点经过验证期间需手动开启;可按平台配置:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
rich_messages: true
|
||||
```
|
||||
|
||||
当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。
|
||||
|
||||
**MarkdownV2 回退。** 当某条消息无法使用富消息路径时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化:
|
||||
**MarkdownV2 回退。** 当富消息路径被禁用或不可用时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化:
|
||||
|
||||
- **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
|
||||
- **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。
|
||||
|
||||
Reference in New Issue
Block a user