From 13a7cbcd6404c6e8ef501f98a0b315da4223228c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:20 +0530 Subject: [PATCH 01/99] fix(nix): refresh stale tui npmDepsHash + fix cache-blind detection (#20144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix-lockfiles script used 'nix build .#tui.npmDeps' to detect stale hashes. This always succeeds when the OLD derivation is cached in Cachix or cache.nixos.org — even when the source package-lock.json has changed. Fix: use prefetch-npm-deps to compute the hash directly from the lockfile and compare against what's in the nix file. Falls back to nix build only if prefetch-npm-deps fails. --- nix/lib.nix | 55 ++++++++++++++++++++++++++++++----------------------- nix/tui.nix | 2 +- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/nix/lib.nix b/nix/lib.nix index 95591eb34dd0..7a511c807d11 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -163,35 +163,42 @@ for entry in "''${ENTRIES[@]}"; do IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry" echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)" - OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1) - STATUS=$? - if [ "$STATUS" -eq 0 ]; then + + # Compute the actual hash from the lockfile directly using + # prefetch-npm-deps. This avoids false "ok" from nix build when + # an old derivation is cached in a substituter (cachix/cache.nixos.org). + LOCK_FILE="$FOLDER/package-lock.json" + NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null) + if [ -z "$NEW_HASH" ]; then + echo " prefetch-npm-deps failed, falling back to nix build" >&2 + OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1) + STATUS=$? + if [ "$STATUS" -eq 0 ]; then + echo " ok (via nix build)" + continue + fi + NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}') + if [ -z "$NEW_HASH" ]; then + if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then + echo " skipped (transient cache failure — see primary nix build for real status)" >&2 + echo "$OUTPUT" | tail -8 >&2 + continue + fi + echo " build failed with no hash mismatch:" >&2 + echo "$OUTPUT" | tail -40 >&2 + exit 1 + fi + fi + + OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \ + | sed -E 's/hash = "(.*)"/\1/') + + if [ "$NEW_HASH" = "$OLD_HASH" ]; then echo " ok" continue fi - NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}') - if [ -z "$NEW_HASH" ]; then - # Magic-Nix-Cache occasionally returns HTTP 418 / cache-throttled - # mid-run; nix then prints "outputs … not valid, so checking is - # not possible" without a `got:` line. That's an infrastructure - # blip, not a stale lockfile — warn + skip rather than failing - # the lint. A real hash mismatch would still surface in the - # primary `.#$ATTR` build, which is a separate CI job. - if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then - echo " skipped (transient cache failure — see primary nix build for real status)" >&2 - echo "$OUTPUT" | tail -8 >&2 - continue - fi - echo " build failed with no hash mismatch:" >&2 - echo "$OUTPUT" | tail -40 >&2 - exit 1 - fi - HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1) - OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \ - | sed -E 's/hash = "(.*)"/\1/') - LOCK_FILE="$FOLDER/package-lock.json" echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH" STALE=1 diff --git a/nix/tui.nix b/nix/tui.nix index 4d27dde798e2..9ad63378da36 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0="; + hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From c3112adac551f5afe4166c68a1165d60f33af74f Mon Sep 17 00:00:00 2001 From: baojianhang Date: Sun, 26 Apr 2026 16:40:11 +0800 Subject: [PATCH 02/99] fix(tui): improve clipboard copy fallbacks --- ui-tui/src/__tests__/clipboard.test.ts | 186 ++++++++++++++++++++++++- ui-tui/src/app/slash/commands/core.ts | 22 ++- ui-tui/src/lib/clipboard.ts | 82 ++++++++--- 3 files changed, 265 insertions(+), 25 deletions(-) diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index ba14e9bebc2e..b0646ee488e8 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -100,11 +100,22 @@ describe('isUsableClipboardText', () => { }) describe('writeClipboardText', () => { - it('does nothing off macOS', async () => { - const start = vi.fn() + it('does nothing off macOS when no tools are available', async () => { + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(1) // non-zero exit = failure + } - await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false) - expect(start).not.toHaveBeenCalled() + return child + }), + stdin: { end: vi.fn() } + } + + const start = vi.fn().mockReturnValue(child) + + // Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail + await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false) }) it('writes text to pbcopy on macOS', async () => { @@ -148,4 +159,171 @@ describe('writeClipboardText', () => { await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false) }) + + it('uses wl-copy on Wayland Linux', async () => { + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect( + writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' }) + ).resolves.toBe(true) + expect(start).toHaveBeenCalledWith( + 'wl-copy', + ['--type', 'text/plain'], + expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + ) + expect(stdin.end).toHaveBeenCalledWith('wayland text') + }) + + it('falls back to xclip when wl-copy fails on Wayland', async () => { + let callCount = 0 + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + callCount++ + // wl-copy fails, xclip succeeds + cb(callCount === 1 ? 1 : 0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect( + writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' }) + ).resolves.toBe(true) + expect(start).toHaveBeenNthCalledWith( + 1, + 'wl-copy', + ['--type', 'text/plain'], + expect.anything() + ) + expect(start).toHaveBeenNthCalledWith( + 2, + 'xclip', + ['-selection', 'clipboard', '-in'], + expect.anything() + ) + }) + + it('falls back to xsel when both wl-copy and xclip fail', async () => { + let callCount = 0 + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + callCount++ + cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect( + writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' }) + ).resolves.toBe(true) + expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything()) + }) + + it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => { + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true) + expect(start).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining(['-NoProfile', '-NonInteractive']), + expect.anything() + ) + expect(stdin.end).toHaveBeenCalledWith('wsl text') + }) + + it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => { + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect( + writeClipboardText('wslg text', 'linux', start as any, { + WAYLAND_DISPLAY: 'wayland-0', + WSL_DISTRO_NAME: 'Ubuntu' + }) + ).resolves.toBe(true) + expect(start).toHaveBeenNthCalledWith( + 1, + 'powershell.exe', + expect.arrayContaining(['-NoProfile', '-NonInteractive']), + expect.anything() + ) + expect(stdin.end).toHaveBeenCalledWith('wslg text') + }) + + it('uses PowerShell on Windows', async () => { + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + + await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true) + expect(start).toHaveBeenCalledWith( + 'powershell', + expect.arrayContaining(['-NoProfile', '-NonInteractive']), + expect.anything() + ) + }) }) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index f9b54c34c18a..dcbafb3a82d4 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -10,6 +10,7 @@ import type { SessionTitleResponse, SessionUndoResponse } from '../../../gatewayTypes.js' +import { writeClipboardText } from '../../../lib/clipboard.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' import type { Msg, PanelSection } from '../../../types.js' @@ -318,10 +319,27 @@ export const coreCommands: SlashCommand[] = [ const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] if (!target) { - return sys('nothing to copy') + return sys('nothing to copy — start a conversation first') } - writeOsc52Clipboard(target.text) + void writeClipboardText(target.text) + .then(nativeOk => { + if (ctx.stale()) { + return + } + + if (nativeOk) { + sys('copied to clipboard') + } else { + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + } + }) + .catch(error => { + if (!ctx.stale()) { + sys(`copy failed: ${String(error)}`) + } + }) } }, diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 23e03e5feb89..587e8986c3eb 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -44,7 +44,7 @@ function readClipboardCommands( const attempts: Array<{ args: readonly string[]; cmd: string }> = [] - if (env.WSL_INTEROP) { + if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS }) } @@ -91,32 +91,76 @@ export async function readClipboardText( return null } +function writeClipboardCommands( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): Array<{ args: readonly string[]; cmd: string }> { + if (platform === 'darwin') { + return [{ cmd: 'pbcopy', args: [] }] + } + + if (platform === 'win32') { + return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }] + } + + const attempts: Array<{ args: readonly string[]; cmd: string }> = [] + + if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { + attempts.push({ + cmd: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] + }) + } + + if (env.WAYLAND_DISPLAY) { + attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] }) + } + + attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] }) + attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }) + + return attempts +} + /** * Write plain text to the system clipboard. * - * On macOS this uses `pbcopy`. On other platforms we intentionally return - * false for now; non-mac copy still falls back to OSC52. + * Tries native platform tools in fallback order: + * - macOS: pbcopy + * - Windows: PowerShell Set-Clipboard + * - WSL: powershell.exe Set-Clipboard + * - Linux Wayland: wl-copy --type text/plain + * - Linux X11: xclip -selection clipboard -in + * - Linux X11 alt: xsel --clipboard --input + * + * Returns true if at least one backend succeeded, false otherwise + * (callers should fall back to OSC52 on false). */ export async function writeClipboardText( text: string, platform: NodeJS.Platform = process.platform, - start: typeof spawn = spawn + start: typeof spawn = spawn, + env: NodeJS.ProcessEnv = process.env ): Promise { - if (platform !== 'darwin') { - return false + const candidates = writeClipboardCommands(platform, env) + + for (const { cmd, args } of candidates) { + try { + const ok = await new Promise(resolve => { + const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + + child.once('error', () => resolve(false)) + child.once('close', code => resolve(code === 0)) + child.stdin?.end(text) + }) + + if (ok) { + return true + } + } catch { + // Fall through to the next clipboard backend. + } } - try { - const ok = await new Promise(resolve => { - const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) - - child.once('error', () => resolve(false)) - child.once('close', code => resolve(code === 0)) - child.stdin.end(text) - }) - - return ok - } catch { - return false - } + return false } From 278535575086a9613a4459cd800abf940479f911 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 03:57:16 -0700 Subject: [PATCH 03/99] =?UTF-8?q?chore(release):=20map=20bjianhang@gmail.c?= =?UTF-8?q?om=20=E2=86=92=20@bjianhang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 0acdf219df9a..1b0cf0b8454a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -533,6 +533,7 @@ AUTHOR_MAP = { "memosr_email@gmail.com": "memosr", "jperlow@gmail.com": "perlowja", "jasonpette1783@gmail.com": "web-dev0521", + "bjianhang@gmail.com": "bjianhang", "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", "harryplusplus@gmail.com": "harryplusplus", "anthhub@163.com": "anthhub", From 8ad5e98f8d433e6e302355c77e22931f7a047eea Mon Sep 17 00:00:00 2001 From: simbam99 Date: Fri, 1 May 2026 20:16:11 +0300 Subject: [PATCH 04/99] fix(gateway): preserve pending update prompts across restarts --- gateway/run.py | 13 +++-- tests/gateway/test_update_streaming.py | 80 +++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index ebfd2731fe18..5e2163e830f0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5020,10 +5020,12 @@ class GatewayRunner: response_text = raw if response_text: response_path = _hermes_home / ".update_response" + prompt_path = _hermes_home / ".update_prompt.json" try: tmp = response_path.with_suffix(".tmp") tmp.write_text(response_text) tmp.replace(response_path) + prompt_path.unlink(missing_ok=True) except OSError as e: logger.warning("Failed to write update response: %s", e) return f"✗ Failed to send response to update process: {e}" @@ -5038,10 +5040,12 @@ class GatewayRunner: # The slash command then falls through to normal dispatch. if _recognized_cmd: response_path = _hermes_home / ".update_response" + prompt_path = _hermes_home / ".update_prompt.json" try: tmp = response_path.with_suffix(".tmp") tmp.write_text("") tmp.replace(response_path) + prompt_path.unlink(missing_ok=True) logger.info( "Recognized /%s during pending update prompt for %s; " "cancelled prompt with default and dispatching command", @@ -11488,12 +11492,13 @@ class GatewayRunner: f"or type your answer directly.", metadata=metadata, ) + # Keep the prompt marker on disk until the user + # answers. If the gateway restarts mid-prompt, the + # next watcher can recover by re-forwarding it from + # disk. Duplicate sends in the same process are + # still suppressed by _update_prompt_pending. self._update_prompt_pending[session_key] = True - # Remove the prompt file so it isn't re-read on the - # next poll cycle. The update process only needs # .update_response to continue — it doesn't re-check - # .update_prompt.json while waiting. - prompt_path.unlink(missing_ok=True) logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) except (json.JSONDecodeError, OSError) as e: logger.debug("Failed to read update prompt: %s", e) diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index b78eaa3327f4..36923bc5f05b 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -459,8 +459,9 @@ class TestWatchUpdateProgress: async def test_prompt_forwarded_only_once(self, tmp_path): """Regression: prompt must not be re-sent on every poll cycle. - Before the fix, the watcher never deleted .update_prompt.json after - forwarding, causing the same prompt to be sent every poll_interval. + The in-memory pending flag should suppress duplicate sends within a + single watcher process even when the prompt marker stays on disk for + restart recovery. """ runner = _make_runner() hermes_home = tmp_path / "hermes" @@ -505,6 +506,75 @@ class TestWatchUpdateProgress: f"All sends: {all_sent}" ) + @pytest.mark.asyncio + async def test_prompt_is_recovered_after_watcher_restart(self, tmp_path): + """A forwarded prompt stays on disk until answered so a new watcher can recover it.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = { + "platform": "telegram", + "chat_id": "111", + "user_id": "222", + "session_key": "agent:main:telegram:dm:111", + } + prompt = { + "prompt": "Restore local changes? [Y/n]", + "default": "y", + "id": "restart-recover", + } + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("") + (hermes_home / ".update_prompt.json").write_text(json.dumps(prompt)) + + runner1 = _make_runner() + adapter1 = AsyncMock() + runner1.adapters = {Platform.TELEGRAM: adapter1} + + with patch("gateway.run._hermes_home", hermes_home): + watch1 = asyncio.create_task( + runner1._watch_update_progress( + poll_interval=0.05, + stream_interval=0.1, + timeout=10.0, + ) + ) + for _ in range(40): + if adapter1.send.call_count: + break + await asyncio.sleep(0.05) + + assert adapter1.send.call_count == 1 + assert (hermes_home / ".update_prompt.json").exists() + + watch1.cancel() + with pytest.raises(asyncio.CancelledError): + await watch1 + + runner2 = _make_runner() + adapter2 = AsyncMock() + runner2.adapters = {Platform.TELEGRAM: adapter2} + + async def respond_and_finish(): + await asyncio.sleep(0.2) + (hermes_home / ".update_response").write_text("y") + await asyncio.sleep(0.2) + (hermes_home / ".update_exit_code").write_text("0") + + finisher = asyncio.create_task(respond_and_finish()) + await runner2._watch_update_progress( + poll_interval=0.05, + stream_interval=0.1, + timeout=10.0, + ) + await finisher + + prompt_sends = [ + str(call) for call in adapter2.send.call_args_list + if "Restore local changes" in str(call) + ] + assert len(prompt_sends) == 1 + # --------------------------------------------------------------------------- # Message interception for update prompts @@ -525,6 +595,7 @@ class TestUpdatePromptInterception: # The session key uses the full format from build_session_key session_key = "agent:main:telegram:dm:67890" runner._update_prompt_pending[session_key] = True + (hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"})) # Mock authorization and _session_key_for_source runner._is_user_authorized = MagicMock(return_value=True) @@ -538,6 +609,7 @@ class TestUpdatePromptInterception: response_path = hermes_home / ".update_response" assert response_path.exists() assert response_path.read_text() == "y" + assert not (hermes_home / ".update_prompt.json").exists() # Should clear the pending flag assert session_key not in runner._update_prompt_pending @@ -560,6 +632,7 @@ class TestUpdatePromptInterception: runner._is_user_authorized = MagicMock(return_value=True) runner._session_key_for_source = MagicMock(return_value=session_key) runner._handle_reset_command = AsyncMock(return_value="reset ok") + (hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"})) with patch("gateway.run._hermes_home", hermes_home): result = await runner._handle_message(event) @@ -572,6 +645,7 @@ class TestUpdatePromptInterception: response_path = hermes_home / ".update_response" assert response_path.exists() assert response_path.read_text() == "" + assert not (hermes_home / ".update_prompt.json").exists() # Pending flag is cleared so stray future input won't be # re-intercepted for a prompt that is no longer outstanding. assert session_key not in runner._update_prompt_pending @@ -588,6 +662,7 @@ class TestUpdatePromptInterception: runner._update_prompt_pending[session_key] = True runner._is_user_authorized = MagicMock(return_value=True) runner._session_key_for_source = MagicMock(return_value=session_key) + (hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"})) with patch("gateway.run._hermes_home", hermes_home): result = await runner._handle_message(event) @@ -595,6 +670,7 @@ class TestUpdatePromptInterception: response_path = hermes_home / ".update_response" assert response_path.exists() assert response_path.read_text() == "/foobarbaz" + assert not (hermes_home / ".update_prompt.json").exists() assert "Sent" in (result or "") assert session_key not in runner._update_prompt_pending From 91ce8fc000deaa4b4bbf1edb5b3d9f6dd1668f09 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:07:49 -0700 Subject: [PATCH 05/99] fix(setup): offer Keep/Replace/Clear when API key already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes setup / hermes model used to silently skip the key prompt when any value was present in .env — even a malformed paste — leaving users with a stuck '✓' and no way to recover without hand-editing .env. Replace the silent acknowledgement at all three API-key provider flows (Kimi, Stepfun, generic) with a single [K]eep / [R]eplace / [C]lear menu via a shared `_prompt_api_key` helper. - K / Enter / Ctrl-C / unknown input → keep (never destroys the key) - R → getpass for new key; empty input cancels and preserves existing - C → clears the env var, tells user to rerun hermes setup, aborts flow LM Studio's no-auth-placeholder substitution stays on first-time entry only; on Replace an empty input means 'cancel', not 'overwrite with dummy key'. 11 unit tests cover all branches incl. garbage-input-keeps-key, Ctrl-C at the choice prompt, Replace-cancel preserving the old key, Clear wiping only the target env var, and lmstudio placeholder semantics. Fixes #16394 Reshapes #18355 — original PR pasted the menu inline at 3 sites with no tests; this consolidates to one helper (+88/-66) with coverage. Co-authored-by: Feranmi10 <89228157+Feranmi10@users.noreply.github.com> --- hermes_cli/main.py | 154 +++++++++++++---------- tests/hermes_cli/test_prompt_api_key.py | 157 ++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 66 deletions(-) create mode 100644 tests/hermes_cli/test_prompt_api_key.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2f10d3f47127..4e709a8f83a5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3974,6 +3974,85 @@ def _model_flow_copilot_acp(config, current_model=""): print(f"Default model set to: {selected} (via {pconfig.name})") +def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple: + """Shared API-key entry point for ``hermes setup`` / ``hermes model``. + + Handles both first-time entry and the already-configured case. When a key + is already present, offers [K]eep / [R]eplace / [C]lear so the user can + recover from a malformed paste without editing ``~/.hermes/.env`` by hand. + + Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should + ``return`` immediately — the user cancelled entry, declined to replace, or + cleared the key and is now unconfigured. + """ + import getpass + + from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER + from hermes_cli.config import save_env_value + + key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" + + def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: + if provider_id == "lmstudio" and allow_lmstudio_default: + prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " + else: + prompt = f"{key_env} (or Enter to cancel): " + try: + entered = getpass.getpass(prompt).strip() + except (KeyboardInterrupt, EOFError): + print() + return "" + if not entered and provider_id == "lmstudio" and allow_lmstudio_default: + return LMSTUDIO_NOAUTH_PLACEHOLDER + return entered + + # First-time entry ──────────────────────────────────────────────────── + if not existing_key: + print(f"No {pconfig.name} API key configured.") + if not key_env: + return "", True + new_key = _prompt_new_key(allow_lmstudio_default=True) + if not new_key: + print("Cancelled.") + return "", True + save_env_value(key_env, new_key) + print("API key saved.") + print() + return new_key, False + + # Already configured — offer K / R / C ──────────────────────────────── + print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + if not key_env: + # Nothing we can rewrite; just acknowledge and move on. + print() + return existing_key, False + try: + choice = input(" [K]eep / [R]eplace / [C]lear (default K): ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + choice = "k" + + if choice.startswith("r"): + new_key = _prompt_new_key(allow_lmstudio_default=False) + if not new_key: + print(" No change.") + print() + return existing_key, False + save_env_value(key_env, new_key) + print(" API key updated.") + print() + return new_key, False + + if choice.startswith("c"): + save_env_value(key_env, "") + print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.") + return "", True + + # Keep (default, or any other input) + print() + return existing_key, False + + def _model_flow_kimi(config, current_model=""): """Kimi / Moonshot model selection with automatic endpoint routing. @@ -4008,26 +4087,9 @@ def _model_flow_kimi(config, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - - new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return # Step 2: Auto-detect endpoint from key prefix is_coding_plan = existing_key.startswith("sk-kimi-") @@ -4128,25 +4190,9 @@ def _model_flow_stepfun(config, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return current_base = "" if base_url_env: @@ -4522,33 +4568,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - - if provider_id == "lmstudio": - prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " - else: - prompt = f"{key_env} (or Enter to cancel): " - new_key = getpass.getpass(prompt).strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - if provider_id == "lmstudio": - new_key = LMSTUDIO_NOAUTH_PLACEHOLDER - else: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return # Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash) # are exhausted in a handful of agent turns, so refuse to wire up the diff --git a/tests/hermes_cli/test_prompt_api_key.py b/tests/hermes_cli/test_prompt_api_key.py new file mode 100644 index 000000000000..39be8faa91b8 --- /dev/null +++ b/tests/hermes_cli/test_prompt_api_key.py @@ -0,0 +1,157 @@ +"""Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by +``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``. + +Regression coverage for #16394: the wizard used to silently skip the key prompt +when any value was present (even malformed junk), leaving users stuck. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def profile_env(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + (home / ".env").write_text("") + return home + + +def _pconfig(name="deepseek"): + from hermes_cli.auth import PROVIDER_REGISTRY + return PROVIDER_REGISTRY[name] + + +def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="deepseek"): + """Invoke _prompt_api_key with mocked input()/getpass() responses.""" + from hermes_cli import main as m + + pconfig = _pconfig(pconfig_name) + with patch("builtins.input", return_value=choice), \ + patch("getpass.getpass", return_value=new_key): + return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id) + + +# First-time entry ──────────────────────────────────────────────────────────── + +def test_first_time_save_new_key(profile_env): + from hermes_cli.config import get_env_value + + key, abort = _run_prompt(existing_key="", choice="", new_key="sk-abcdef") + assert key == "sk-abcdef" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-abcdef" + + +def test_first_time_cancelled(profile_env): + key, abort = _run_prompt(existing_key="", choice="", new_key="") + assert key == "" + assert abort is True + + +# Already configured — K / R / C ─────────────────────────────────────────────── + +def test_keep_default_empty_input(profile_env): + from hermes_cli.config import save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + + key, abort = _run_prompt(existing_key="sk-existing", choice="") + assert key == "sk-existing" + assert abort is False + + +def test_keep_letter_k(profile_env): + key, abort = _run_prompt(existing_key="sk-existing", choice="k") + assert key == "sk-existing" + assert abort is False + + +def test_keep_on_unrecognised_input(profile_env): + """Garbage input falls through to keep — never destroys the user's key.""" + key, abort = _run_prompt(existing_key="sk-existing", choice="xyz") + assert key == "sk-existing" + assert abort is False + + +def test_replace_saves_new_key(profile_env): + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-malformed-junk") + + key, abort = _run_prompt( + existing_key="sk-malformed-junk", choice="r", new_key="sk-fresh" + ) + assert key == "sk-fresh" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-fresh" + + +def test_replace_cancelled_preserves_key(profile_env): + """Empty entry to the Replace prompt means cancel — keeps the old key intact.""" + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + + key, abort = _run_prompt( + existing_key="sk-existing", choice="r", new_key="" + ) + assert key == "sk-existing" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-existing" + + +def test_clear_wipes_env_and_aborts(profile_env): + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + save_env_value("OTHER_VAR", "keep-me") + + key, abort = _run_prompt(existing_key="sk-existing", choice="c") + assert key == "" + assert abort is True + # Cleared, but sibling entries untouched. + assert not get_env_value("DEEPSEEK_API_KEY") + assert get_env_value("OTHER_VAR") == "keep-me" + + +def test_ctrl_c_at_choice_prompt_keeps(profile_env): + from hermes_cli import main as m + + pconfig = _pconfig("deepseek") + with patch("builtins.input", side_effect=KeyboardInterrupt): + key, abort = m._prompt_api_key(pconfig, "sk-existing") + assert key == "sk-existing" + assert abort is False + + +# LM Studio no-auth placeholder ──────────────────────────────────────────────── + +def test_lmstudio_first_time_empty_uses_placeholder(profile_env): + from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER + from hermes_cli.config import get_env_value + + key, abort = _run_prompt( + existing_key="", choice="", new_key="", + provider_id="lmstudio", pconfig_name="lmstudio", + ) + assert key == LMSTUDIO_NOAUTH_PLACEHOLDER + assert abort is False + assert get_env_value("LM_API_KEY") == LMSTUDIO_NOAUTH_PLACEHOLDER + + +def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env): + """On REPLACE with empty input, preserve the user's existing key — do NOT + silently substitute the placeholder. The placeholder path only fires for + first-time configuration where the user has made no explicit choice yet.""" + from hermes_cli.config import get_env_value, save_env_value + save_env_value("LM_API_KEY", "my-real-lmstudio-key") + + key, abort = _run_prompt( + existing_key="my-real-lmstudio-key", choice="r", new_key="", + provider_id="lmstudio", pconfig_name="lmstudio", + ) + assert key == "my-real-lmstudio-key" + assert abort is False + assert get_env_value("LM_API_KEY") == "my-real-lmstudio-key" From ca5595fe7b707f7285147623ea2473fb7460b7e7 Mon Sep 17 00:00:00 2001 From: Brecht-H <73849650+Brecht-H@users.noreply.github.com> Date: Tue, 5 May 2026 07:47:20 +0000 Subject: [PATCH 06/99] fix(kanban): dispatcher skips ready tasks whose assignee is not a real profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kanban dispatcher's `_default_spawn` invokes ``hermes -p chat -q ...``. When ``assignee`` names a control-plane lane (e.g. an interactive Claude Code terminal like ``orion-cc`` / ``orion-research``) instead of a real Hermes profile, the subprocess fails on startup with "Profile 'X' does not exist", gets reaped as a zombie, the TTL/crash detector marks the task back to ``ready``, and the next tick re-spawns the same crashing worker. Result: a permanent crash loop emitting ``spawned=2 crashed=2 every tick`` in the gateway log and burning CPU forever. Reproduce on a fresh Hermes-agent install: # 1. Create a kanban task whose assignee names a non-profile. hermes kanban create --assignee orion-cc --status ready \ --title "Review PR #N" --body "..." # 2. Start the gateway with the embedded dispatcher. hermes gateway run # gateway.log lines every minute: # kanban dispatcher: tick spawned=1 reclaimed=0 crashed=1 ... # 3. ps -ef | grep '[h]ermes.*defunct' shows zombies. Fix --- ``dispatch_once()`` now pre-checks ``hermes_cli.profiles. profile_exists(assignee)`` before claiming. If False, the row is added to ``skipped_unassigned`` (it's effectively "unassigned-to-an-executable-profile") and the dispatcher moves on without claiming, spawning, or counting a crash. The check is opt-in safe: if the import fails (e.g. test isolation, profile module restructured), ``profile_exists`` falls back to ``None`` and the original behaviour is preserved unchanged. This addresses the explicit hint in the kanban task body (``t_2bab06e3``): "Should ready-state tasks auto-spawn at all, or only on explicit orion-cc claim? If spurious, gate the auto-spawn behind a config flag (e.g. only assignee=hermes or assignee=auto)." Profile-existence is a tighter gate than a config flag — it self-documents (the user already knows whether they have an ``orion-cc`` profile), and it doesn't require Mac to maintain an allowlist as new lane names appear. New lanes that ARE real profiles (created via ``hermes profile create``) auto- qualify the moment the profile dir is created. Validated live -------------- On Orion's hermes-agent install, two ``orion-research``- assigned tasks (Bug A and Bug C investigations) had been crash-looping since 2026-05-05 06:58 local. After applying the patch + restarting the gateway: - Stale ``running`` claims released to ``ready`` cleanly. - New gateway emitted ``kanban dispatcher: embedded`` and has ticked silently for 2+ minutes — no spawned=, crashed=, or stuck= log lines (all spawn skips are quiet). - Tasks remain ``ready`` with ``claim_lock=None``, ``worker_pid=None``, ``spawn_failures=0``. - Dashboard + telegram + freqtrade unaffected. Confidence: high (live verified on Orion). Scope-risk: narrow (additive guard inside one function). Not-tested: behaviour when a profile is renamed mid-tick — current code re-imports ``profile_exists`` per row so a freshly created profile auto-qualifies on the next tick. Machine: orion-terminal Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/kanban_db.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a58e542ac635..fb2027839253 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2506,6 +2506,23 @@ def dispatch_once( if not row["assignee"]: result.skipped_unassigned.append(row["id"]) continue + # Skip ready tasks whose assignee is not a real Hermes profile. + # `_default_spawn` invokes ``hermes -p `` which fails + # with "Profile 'X' does not exist" when the assignee names a + # control-plane lane (e.g. an interactive Claude Code terminal + # like ``orion-cc`` / ``orion-research``) rather than a Hermes + # profile. Those task lanes are pulled by terminals via + # ``claim_task`` directly and should NEVER auto-spawn — the + # subprocess would crash on startup, get reaped as a zombie, + # the task would loop back to ``ready`` on next tick, and we'd + # burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05). + try: + from hermes_cli.profiles import profile_exists # local import: avoids cycle + except Exception: + profile_exists = None # type: ignore[assignment] + if profile_exists is not None and not profile_exists(row["assignee"]): + result.skipped_unassigned.append(row["id"]) + continue if dry_run: result.spawned.append((row["id"], row["assignee"], "")) continue From f25d3ec9176bc15d9be59ab32dbd4537606d0254 Mon Sep 17 00:00:00 2001 From: Brecht-H <73849650+Brecht-H@users.noreply.github.com> Date: Tue, 5 May 2026 09:43:06 +0000 Subject: [PATCH 07/99] fix(kanban): suppress dispatcher stuck-warn when ready queue holds only non-spawnable assignees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After PR #20105 (dispatcher skips ready tasks whose assignee fails ``profile_exists()`` to prevent the orion-cc/orion-research crash loop), the gateway and CLI emit a spurious "kanban dispatcher stuck: ready queue non-empty for N consecutive ticks but 0 workers spawned" warning every 5 minutes on multi-lane setups where the queue is steadily full of human-pulled work assigned to terminal lanes. The warn is intended to catch real failure modes (broken PATH, missing venv, credential loss for a real Hermes profile). On a multi-lane host it fires forever even though everything is healthy: the dispatcher correctly chose not to spawn, and there is nothing for the operator to fix. Changes: * ``DispatchResult`` gains a ``skipped_nonspawnable`` field (separate from ``skipped_unassigned``) so callers can distinguish "task missing an owner — operator should route it" from "task owned by a control-plane lane — terminal will pull it". * ``dispatch_once`` routes the ``not profile_exists(assignee)`` skip into the new bucket (was lumped into ``skipped_unassigned``). * New helper ``has_spawnable_ready(conn)`` returns True iff at least one ready+assigned+unclaimed task in the DB has an assignee that maps to a real Hermes profile. Falls back to legacy "any ready+assigned" when ``profile_exists`` is unimportable so degraded installs still surface the original warn. * The gateway dispatcher (``gateway/run.py``) and the CLI standalone daemon (``hermes_cli/kanban.py``) both swap their cheap ``ready_nonempty`` probe to use ``has_spawnable_ready``. Stuck-warn now fires only when there is genuine spawnable work the dispatcher failed to start. * CLI dispatch output prints ``Skipped (non-spawnable assignee — terminal lane, OK)`` for visibility without alarm. Tests: * New ``has_spawnable_ready`` cases (empty queue, terminal-lane only, mixed real+terminal). * New ``test_dispatch_skips_nonspawnable_into_separate_bucket`` verifies the bucketing change. * Updated ``test_dispatch_skips_unassigned`` to assert no cross-leak. * Added ``all_assignees_spawnable`` fixture in ``tests/hermes_cli/conftest.py`` and threaded it through dispatcher tests that use synthetic assignees ("alice", "bob"). PR #20105 (the parent commit) silently broke 8 such tests by routing those assignees into ``skipped_nonspawnable`` instead of spawning; this PR repairs them as part of the same code area. Verified locally: 246/246 kanban-suite tests pass. Stacks on top of fix/kanban-dispatcher-skip-missing-profile-2026-05-05 (PR #20105). Reviewer: this PR is meant to merge AFTER #20105. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/run.py | 19 ++++--- hermes_cli/kanban.py | 24 ++++++--- hermes_cli/kanban_db.py | 49 ++++++++++++++++- tests/hermes_cli/conftest.py | 19 +++++++ .../test_kanban_core_functionality.py | 12 ++--- tests/hermes_cli/test_kanban_db.py | 54 +++++++++++++++++-- 6 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 tests/hermes_cli/conftest.py diff --git a/gateway/run.py b/gateway/run.py index 5e2163e830f0..5e10303decd8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3906,7 +3906,17 @@ class GatewayRunner: return out def _ready_nonempty() -> bool: - """Cheap probe: is there a ready+assigned+unclaimed task on ANY board?""" + """Cheap probe: is there at least one ready+assigned+unclaimed + task on ANY board whose assignee maps to a real Hermes profile + (i.e. one the dispatcher would actually spawn for)? + + Tasks assigned to control-plane lanes (e.g. ``orion-cc``, + ``orion-research``) are pulled by terminals via + ``claim_task`` directly and never spawnable, so a queue full + of those is "correctly idle", not "stuck". Filtering them out + here keeps the stuck-warn fire only on real failures (broken + PATH, missing venv, credential loss for a real Hermes profile). + """ try: boards = _kb.list_boards(include_archived=False) except Exception: @@ -3916,12 +3926,7 @@ class GatewayRunner: conn = None try: conn = _kb.connect(board=slug) - row = conn.execute( - "SELECT 1 FROM tasks " - "WHERE status = 'ready' AND assignee IS NOT NULL " - " AND claim_lock IS NULL LIMIT 1" - ).fetchone() - if row is not None: + if _kb.has_spawnable_ready(conn): return True except Exception: continue diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 4befd64fa432..4d738eaff0ff 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -1274,6 +1274,7 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: for (tid, who, ws) in res.spawned ], "skipped_unassigned": res.skipped_unassigned, + "skipped_nonspawnable": res.skipped_nonspawnable, }, indent=2)) return 0 print(f"Reclaimed: {res.reclaimed}") @@ -1293,6 +1294,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int: print(f" - {tid} -> {who} @ {ws or '-'}{tag}") if res.skipped_unassigned: print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}") + if res.skipped_nonspawnable: + print( + f"Skipped (non-spawnable assignee — terminal lane, OK): " + f"{', '.join(res.skipped_nonspawnable)}" + ) return 0 @@ -1404,16 +1410,18 @@ def _cmd_daemon(args: argparse.Namespace) -> int: ) def _ready_queue_nonempty() -> bool: - """Cheap SELECT — just asks whether there's at least one ready - task with an assignee that the dispatcher could have picked up.""" + """Cheap probe — is there at least one ready+assigned+unclaimed + task whose assignee maps to a real Hermes profile (i.e. one the + dispatcher would actually try to spawn for)? + + Filters out tasks assigned to control-plane lanes + (e.g. ``orion-cc``, ``orion-research``) that are pulled by + terminals via ``claim_task`` directly — those are correctly idle + from the dispatcher's perspective, not stuck. + """ try: with kb.connect() as conn: - row = conn.execute( - "SELECT 1 FROM tasks " - "WHERE status = 'ready' AND assignee IS NOT NULL " - " AND claim_lock IS NULL LIMIT 1" - ).fetchone() - return row is not None + return kb.has_spawnable_ready(conn) except Exception: return False diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index fb2027839253..9ed9f512b1cb 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2118,6 +2118,15 @@ class DispatchResult: spawned: list[tuple[str, str, str]] = field(default_factory=list) """List of ``(task_id, assignee, workspace_path)`` triples.""" skipped_unassigned: list[str] = field(default_factory=list) + """Ready task ids skipped because they have no assignee at all. + Operator-actionable — usually a misfiled task waiting for routing.""" + skipped_nonspawnable: list[str] = field(default_factory=list) + """Ready task ids skipped because their assignee names a control-plane + lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes + profile. Expected steady-state on multi-lane setups; NOT an + operator-actionable failure. Tracked separately so health telemetry + can distinguish "real stuck" (nothing spawned but spawnable work + available) from "correctly idle" (nothing spawnable in the queue).""" crashed: list[str] = field(default_factory=list) """Task ids reclaimed because their worker PID disappeared.""" auto_blocked: list[str] = field(default_factory=list) @@ -2459,6 +2468,38 @@ def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None: ) +def has_spawnable_ready(conn: sqlite3.Connection) -> bool: + """Return True iff there is at least one ready+assigned+unclaimed task + whose assignee maps to a real Hermes profile. + + Used by the gateway- and CLI-embedded dispatchers' health telemetry to + decide whether ``0 spawned`` is a "stuck" condition (real spawnable + work waiting) or a "correctly idle" condition (only control-plane + lanes like ``orion-cc`` / ``orion-research`` waiting on terminals + that pull tasks via ``claim_task`` directly). + + Falls back to "any ready+assigned" if ``profile_exists`` is not + importable (e.g. partial install) — preserves the old behavior so + the warning still fires in degraded environments. + """ + rows = conn.execute( + "SELECT DISTINCT assignee FROM tasks " + "WHERE status = 'ready' AND assignee IS NOT NULL " + " AND claim_lock IS NULL" + ).fetchall() + if not rows: + return False + try: + from hermes_cli.profiles import profile_exists # local import: avoids cycle + except Exception: + # Can't introspect — assume spawnable, preserve legacy behavior. + return True + for row in rows: + if profile_exists(row["assignee"]): + return True + return False + + def dispatch_once( conn: sqlite3.Connection, *, @@ -2521,7 +2562,13 @@ def dispatch_once( except Exception: profile_exists = None # type: ignore[assignment] if profile_exists is not None and not profile_exists(row["assignee"]): - result.skipped_unassigned.append(row["id"]) + # Bucket separately from skipped_unassigned: the operator + # cannot fix this by assigning a profile (the assignee IS the + # intended owner — a terminal lane). Health telemetry uses + # this distinction to suppress spurious "stuck" warnings on + # multi-lane setups where the ready queue is steadily full + # of human-pulled work. + result.skipped_nonspawnable.append(row["id"]) continue if dry_run: result.spawned.append((row["id"], row["assignee"], "")) diff --git a/tests/hermes_cli/conftest.py b/tests/hermes_cli/conftest.py new file mode 100644 index 000000000000..531f033e7e08 --- /dev/null +++ b/tests/hermes_cli/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures shared across hermes_cli kanban tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def all_assignees_spawnable(monkeypatch): + """Pretend every assignee maps to a real Hermes profile. + + Most dispatcher tests use synthetic assignees ("alice", "bob") that + don't correspond to actual profile directories on disk. Without this + patch, the dispatcher's profile-exists guard (PR #20105) routes + those tasks into ``skipped_nonspawnable`` instead of spawning, which + would break tests that assert spawn behavior. + """ + from hermes_cli import profiles + monkeypatch.setattr(profiles, "profile_exists", lambda name: True) diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index a7896bf940ec..3c5454b35cca 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -80,7 +80,7 @@ def test_no_idempotency_key_never_collides(kanban_home): # Spawn-failure circuit breaker # --------------------------------------------------------------------------- -def test_spawn_failure_auto_blocks_after_limit(kanban_home): +def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawnable): """N consecutive spawn failures on the same task → auto_blocked.""" def _bad_spawn(task, ws): raise RuntimeError("no PATH") @@ -109,7 +109,7 @@ def test_spawn_failure_auto_blocks_after_limit(kanban_home): conn.close() -def test_successful_spawn_resets_failure_counter(kanban_home): +def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spawnable): """A successful spawn clears the counter so past failures don't count against future retries of the same task.""" calls = [0] @@ -138,7 +138,7 @@ def test_successful_spawn_resets_failure_counter(kanban_home): conn.close() -def test_workspace_resolution_failure_also_counts(kanban_home): +def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable): """`dir:` workspace with no path should fail workspace resolution AND count against the failure budget — not just crash the tick.""" conn = kb.connect() @@ -824,7 +824,7 @@ def test_recompute_ready_emits_promoted_not_ready(kanban_home): conn.close() -def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home): +def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home, all_assignees_spawnable): def _bad(task, ws): raise RuntimeError("nope") conn = kb.connect() @@ -840,7 +840,7 @@ def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home): conn.close() -def test_spawned_event_emitted_with_pid(kanban_home): +def test_spawned_event_emitted_with_pid(kanban_home, all_assignees_spawnable): """Successful spawn must append a ``spawned`` event with the pid in the payload so humans tailing events see pid tracking.""" def _spawn_returns_pid(task, ws): @@ -1154,7 +1154,7 @@ def test_run_on_block_with_reason(kanban_home): conn.close() -def test_run_on_spawn_failure_records_failed_runs(kanban_home): +def test_run_on_spawn_failure_records_failed_runs(kanban_home, all_assignees_spawnable): """Each spawn_failed event closes a run with outcome='spawn_failed', and the Nth failure closes a run with outcome='gave_up'.""" def _bad(task, ws): diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 1907938b429c..e6d25a3d84ca 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -327,7 +327,7 @@ def test_worker_context_includes_parent_results_and_comments(kanban_home): # Dispatcher # --------------------------------------------------------------------------- -def test_dispatch_dry_run_does_not_claim(kanban_home): +def test_dispatch_dry_run_does_not_claim(kanban_home, all_assignees_spawnable): with kb.connect() as conn: t1 = kb.create_task(conn, title="a", assignee="alice") t2 = kb.create_task(conn, title="b", assignee="bob") @@ -344,10 +344,58 @@ def test_dispatch_skips_unassigned(kanban_home): t = kb.create_task(conn, title="floater") res = kb.dispatch_once(conn, dry_run=True) assert t in res.skipped_unassigned + assert t not in res.skipped_nonspawnable assert not res.spawned -def test_dispatch_promotes_ready_and_spawns(kanban_home): +def test_dispatch_skips_nonspawnable_into_separate_bucket(kanban_home, monkeypatch): + """Tasks whose assignee fails profile_exists() must NOT land in + ``skipped_unassigned`` (which is operator-actionable) — they go in + the dedicated ``skipped_nonspawnable`` bucket so health telemetry + can suppress false-positive "stuck" warnings.""" + from hermes_cli import profiles + monkeypatch.setattr(profiles, "profile_exists", lambda name: False) + with kb.connect() as conn: + t = kb.create_task(conn, title="for-terminal", assignee="orion-cc") + res = kb.dispatch_once(conn, dry_run=True) + assert t in res.skipped_nonspawnable + assert t not in res.skipped_unassigned + assert not res.spawned + + +def test_has_spawnable_ready_false_when_only_terminal_lanes(kanban_home, monkeypatch): + """``has_spawnable_ready`` returns False when every ready task is + assigned to a control-plane lane — used by gateway/CLI dispatchers + to silence the stuck-warn while terminals still have queued work.""" + from hermes_cli import profiles + monkeypatch.setattr(profiles, "profile_exists", lambda name: False) + with kb.connect() as conn: + kb.create_task(conn, title="t1", assignee="orion-cc") + kb.create_task(conn, title="t2", assignee="orion-research") + assert kb.has_spawnable_ready(conn) is False + + +def test_has_spawnable_ready_true_when_real_profile_present(kanban_home, monkeypatch): + """``has_spawnable_ready`` returns True as soon as ANY ready task + has an assignee that maps to a real Hermes profile — preserves the + real "stuck" signal when a daily/agent task is queued.""" + from hermes_cli import profiles + monkeypatch.setattr( + profiles, "profile_exists", lambda name: name == "daily" + ) + with kb.connect() as conn: + kb.create_task(conn, title="terminal-task", assignee="orion-cc") + kb.create_task(conn, title="hermes-task", assignee="daily") + assert kb.has_spawnable_ready(conn) is True + + +def test_has_spawnable_ready_false_on_empty_queue(kanban_home): + """Empty queue is the trivial false case — no ready tasks at all.""" + with kb.connect() as conn: + assert kb.has_spawnable_ready(conn) is False + + +def test_dispatch_promotes_ready_and_spawns(kanban_home, all_assignees_spawnable): spawns = [] def fake_spawn(task, workspace): @@ -368,7 +416,7 @@ def test_dispatch_promotes_ready_and_spawns(kanban_home): assert kb.get_task(conn, c).status == "running" -def test_dispatch_spawn_failure_releases_claim(kanban_home): +def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawnable): def boom(task, workspace): raise RuntimeError("spawn failed") From fc4aa66ee4cda20f711416586cf3401a7cb498b7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:15:58 -0700 Subject: [PATCH 08/99] feat(tips): add 100 new CLI startup tips (#20168) Expands TIPS corpus from 280 to 380 entries covering untapped territory across slash commands, CLI flags, env vars, config keys, and platform features. Every tip verified against real code and docs. Batch 1 (50): advanced slash commands (/steer, /goal, /snapshot, /copy, /redraw, /agents, /footer, /busy, /topic, /approve, /restart, /kanban, /reload), no-agent cron, gateway hooks, curator, credential pools, provider routing, TUI/dashboard env vars and themes, checkpoints, Piper TTS, API server, GATEWAY_PROXY_URL, MATRIX_DEVICE_ID, TELEGRAM_WEBHOOK_SECRET, batch_runner --resume. Batch 2 (50): lesser-known slash commands (/new, /clear, /history, /save, /status, /image, /platforms, /commands, /toolsets, /gquota, /voice tts, /reload-skills, /indicator, /debug), CLI subcommands (hermes -z, --pass-session-id, --image, --ignore-user-config, --source tool, dump --show-keys, sessions rename/delete, import, fallback, pairing, setup, status --deep), agent behavior env vars (HERMES_AGENT_TIMEOUT, HERMES_ENABLE_PROJECT_PLUGINS, HERMES_DISABLE_FILE_STATE_GUARD, HERMES_ALLOW_PRIVATE_URLS, HERMES_OPTIONAL_SKILLS, HERMES_BUNDLED_SKILLS, HERMES_DUMP_REQUEST_STDOUT, HERMES_OAUTH_TRACE, HERMES_STREAM_RETRIES), gateway env vars, image_gen config, auxiliary.session_search, tirith_fail_open, source tool filtering, API_SERVER_MODEL_NAME, dashboard plugins. --- hermes_cli/tips.py | 138 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 62fad2eb6adb..c95bc316b725 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -334,6 +334,144 @@ TIPS = [ "MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.", "Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.", "The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.", + + # --- Advanced Slash Commands --- + '/steer injects a note after the next tool call — nudge direction mid-task without interrupting.', + '/goal sets a standing Ralph-loop objective — Hermes auto-continues turn after turn until a judge says done.', + '/snapshot create [label] saves a full state snapshot of Hermes config; /snapshot restore reverts later.', + '/copy [N] copies the last assistant response to your clipboard, or the Nth-from-last with a number.', + '/redraw forces a full UI repaint, fixing terminal drift after tmux resize or mouse selection artifacts.', + '/agents (alias /tasks) shows active agents and running background tasks across the current session.', + '/footer toggles the gateway footer on final replies showing model, tool counts, and turn timing.', + '/busy queue|steer|interrupt controls what pressing Enter does while Hermes is working.', + '/topic in Telegram DMs enables user-managed multi-session topic mode — /topic restores past sessions inline.', + '/approve session|always runs a pending dangerous command with your chosen trust scope; /deny rejects it.', + '/restart gracefully restarts the gateway after draining active runs, then pings the requester when back up.', + '/kanban boards switch changes the active multi-project Kanban board from inside chat.', + '/reload reloads ~/.hermes/.env into the running session — pick up new API keys without restarting.', + + # --- Cron (no-agent & scripts) --- + 'cronjob with no_agent=True runs a script on schedule and sends its stdout directly — zero tokens, zero LLM.', + 'An empty cron script stdout means silent tick — nothing is delivered, perfect for threshold watchdogs.', + "HERMES_CRON_MAX_PARALLEL (default 4) caps how many cron jobs run per tick so bursts don't saturate your keys.", + + # --- Gateway Hooks --- + 'Gateway hooks live under ~/.hermes/hooks// with HOOK.yaml + handler.py — handler must be named `handle`.', + 'Hook events include gateway:startup, session:start, agent:step, and command:* wildcard subscriptions.', + 'Drop a ~/.hermes/BOOT.md checklist and a gateway:startup hook runs it as a one-shot agent every boot.', + + # --- Curator --- + 'hermes curator run --dry-run previews what the curator would archive or consolidate without mutating anything.', + "hermes curator pin hard-fences a skill against both auto-archival and the agent's skill_manage tool.", + 'hermes curator rollback restores skills from a pre-run snapshot — backups live under skills/.curator_backups/.', + + # --- Credential Pools & Routing --- + 'hermes auth reset clears all cooldowns and exhaustion flags on a credential pool.', + 'credential_pool_strategies.: round_robin cycles keys evenly instead of the fill_first default.', + 'use_gateway: true per-tool routes web, image, tts, or browser through your Nous subscription — no extra keys.', + 'provider_routing.data_collection: deny excludes data-storing providers on OpenRouter.', + 'provider_routing.require_parameters: true only routes to providers that support every param in your request.', + + # --- TUI & Dashboard --- + 'HERMES_TUI_RESUME=1 auto-re-attaches to the most recent TUI session on launch — handy after SSH drops.', + "HERMES_TUI_THEME=light|dark| forces the TUI theme on terminals that don't set COLORFGBG.", + 'Ctrl+G or Ctrl+X Ctrl+E in the TUI opens the input buffer in $EDITOR for long multi-line prompts.', + 'The TUI renders LaTeX inline — $E=mc^2$ becomes Unicode math instead of raw TeX.', + 'hermes dashboard launches a local web UI at 127.0.0.1:9119 — zero data leaves localhost.', + 'hermes dashboard --tui embeds the full Hermes TUI in your browser via xterm.js and a WebSocket PTY.', + 'Drop a YAML in ~/.hermes/dashboard-themes/ with two palette colors to reskin the entire dashboard.', + 'Dashboard plugins are drop-in: manifest.json + JS bundle in ~/.hermes/dashboard-plugins/ — no npm build required.', + 'layoutVariant: cockpit in a dashboard theme adds a 260px left rail that plugins can populate via the sidebar slot.', + + # --- Env Vars & Config Gates --- + "display.tool_progress_command: true exposes /verbose on messaging platforms; it's CLI-only by default.", + 'HERMES_BACKGROUND_NOTIFICATIONS=result only pings when background tasks finish (vs all/error/off).', + 'HERMES_WRITE_SAFE_ROOT restricts write_file and patch to a directory prefix; writes outside require approval.', + 'HERMES_IGNORE_RULES skips auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills.', + 'HERMES_ACCEPT_HOOKS auto-approves unseen shell hooks declared in config.yaml without a TTY prompt.', + 'auxiliary.goal_judge.model routes the /goal judge to a cheap fast model to keep loop cost near zero.', + 'Checkpoints skip directories with more than 50,000 files to avoid slow git operations on massive monorepos.', + + # --- TTS --- + 'tts.provider: piper runs 44-language local TTS on CPU — voices auto-download to ~/.hermes/cache/piper-voices/.', + 'tts.providers..type: command wires any CLI TTS engine with {input_path} and {output_path} placeholders.', + + # --- API Server & Proxy --- + 'API_SERVER_ENABLED=true runs an OpenAI-compatible endpoint alongside the gateway for Open WebUI and LibreChat.', + 'GATEWAY_PROXY_URL runs a split setup: platform I/O locally, agent work delegated to a remote API server.', + + # --- Platform-specific --- + 'MATRIX_DEVICE_ID pins a stable device ID for E2EE — without it, keys rotate every start and historic decrypt breaks.', + 'TELEGRAM_WEBHOOK_SECRET is required whenever TELEGRAM_WEBHOOK_URL is set — generate with openssl rand -hex 32.', + + # --- Batch --- + "batch_runner.py --resume content-matches completed prompts by text so dataset reorders don't re-run finished work.", + + # --- Less-Known Slash Commands --- + '/new starts a fresh session in place (alias /reset) — fresh session ID, clean history, CLI stays open.', + '/clear wipes the terminal screen AND starts a new session — one shortcut for a visual reset.', + '/history prints the current conversation in-line without leaving the CLI — useful for a quick re-read.', + '/save writes the current conversation to disk without ending the session.', + '/status shows session info at a glance: ID, title, model, token usage, and elapsed time.', + '/image attaches a local image file for your next prompt without pasting or drag-and-drop.', + '/platforms shows gateway and messaging-platform connection status right from inside chat.', + '/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.', + '/toolsets lists every available toolset so you know what -t/--toolsets accepts.', + '/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.', + '/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.', + '/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.', + '/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.', + '/debug uploads a support bundle (system info + logs) and returns shareable links — works in chat too.', + + # --- CLI Subcommands & Flags --- + 'hermes -z "" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.', + 'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.', + 'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.', + 'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.', + "hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.", + 'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.', + 'hermes sessions rename "new title" renames any past session; hermes sessions delete removes one.', + 'hermes import restores a session export or profile archive produced by sessions export or profile export.', + 'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.', + 'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.', + 'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.', + 'hermes status --deep runs the full health sweep across every component; plain hermes status is the quick view.', + + # --- Agent Behavior Env Vars --- + 'HERMES_AGENT_TIMEOUT=0 disables the gateway inactivity kill for a running agent — use for long research runs.', + 'HERMES_ENABLE_PROJECT_PLUGINS=1 auto-loads repo-local plugins from ./.hermes/plugins/ — trust-gated by design.', + "HERMES_DISABLE_FILE_STATE_GUARD=1 turns off the 'file changed since you read it' guard on patch and write_file.", + 'HERMES_ALLOW_PRIVATE_URLS=true lets web tools hit localhost and private networks — off by default in gateway mode.', + 'HERMES_OPTIONAL_SKILLS=name1,name2 auto-installs extra optional-catalog skills on first run per profile.', + 'HERMES_BUNDLED_SKILLS points at a custom bundled-skill tree — used by Homebrew and Nix packaging.', + 'HERMES_DUMP_REQUEST_STDOUT=1 dumps every API request payload to stdout instead of log files.', + 'HERMES_OAUTH_TRACE=1 logs redacted OAuth token exchange and refresh attempts for debugging provider auth.', + 'HERMES_STREAM_RETRIES (default 3) controls mid-stream reconnect attempts on transient network errors.', + + # --- Gateway Behavior Env Vars --- + 'HERMES_GATEWAY_BUSY_ACK_ENABLED=false silences the ⚡/⏳/⏩ ack messages when a user messages a busy agent.', + 'HERMES_AGENT_NOTIFY_INTERVAL (default 180s) sets how often the gateway pings with progress on long turns.', + 'HERMES_RESTART_DRAIN_TIMEOUT (default 900s) caps how long /restart waits for in-flight runs before forcing.', + 'HERMES_CHECKPOINT_TIMEOUT (default 30s) caps filesystem checkpoint creation — raise it on huge monorepos.', + + # --- Auxiliary Tasks & Image Generation --- + 'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.', + 'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.', + 'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.', + 'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).', + 'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.', + + # --- Security --- + 'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.', + 'TIRITH_FAIL_OPEN env var overrides the tirith_fail_open config — a quick toggle without editing config.yaml.', + + # --- Sessions & Source Tags --- + '--source tool chats are excluded from hermes sessions list by default — set --source explicitly to see them.', + 'Session IDs are timestamp-prefixed (20250305_091523_abcd) so sorting works naturally in ls and jq.', + + # --- Misc --- + 'API_SERVER_MODEL_NAME customizes the model name on /v1/models — essential for multi-profile Open WebUI setups.', + 'Dashboard plugins are served from /dashboard-plugins// — drop files into ~/.hermes/dashboard-plugins/.', ] From 542e06c789f1704ebd3253027b03b6b07a7a8c07 Mon Sep 17 00:00:00 2001 From: Interstellar-code <33978413+Interstellar-code@users.noreply.github.com> Date: Tue, 5 May 2026 10:58:26 +0200 Subject: [PATCH 09/99] fix: include default profile in kanban assignees --- hermes_cli/kanban_db.py | 44 +++++++++++-------- .../test_kanban_core_functionality.py | 10 +++-- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 9ed9f512b1cb..e39808546f7b 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3277,30 +3277,38 @@ def read_worker_log( # --------------------------------------------------------------------------- def list_profiles_on_disk() -> list[str]: - """Return the set of named profiles discovered on disk. + """Return the set of assignee/profile names discovered on disk. - Reads ``~/.hermes/profiles/`` directly so this module has no import - dependency on ``hermes_cli.profiles`` (which pulls in a large chunk - of the CLI startup path). Only returns directories that contain a - ``config.yaml`` — a bare dir without config isn't a real profile. + Includes: + - named profiles under ``/profiles//config.yaml`` + - the implicit ``default`` profile when the default Hermes root exists + + Reads profile paths directly so this module has no import dependency on + ``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup + path). """ try: from hermes_constants import get_default_hermes_root - home = get_default_hermes_root() / "profiles" + default_root = get_default_hermes_root() + profiles_dir = default_root / "profiles" except Exception: return [] - if not home.is_dir(): - return [] - names: list[str] = [] - try: - for entry in sorted(home.iterdir()): - if not entry.is_dir(): - continue - if (entry / "config.yaml").is_file(): - names.append(entry.name) - except OSError: - return names - return names + + names: set[str] = set() + if default_root.exists(): + names.add("default") + + if profiles_dir.is_dir(): + try: + for entry in sorted(profiles_dir.iterdir()): + if not entry.is_dir(): + continue + if (entry / "config.yaml").is_file(): + names.add(entry.name) + except OSError: + pass + + return sorted(names) def known_assignees(conn: sqlite3.Connection) -> list[dict]: diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 3c5454b35cca..3fe09086e50b 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -899,8 +899,8 @@ def test_migration_renames_legacy_event_kinds(tmp_path, monkeypatch): # --------------------------------------------------------------------------- def test_list_profiles_on_disk(tmp_path, monkeypatch): - """list_profiles_on_disk returns directories under ~/.hermes/profiles/ - that contain a config.yaml.""" + """list_profiles_on_disk returns the implicit default profile plus + named profiles under ~/.hermes/profiles/ that contain a config.yaml.""" monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("HERMES_HOME", raising=False) profiles = tmp_path / ".hermes" / "profiles" @@ -914,7 +914,7 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch): (profiles / "stray.txt").write_text("noise") names = kb.list_profiles_on_disk() - assert names == ["researcher", "writer"] + assert names == ["default", "researcher", "writer"] def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch): @@ -928,7 +928,7 @@ def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch): (d / "config.yaml").write_text("model: {}\n") names = kb.list_profiles_on_disk() - assert names == ["researcher", "writer"] + assert names == ["default", "researcher", "writer"] def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch): @@ -955,6 +955,8 @@ def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch): conn.close() by_name = {d["name"]: d for d in data} + assert by_name["default"]["on_disk"] is True + assert by_name["default"]["counts"] == {} assert by_name["researcher"]["on_disk"] is True assert by_name["researcher"]["counts"] == {} assert by_name["writer"]["on_disk"] is True From 28f4d6db63f450828ffe419964719d35bbeedc58 Mon Sep 17 00:00:00 2001 From: Chris Danis Date: Tue, 5 May 2026 04:21:17 -0700 Subject: [PATCH 10/99] fix(tool-schemas): reactive strip of pattern/format on llama.cpp grammar 400s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP servers commonly emit JSON Schema `pattern` (e.g. `\\d{4}-\\d{2}-\\d{2}` for date-time params) and `format` keywords. llama.cpp's `json-schema-to-grammar` converter rejects regex escape classes (\\d/\\w/\\s) and most format values, returning HTTP 400 "parse: error parsing grammar: unknown escape at \\d" — the whole request fails. Cloud providers (OpenAI, Anthropic, OpenRouter, Gemini) accept these keywords fine and use them as prompting hints. Stripping unconditionally loses useful hints for every cloud user to fix a llama.cpp-only bug. Approach: classify the llama.cpp grammar-parse 400 in the error classifier, and on match do a one-shot in-place strip of pattern/format from `self.tools`, then retry. Follows the existing `thinking_signature` recovery pattern. Cloud users hit zero overhead; llama.cpp users pay one failed request per session. Changes - agent/error_classifier.py: new `FailoverReason.llama_cpp_grammar_pattern` + narrow HTTP-400 branch matching "error parsing grammar", "json-schema-to-grammar", or "unable to generate parser ... template". - tools/schema_sanitizer.py: new `strip_pattern_and_format()` helper — reactive, walks schema nodes, skips property names (search_files.pattern survives). Returns strip count for logging. - run_agent.py: new one-shot recovery block in the retry loop. Strips, logs, continues. Falls through to normal retry if nothing to strip. - tests: 4 classifier tests (3 variants + 1 non-400 negative), 7 strip tests including the property-name preservation and idempotency checks. Co-authored-by: Chris Danis --- agent/error_classifier.py | 26 +++++++ run_agent.py | 44 ++++++++++++ tests/agent/test_error_classifier.py | 38 ++++++++++ tests/tools/test_schema_sanitizer.py | 101 ++++++++++++++++++++++++++- tools/schema_sanitizer.py | 72 +++++++++++++++++++ 5 files changed, 280 insertions(+), 1 deletion(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 67feaa4304f5..419a984b75e6 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -55,6 +55,7 @@ class FailoverReason(enum.Enum): thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry + llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp json-schema-to-grammar rejects regex escapes in `pattern` / `format` — strip from tools and retry # Catch-all unknown = "unknown" # Unclassifiable — retry with backoff @@ -470,6 +471,31 @@ def classify_api_error( should_compress=False, ) + # llama.cpp's ``json-schema-to-grammar`` converter (used by its OAI + # server to build GBNF tool-call parsers) rejects regex escape classes + # like ``\d``/``\w``/``\s`` and most ``format`` values. MCP servers + # routinely emit ``"pattern": "\\d{4}-\\d{2}-\\d{2}"`` for date/phone/ + # email params. llama.cpp surfaces this as HTTP 400 with one of a few + # recognizable phrases; on match we strip ``pattern``/``format`` from + # ``self.tools`` in the retry loop and retry once. Cloud providers are + # unaffected — they accept these keywords and we never hit this branch. + if ( + status_code == 400 + and ( + "error parsing grammar" in error_msg + or "json-schema-to-grammar" in error_msg + or ( + "unable to generate parser" in error_msg + and "template" in error_msg + ) + ) + ): + return _result( + FailoverReason.llama_cpp_grammar_pattern, + retryable=True, + should_compress=False, + ) + # ── 2. HTTP status code classification ────────────────────────── if status_code is not None: diff --git a/run_agent.py b/run_agent.py index 8e1549925bd7..4d8ffa1908fa 100644 --- a/run_agent.py +++ b/run_agent.py @@ -11116,6 +11116,7 @@ class AIAgent: thinking_sig_retry_attempted = False image_shrink_retry_attempted = False oauth_1m_beta_retry_attempted = False + llama_cpp_grammar_retry_attempted = False has_retried_429 = False restart_with_compressed_messages = False restart_with_length_continuation = False @@ -12206,6 +12207,49 @@ class AIAgent: ) continue + # ── llama.cpp grammar-parse recovery ────────────────── + # llama.cpp's ``json-schema-to-grammar`` converter rejects + # regex escape classes (``\d``, ``\w``, ``\s``) and most + # ``format`` values in tool schemas. MCP servers emit + # these routinely for date/phone/email params. Recovery: + # strip ``pattern``/``format`` from ``self.tools`` and + # retry once. We keep the keywords by default so cloud + # providers get the full prompting hints; this branch + # fires only for users on llama.cpp's OAI server. + if ( + classified.reason == FailoverReason.llama_cpp_grammar_pattern + and not llama_cpp_grammar_retry_attempted + ): + llama_cpp_grammar_retry_attempted = True + try: + from tools.schema_sanitizer import strip_pattern_and_format + _, _stripped = strip_pattern_and_format(self.tools) + except Exception as _strip_exc: # pragma: no cover — defensive + logging.warning( + "%sllama.cpp grammar recovery: strip helper failed: %s", + self.log_prefix, _strip_exc, + ) + _stripped = 0 + if _stripped: + self._vprint( + f"{self.log_prefix}⚠️ llama.cpp rejected tool schema grammar — " + f"stripped {_stripped} pattern/format keyword(s), retrying...", + force=True, + ) + logging.warning( + "%sllama.cpp grammar recovery: stripped %d " + "pattern/format keyword(s) from tool schemas", + self.log_prefix, _stripped, + ) + continue + # No keywords found to strip — fall through to normal + # retry path rather than loop forever on the same error. + logging.warning( + "%sllama.cpp grammar error but no pattern/format " + "keywords to strip — falling through to normal retry", + self.log_prefix, + ) + retry_count += 1 elapsed_time = time.time() - api_start_time self._touch_activity( diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 5a2879734906..d3f62c847c70 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -59,6 +59,7 @@ class TestFailoverReason: "provider_policy_blocked", "thinking_signature", "long_context_tier", "oauth_long_context_beta_forbidden", + "llama_cpp_grammar_pattern", "unknown", } actual = {r.value for r in FailoverReason} @@ -475,6 +476,43 @@ class TestClassifyApiError: # Without "thinking" in the message, it shouldn't be thinking_signature assert result.reason != FailoverReason.thinking_signature + # ── Provider-specific: llama.cpp grammar-parse ── + + def test_llama_cpp_grammar_parse_error(self): + """llama.cpp rejects regex escapes in JSON Schema `pattern`.""" + e = MockAPIError( + "parse: error parsing grammar: unknown escape at \\d", + status_code=400, + ) + result = classify_api_error(e, provider="openai-compatible") + assert result.reason == FailoverReason.llama_cpp_grammar_pattern + assert result.retryable is True + assert result.should_compress is False + + def test_llama_cpp_unable_to_generate_parser(self): + """Older llama.cpp builds surface the error as 'unable to generate parser'.""" + e = MockAPIError( + "Unable to generate parser for this template", + status_code=400, + ) + result = classify_api_error(e, provider="openai-compatible") + assert result.reason == FailoverReason.llama_cpp_grammar_pattern + + def test_llama_cpp_json_schema_to_grammar_phrase(self): + """Some builds mention the module name explicitly.""" + e = MockAPIError( + "json-schema-to-grammar failed to convert schema", + status_code=400, + ) + result = classify_api_error(e, provider="openai-compatible") + assert result.reason == FailoverReason.llama_cpp_grammar_pattern + + def test_llama_cpp_grammar_requires_400(self): + """A 500 with the same phrase isn't the llama.cpp grammar case.""" + e = MockAPIError("error parsing grammar", status_code=500) + result = classify_api_error(e, provider="openai-compatible") + assert result.reason != FailoverReason.llama_cpp_grammar_pattern + # ── Provider-specific: Anthropic long-context tier ── def test_anthropic_long_context_tier(self): diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index 171651ca7a21..cc54fbfeb025 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -9,7 +9,7 @@ from __future__ import annotations import copy -from tools.schema_sanitizer import sanitize_tool_schemas +from tools.schema_sanitizer import sanitize_tool_schemas, strip_pattern_and_format def _tool(name: str, parameters: dict) -> dict: @@ -203,3 +203,102 @@ def test_empty_tools_list_returns_empty(): def test_none_tools_returns_none(): assert sanitize_tool_schemas(None) is None + + +# ───────────────────────────────────────────────────────────────────────── +# strip_pattern_and_format — reactive recovery when llama.cpp rejects a +# schema with an HTTP 400 grammar-parse error. Must be opt-in (only +# invoked on recovery) and must not damage property names. +# ───────────────────────────────────────────────────────────────────────── + + +def test_strip_pattern_removes_schema_pattern_keyword(): + """`pattern` as a sibling of `type` → stripped.""" + tools = [_tool("t", { + "type": "object", + "properties": { + "date": {"type": "string", "pattern": "\\d{4,4}-\\d{2,2}-\\d{2,2}"}, + }, + })] + _, stripped = strip_pattern_and_format(tools) + assert stripped == 1 + prop = tools[0]["function"]["parameters"]["properties"]["date"] + assert "pattern" not in prop + assert prop["type"] == "string" + + +def test_strip_format_removes_schema_format_keyword(): + """`format` as a sibling of `type` → stripped.""" + tools = [_tool("t", { + "type": "object", + "properties": { + "ts": {"type": "string", "format": "date-time"}, + }, + })] + _, stripped = strip_pattern_and_format(tools) + assert stripped == 1 + assert "format" not in tools[0]["function"]["parameters"]["properties"]["ts"] + + +def test_strip_preserves_property_named_pattern(): + """Property literally *named* 'pattern' (search_files) must survive.""" + tools = [_tool("search_files", { + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "Regex pattern..."}, + "limit": {"type": "integer"}, + }, + "required": ["pattern"], + })] + _, stripped = strip_pattern_and_format(tools) + assert stripped == 0 + params = tools[0]["function"]["parameters"] + # Property named "pattern" still exists with its schema intact + assert "pattern" in params["properties"] + assert params["properties"]["pattern"]["type"] == "string" + assert params["required"] == ["pattern"] + + +def test_strip_recurses_into_anyof_variants(): + """Pattern/format inside anyOf variant schemas are also stripped.""" + tools = [_tool("t", { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string", "pattern": "[A-Z]+", "format": "uuid"}, + {"type": "integer"}, + ], + }, + }, + })] + _, stripped = strip_pattern_and_format(tools) + assert stripped == 2 + variants = tools[0]["function"]["parameters"]["properties"]["value"]["anyOf"] + assert "pattern" not in variants[0] + assert "format" not in variants[0] + assert variants[0]["type"] == "string" + + +def test_strip_is_idempotent(): + """Second call on already-stripped tools is a no-op.""" + tools = [_tool("t", { + "type": "object", + "properties": {"d": {"type": "string", "pattern": "\\d+"}}, + })] + _, first = strip_pattern_and_format(tools) + _, second = strip_pattern_and_format(tools) + assert first == 1 + assert second == 0 + + +def test_strip_empty_tools_returns_zero(): + tools, stripped = strip_pattern_and_format([]) + assert tools == [] + assert stripped == 0 + + +def test_strip_none_returns_zero(): + tools, stripped = strip_pattern_and_format(None) + assert tools is None + assert stripped == 0 diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index de43b131b675..8c0a915acabe 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -255,3 +255,75 @@ def _sanitize_node(node: Any, path: str) -> Any: out["required"] = valid return out + + +# ============================================================================= +# Reactive strip — only invoked when llama.cpp rejects a schema +# ============================================================================= + +_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"}) + + +def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]: + """Strip ``pattern`` and ``format`` JSON Schema keywords from tool schemas. + + This is a *reactive* sanitizer invoked only when llama.cpp's + ``json-schema-to-grammar`` converter has rejected a tool schema with an + HTTP 400 grammar-parse error. llama.cpp's regex engine supports only a + small subset of ECMAScript regex (literals, ``.``, ``[...]``, ``|``, + ``*``, ``+``, ``?``, ``{n,m}``) — it rejects escape classes like ``\\d``, + ``\\w``, ``\\s`` and most ``format`` values. Cloud providers (OpenAI, + Anthropic, OpenRouter, Gemini) accept these keywords fine and rely on + them as prompting hints, so we keep them in the default schema and only + strip on demand. + + The strip operates on a sibling of ``type`` (so schema keywords are + removed) — a property literally *named* ``pattern`` (e.g. the first arg + of the built-in ``search_files`` tool) is not affected because property + names live in the ``properties`` dict, not as siblings of ``type``. + + Args: + tools: OpenAI-format tool list, mutated in place for efficiency. + Callers that need to preserve the original should deep-copy first. + + Returns: + ``(tools, stripped_count)`` — the same list reference plus a count of + how many ``pattern``/``format`` keywords were removed across all tools. + """ + if not tools: + return tools, 0 + + stripped = 0 + + def _walk(node: Any) -> None: + nonlocal stripped + if isinstance(node, dict): + # Only strip as a sibling of ``type`` — i.e. when this node is + # itself a schema. This avoids stripping literal property keys + # named "pattern" (search_files.pattern, etc.) because those live + # inside a ``properties`` dict, not as siblings of ``type``. + is_schema_node = "type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node + for key in list(node.keys()): + if is_schema_node and key in _STRIP_ON_RECOVERY_KEYS: + node.pop(key, None) + stripped += 1 + continue + _walk(node[key]) + elif isinstance(node, list): + for item in node: + _walk(item) + + for tool in tools: + fn = tool.get("function") if isinstance(tool, dict) else None + if isinstance(fn, dict): + params = fn.get("parameters") + if isinstance(params, dict): + _walk(params) + + if stripped: + logger.info( + "schema_sanitizer: stripped %d pattern/format keyword(s) from " + "tool schemas (llama.cpp grammar-parse recovery)", + stripped, + ) + return tools, stripped From 2a285d5ec228e5df782957ed1b29d1df44e2a3ae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:33:38 -0700 Subject: [PATCH 11/99] fix(agent): stateful streaming scrubber for reasoning-block leaks (#17924) (#20184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * revert(gateway): remove stale-code self-check and auto-restart Removes the _detect_stale_code / _trigger_stale_code_restart mechanism introduced in #17648 and iterated in #19740. On every incoming message the gateway compared the boot-time git HEAD SHA to the current SHA on disk, and if they differed it would reply with Gateway code was updated in the background -- restarting this gateway so your next message runs on the new code. Please retry in a moment. and then kick off a graceful restart. This is unwanted behaviour: users who run a long-lived gateway and do their own ad-hoc git operations on the checkout end up with their chat interrupted and the current message dropped every time HEAD moves, with no way to opt out. If an operator really needs the old protection against stale sys.modules after "hermes update", the SIGKILL-survivor sweep in hermes update (hermes_cli/main.py, also tagged #17648) already handles the supervisor-respawn case on its own. Removed: gateway/run.py: - _STALE_CODE_SENTINELS, _GIT_SHA_CACHE_TTL_SECS - _read_git_head_sha(), _compute_repo_mtime() module helpers - class-level _boot_wall_time / _boot_repo_mtime / _boot_git_sha / _stale_code_restart_triggered defaults - __init__ boot-snapshot block (_boot_*, _cached_current_sha*, _repo_root_for_staleness, _stale_code_notified) - _current_git_sha_cached(), _detect_stale_code(), _trigger_stale_code_restart() methods - stale-code check + user-facing restart notice at the top of _handle_message() tests/gateway/test_stale_code_self_check.py (deleted, 412 lines) No new logic added. Zero remaining references to any removed symbol. Gateway test suite passes the same 4589 tests it passed before; the 3 pre-existing unrelated failures (discord free-channel, feishu bot admission, teams typing) are unchanged by this commit. * fix(agent): stateful streaming scrubber for reasoning-block leaks (#17924) Per-delta _strip_think_blocks ran at _fire_stream_delta and destroyed downstream state. When MiniMax-M2.7 / DeepSeek / Qwen3 streamed a tag split across deltas (delta1='', delta2='Let me check'), the regex case-2 match erased delta1 entirely, so CLI/gateway state machines never learned a block was open and leaked delta2 as content. Raw consumers (ACP, api_server, TTS) had no downstream defense at all. Replace the per-delta regex with a stateful StreamingThinkScrubber that survives delta boundaries: - Closed X pairs always stripped (matches _strip_think_blocks case 1). - Unterminated open at block boundary enters a block; content discarded until close tag arrives. At end-of-stream, held content is dropped. - Orphan close tags stripped without boundary gating. - Partial tags at delta boundaries held back until resolved. - Block-boundary rule (start-of-stream, after \n, or whitespace-only since last \n) preserves prose that mentions tag names. Reset at turn start alongside the existing context scrubber; flush at turn end so a benign '<' held back at end-of-stream reaches the UI. E2E-verified on live OpenRouter->MiniMax-m2 streams: closed pairs strip cleanly, first word of post-block content is preserved, pure content passes through unchanged. Stefan's screenshot case (#17924) — 'Let me check' getting chopped to ' me check' — no longer happens. Final _strip_think_blocks calls on completed strings (final_response, replay, compression) are preserved; only the streaming per-delta call site switched to the scrubber. --- agent/think_scrubber.py | 386 ++++++++++++++++++ gateway/run.py | 294 -------------- run_agent.py | 53 ++- tests/agent/test_think_scrubber.py | 229 +++++++++++ tests/gateway/test_stale_code_self_check.py | 412 -------------------- 5 files changed, 665 insertions(+), 709 deletions(-) create mode 100644 agent/think_scrubber.py create mode 100644 tests/agent/test_think_scrubber.py delete mode 100644 tests/gateway/test_stale_code_self_check.py diff --git a/agent/think_scrubber.py b/agent/think_scrubber.py new file mode 100644 index 000000000000..44ddcacff704 --- /dev/null +++ b/agent/think_scrubber.py @@ -0,0 +1,386 @@ +"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text. + +``run_agent._strip_think_blocks`` is regex-based and correct for a complete +string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys +the state that downstream consumers (CLI ``_stream_delta``, gateway +``GatewayStreamConsumer._filter_and_accumulate``) rely on. + +Concretely, when MiniMax-M2.7 streams + + delta1 = "" + delta2 = "Let me check their config" + delta3 = "" + +the per-delta regex erases delta1 entirely (case 2: unterminated-open at +boundary matches ``^...``), so the downstream state machine never +sees the open tag, treats delta2 as regular content, and leaks reasoning +to the user. Consumers that don't run their own state machine (ACP, +api_server, TTS) never had any defence at all — they just emitted +whatever survived the upstream regex. + +This module centralises the tag-suppression state machine at the +upstream layer so every stream_delta_callback sees text that has +already had reasoning blocks removed. Partial tags at delta +boundaries are held back until the next delta resolves them, and +end-of-stream flushing surfaces any held-back prose that turned out +not to be a real tag. + +Usage:: + + scrubber = StreamingThinkScrubber() + for delta in stream: + visible = scrubber.feed(delta) + if visible: + emit(visible) + tail = scrubber.flush() # at end of stream + if tail: + emit(tail) + +The scrubber is re-entrant per agent instance. Call ``reset()`` at +the top of each new turn so a hung block from an interrupted prior +stream cannot taint the next turn's output. + +Tag variants handled (case-insensitive): + ````, ````, ````, ````, + ````. + +Block-boundary rule for opens: an opening tag is only treated as a +reasoning-block opener when it appears at the start of the stream, +after a newline (optionally followed by whitespace), or when only +whitespace has been emitted on the current line. This prevents prose +that *mentions* the tag name (e.g. ``"use tags here"``) from +being incorrectly suppressed. Closed pairs (``X``) are +always suppressed regardless of boundary; a closed pair is an +intentional, bounded construct. +""" + +from __future__ import annotations + +from typing import Tuple + +__all__ = ["StreamingThinkScrubber"] + + +class StreamingThinkScrubber: + """Stateful scrubber for streaming reasoning/thinking blocks. + + State machine: + - ``_in_block``: True while inside an opened block, waiting for + a close tag. All text inside is discarded. + - ``_buf``: held-back partial-tag tail. Emitted / discarded on + the next ``feed()`` call or by ``flush()``. + - ``_last_emitted_ended_newline``: True iff the most recent + emission to the consumer ended with ``\\n``, or nothing has + been emitted yet (start-of-stream counts as a boundary). Used + to decide whether an open tag at buffer position 0 is at a + block boundary. + """ + + _OPEN_TAG_NAMES: Tuple[str, ...] = ( + "think", + "thinking", + "reasoning", + "thought", + "REASONING_SCRATCHPAD", + ) + + # Materialise literal tag strings so the hot path does string + # operations, not regex compilation per feed(). + _OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES) + _CLOSE_TAGS: Tuple[str, ...] = tuple(f"" for name in _OPEN_TAG_NAMES) + + # Pre-compute the longest tag (for partial-tag hold-back bound). + _MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS) + + def __init__(self) -> None: + self._in_block: bool = False + self._buf: str = "" + self._last_emitted_ended_newline: bool = True + + def reset(self) -> None: + """Reset all state. Call at the top of every new turn.""" + self._in_block = False + self._buf = "" + self._last_emitted_ended_newline = True + + def feed(self, text: str) -> str: + """Feed one delta; return the scrubbed visible portion. + + May return an empty string when the entire delta is reasoning + content or is being held back pending resolution of a partial + tag at the boundary. + """ + if not text: + return "" + buf = self._buf + text + self._buf = "" + out: list[str] = [] + + while buf: + if self._in_block: + # Hunt for the earliest close tag. + close_idx, close_len = self._find_first_tag( + buf, self._CLOSE_TAGS, + ) + if close_idx == -1: + # No close yet — hold back a potential partial + # close-tag prefix; discard everything else. + held = self._max_partial_suffix(buf, self._CLOSE_TAGS) + self._buf = buf[-held:] if held else "" + return "".join(out) + # Found close: discard block content + tag, continue. + buf = buf[close_idx + close_len:] + self._in_block = False + else: + # Priority 1 — closed X pair anywhere in + # buf. Closed pairs are always an intentional, + # bounded construct (even mid-line prose containing + # an open/close pair is almost certainly a model + # leaking reasoning inline), so no boundary gating. + pair = self._find_earliest_closed_pair(buf) + # Priority 2 — unterminated open tag at a block + # boundary. Boundary-gated so prose that mentions + # '' isn't over-stripped. + open_idx, open_len = self._find_open_at_boundary( + buf, out, + ) + + # Pick whichever match comes earliest in the buffer. + if pair is not None and ( + open_idx == -1 or pair[0] <= open_idx + ): + start_idx, end_idx = pair + preceding = buf[:start_idx] + if preceding: + preceding = self._strip_orphan_close_tags(preceding) + if preceding: + out.append(preceding) + self._last_emitted_ended_newline = ( + preceding.endswith("\n") + ) + buf = buf[end_idx:] + continue + + if open_idx != -1: + # Unterminated open at boundary — emit preceding, + # enter block, continue loop with remainder. + preceding = buf[:open_idx] + if preceding: + preceding = self._strip_orphan_close_tags(preceding) + if preceding: + out.append(preceding) + self._last_emitted_ended_newline = ( + preceding.endswith("\n") + ) + self._in_block = True + buf = buf[open_idx + open_len:] + continue + + # No resolvable tag structure in buf. Hold back any + # partial-tag prefix at the tail so a split tag + # across deltas isn't missed, then emit the rest. + held = self._max_partial_suffix(buf, self._OPEN_TAGS) + held_close = self._max_partial_suffix( + buf, self._CLOSE_TAGS, + ) + held = max(held, held_close) + if held: + emit_text = buf[:-held] + self._buf = buf[-held:] + else: + emit_text = buf + self._buf = "" + if emit_text: + emit_text = self._strip_orphan_close_tags(emit_text) + if emit_text: + out.append(emit_text) + self._last_emitted_ended_newline = ( + emit_text.endswith("\n") + ) + return "".join(out) + + return "".join(out) + + def flush(self) -> str: + """End-of-stream flush. + + If still inside an unterminated block, held-back content is + discarded — leaking partial reasoning is worse than a + truncated answer. Otherwise the held-back partial-tag tail is + emitted verbatim (it turned out not to be a real tag prefix). + """ + if self._in_block: + self._buf = "" + self._in_block = False + return "" + tail = self._buf + self._buf = "" + if not tail: + return "" + tail = self._strip_orphan_close_tags(tail) + if tail: + self._last_emitted_ended_newline = tail.endswith("\n") + return tail + + # ── internal helpers ─────────────────────────────────────────────── + + @staticmethod + def _find_first_tag( + buf: str, tags: Tuple[str, ...], + ) -> Tuple[int, int]: + """Return (earliest_index, tag_length) over *tags*, or (-1, 0). + + Case-insensitive match. + """ + buf_lower = buf.lower() + best_idx = -1 + best_len = 0 + for tag in tags: + idx = buf_lower.find(tag.lower()) + if idx != -1 and (best_idx == -1 or idx < best_idx): + best_idx = idx + best_len = len(tag) + return best_idx, best_len + + def _find_earliest_closed_pair(self, buf: str): + """Return (start_idx, end_idx) of the earliest closed pair, else None. + + A closed pair is ``...`` of any variant. Matches are + case-insensitive and non-greedy (the closest close tag after + an open tag wins), matching the regex ``.*?`` + semantics of ``_strip_think_blocks`` case 1. When two tag + variants could both match, the one whose open tag appears + earlier wins. + """ + buf_lower = buf.lower() + best: "tuple[int, int] | None" = None + for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS): + open_lower = open_tag.lower() + close_lower = close_tag.lower() + open_idx = buf_lower.find(open_lower) + if open_idx == -1: + continue + close_idx = buf_lower.find( + close_lower, open_idx + len(open_lower), + ) + if close_idx == -1: + continue + end_idx = close_idx + len(close_lower) + if best is None or open_idx < best[0]: + best = (open_idx, end_idx) + return best + + def _find_open_at_boundary( + self, buf: str, already_emitted: list[str], + ) -> Tuple[int, int]: + """Return the earliest block-boundary open-tag (idx, len). + + Returns (-1, 0) if no boundary-legal opener is present. + """ + buf_lower = buf.lower() + best_idx = -1 + best_len = 0 + for tag in self._OPEN_TAGS: + tag_lower = tag.lower() + search_start = 0 + while True: + idx = buf_lower.find(tag_lower, search_start) + if idx == -1: + break + if self._is_block_boundary(buf, idx, already_emitted): + if best_idx == -1 or idx < best_idx: + best_idx = idx + best_len = len(tag) + break # first boundary hit for this tag is enough + search_start = idx + 1 + return best_idx, best_len + + def _is_block_boundary( + self, buf: str, idx: int, already_emitted: list[str], + ) -> bool: + """True iff position *idx* in *buf* is a block boundary. + + A block boundary is: + - buf position 0 AND the most recent emission ended with + a newline (or nothing has been emitted yet) + - any position whose preceding text on the current line + (since the last newline in buf) is whitespace-only, AND + if there is no newline in the preceding buf portion, the + most recent prior emission ended with a newline + """ + if idx == 0: + # Check whether the last already-emitted chunk in THIS + # feed() call ended with a newline, otherwise fall back + # to the cross-feed flag. + if already_emitted: + return already_emitted[-1].endswith("\n") + return self._last_emitted_ended_newline + preceding = buf[:idx] + last_nl = preceding.rfind("\n") + if last_nl == -1: + # No newline in buf before the tag — boundary only if the + # prior emission ended with a newline AND everything since + # is whitespace. + if already_emitted: + prior_newline = already_emitted[-1].endswith("\n") + else: + prior_newline = self._last_emitted_ended_newline + return prior_newline and preceding.strip() == "" + # Newline present — text between it and the tag must be + # whitespace-only. + return preceding[last_nl + 1:].strip() == "" + + @classmethod + def _max_partial_suffix( + cls, buf: str, tags: Tuple[str, ...], + ) -> int: + """Return the longest buf-suffix that is a prefix of any tag. + + Only prefixes strictly shorter than the tag itself count + (full-length suffixes are the tag and are handled as matches, + not held-back partials). Case-insensitive. + """ + if not buf: + return 0 + buf_lower = buf.lower() + max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1) + for i in range(max_check, 0, -1): + suffix = buf_lower[-i:] + for tag in tags: + tag_lower = tag.lower() + if len(tag_lower) > i and tag_lower.startswith(suffix): + return i + return 0 + + @classmethod + def _strip_orphan_close_tags(cls, text: str) -> str: + """Remove any close tags from *text* (orphan-close handling). + + An orphan close tag has no matching open in the current + scrubber state; it's always noise, stripped with any trailing + whitespace so the surrounding prose flows naturally. + """ + if " str: _AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60 -# --- Stale-code self-check ------------------------------------------------ -# Long-running gateway processes that survive an ``hermes update`` keep the -# old ``hermes_cli.config`` (and friends) cached in ``sys.modules``. When -# the updated tool files on disk then try to ``from hermes_cli.config -# import cfg_get`` (added in PR #17304), the import resolves against the -# already-loaded stale module object and raises ``ImportError`` — see -# Issue #17648. Rather than papering over the import failure site-by-site -# in every tool file, detect the stale state centrally and auto-restart -# so the gateway reloads with fresh code. -# -# The signal we use is ``git rev-parse HEAD`` — the only thing ``hermes -# update`` moves that is NOT moved by agent-driven file edits. Earlier -# revisions of this check compared file mtimes across a sentinel set -# (run_agent.py, gateway/run.py, ...), but that produced false positives -# whenever the agent edited its own source files during a session: -# mtime jumps, stale-check fires, gateway restarts, user must retype. -# See the conversation at PR # for the motivating incident. -# -# The legacy mtime sentinels are kept ONLY as a last-resort fallback for -# non-git installs (pip install from wheel, sparse clones with no .git -# dir). In those environments ``hermes update`` is not a supported path, -# so the check effectively no-ops — which is the safe behavior: better -# to ship one broken import than to restart on every agent-edit. -_STALE_CODE_SENTINELS: tuple[str, ...] = ( - "hermes_cli/config.py", - "hermes_cli/__init__.py", - "run_agent.py", - "gateway/run.py", - "pyproject.toml", -) - -# Cache git HEAD reads across consecutive messages so a chat burst doesn't -# spawn one subprocess per message. 5s is long enough to collapse a burst -# and short enough that the real post-update detection still fires within -# the user's perceived "next message" window. -_GIT_SHA_CACHE_TTL_SECS = 5.0 - - -def _read_git_head_sha(repo_root: Path) -> Optional[str]: - """Return the git HEAD SHA for ``repo_root``, or None if unavailable. - - Reads ``.git/HEAD`` directly (and follows one level of ref) instead - of shelling out to ``git`` — cheaper, no subprocess tax, works on - gateway hosts that don't have a ``git`` binary on PATH. Returns - None for non-git installs (no ``.git`` dir) or any I/O error; callers - treat None as "can't tell" and skip the check. - - Supports the three layouts we care about: - 1. Main checkout: ``/.git/`` is a directory. - 2. Git worktree: ``/.git`` is a file ``gitdir: `` that - points at ``
/.git/worktrees//``. The worktree's - gitdir has HEAD + index but NOT refs/heads/ — those live in - the main checkout, and ``/commondir`` points - at the main ``.git``. We search both locations for refs. - 3. Packed refs: ``refs/heads/`` is absent on disk but - listed in ``/packed-refs``. - """ - try: - git_dir = repo_root / ".git" - # Worktrees store ``.git`` as a file pointing at gitdir: - if git_dir.is_file(): - try: - content = git_dir.read_text().strip() - if content.startswith("gitdir:"): - git_dir = Path(content.split(":", 1)[1].strip()) - if not git_dir.is_absolute(): - git_dir = (repo_root / git_dir).resolve() - except OSError: - return None - if not git_dir.is_dir(): - return None - - # Figure out the "common" git dir — the one that owns shared refs. - # For a worktree, commondir points at it (relative path, resolve - # against git_dir). For a main checkout, common_dir == git_dir. - common_dir = git_dir - commondir_file = git_dir / "commondir" - if commondir_file.is_file(): - try: - rel = commondir_file.read_text().strip() - candidate = (git_dir / rel).resolve() if rel else git_dir - if candidate.is_dir(): - common_dir = candidate - except OSError: - pass - - head_path = git_dir / "HEAD" - if not head_path.is_file(): - return None - head_content = head_path.read_text().strip() - - if head_content.startswith("ref:"): - # Symbolic ref — follow one level (e.g. ref: refs/heads/main). - # Worktree-local refs (bisect, rebase-merge state) live under - # git_dir; shared refs (refs/heads/*, refs/tags/*) live under - # common_dir. Try git_dir first, then common_dir. - ref_rel = head_content.split(":", 1)[1].strip() - for base in (git_dir, common_dir) if git_dir != common_dir else (git_dir,): - ref_path = base / ref_rel - if ref_path.is_file(): - try: - sha = ref_path.read_text().strip() - except OSError: - continue - if sha: - return sha - # Packed refs fallback — always stored in the common dir. - packed = common_dir / "packed-refs" - if packed.is_file(): - try: - for line in packed.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#") or line.startswith("^"): - continue - parts = line.split(None, 1) - if len(parts) == 2 and parts[1] == ref_rel: - return parts[0] or None - except OSError: - return None - return None - - # Detached HEAD — content is the SHA directly. - return head_content or None - except Exception: - return None - - -def _compute_repo_mtime(repo_root: Path) -> float: - """Return the newest mtime across the stale-code sentinel files. - - Legacy fallback used only for non-git installs (``.git`` missing). - Missing files are ignored (they may not exist on older checkouts). - Returns 0.0 if no sentinel file is readable — treat that as "can't - tell", which downstream callers interpret as "not stale" to avoid - false-positive restart loops. - """ - newest = 0.0 - for rel in _STALE_CODE_SENTINELS: - try: - st = (repo_root / rel).stat() - except (OSError, FileNotFoundError): - continue - if st.st_mtime > newest: - newest = st.st_mtime - return newest - - def _coerce_gateway_timestamp(value: Any) -> Optional[float]: """Best-effort conversion of stored gateway timestamps to epoch seconds. @@ -1107,13 +960,6 @@ class GatewayRunner: _stop_task: Optional[asyncio.Task] = None _session_model_overrides: Dict[str, Dict[str, str]] = {} _session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} - # Stale-code self-check defaults (see _detect_stale_code()). Class-level - # so tests that construct GatewayRunner via ``object.__new__`` without - # running __init__ don't crash when _handle_message reads these. - _boot_wall_time: float = 0.0 - _boot_repo_mtime: float = 0.0 - _boot_git_sha: Optional[str] = None - _stale_code_restart_triggered: bool = False def __init__(self, config: Optional[GatewayConfig] = None): global _gateway_runner_ref @@ -1122,30 +968,6 @@ class GatewayRunner: self._warn_if_docker_media_delivery_is_risky() _gateway_runner_ref = _weakref.ref(self) - # Boot-time snapshot used by the stale-code self-check. Captured - # before any work happens so post-update file writes are guaranteed - # to have newer mtimes. See _detect_stale_code() / Issue #17648. - try: - self._boot_wall_time: float = time.time() - self._repo_root_for_staleness: Path = Path(__file__).resolve().parent.parent - self._boot_git_sha: Optional[str] = _read_git_head_sha( - self._repo_root_for_staleness, - ) - self._boot_repo_mtime: float = _compute_repo_mtime( - self._repo_root_for_staleness, - ) - except Exception: - self._boot_wall_time = 0.0 - self._repo_root_for_staleness = Path(".") - self._boot_git_sha = None - self._boot_repo_mtime = 0.0 - self._stale_code_notified: set[str] = set() - self._stale_code_restart_triggered: bool = False - # Cached current-SHA read, refreshed at most every - # _GIT_SHA_CACHE_TTL_SECS so bursty chats don't hammer the filesystem. - self._cached_current_sha: Optional[str] = self._boot_git_sha - self._cached_current_sha_at: float = self._boot_wall_time - # Load ephemeral config from config.yaml / env vars. # Both are injected at API-call time only and never persisted. self._prefill_messages = self._load_prefill_messages() @@ -2853,101 +2675,6 @@ class GatewayRunner: task.add_done_callback(self._background_tasks.discard) return True - def _current_git_sha_cached(self) -> Optional[str]: - """Return the current HEAD SHA, cached for _GIT_SHA_CACHE_TTL_SECS. - - A bursty chat (user mashes "hello?" three times) would otherwise - re-read ``.git/HEAD`` on every message. Caching collapses that - into a single read and still re-checks within the user's - perceived "next message" window. - """ - now = time.time() - if ( - self._cached_current_sha is not None - and (now - self._cached_current_sha_at) < _GIT_SHA_CACHE_TTL_SECS - ): - return self._cached_current_sha - try: - sha = _read_git_head_sha(self._repo_root_for_staleness) - except Exception: - sha = None - self._cached_current_sha = sha - self._cached_current_sha_at = now - return sha - - def _detect_stale_code(self) -> bool: - """Return True if the git HEAD moved since this process booted. - - A gateway that survives ``hermes update`` (manual SIGTERM never - escalated, systemd restart race, detached-process respawn failed, - etc.) keeps pre-update modules cached in ``sys.modules``. Later - imports of names added post-update — e.g. ``cfg_get`` from PR - #17304 — raise ImportError against the stale module object (see - Issue #17648). - - We compare the git HEAD SHA at boot to the current SHA on disk. - ``hermes update`` always moves HEAD forward via ``git pull``; - agent file edits (the agent patching ``run_agent.py`` or - ``gateway/run.py`` during a self-dev session) never move HEAD. - That makes SHA comparison free of the false-positive class that - the old mtime check suffered from — the agent can edit any file - without triggering a phantom restart. - - Returns False when: - - the boot SHA is unavailable (non-git install, first call - during partial init, etc.); we can't tell and refuse to loop - - the current SHA matches the boot SHA - - reading the current SHA fails for any reason - """ - if not self._boot_wall_time: - return False - if not self._boot_git_sha: - # Non-git install. ``hermes update`` is git-based, so a - # non-git install can't experience the stale-modules class - # this check exists to catch. Return False — no check, no - # false positives. (If we ever ship a pip-install update - # path, we'd add a persistent update marker here and compare - # its timestamp to self._boot_wall_time.) - return False - try: - current = self._current_git_sha_cached() - except Exception: - return False - if not current: - return False - return current != self._boot_git_sha - - def _trigger_stale_code_restart(self) -> None: - """Idempotently kick off a graceful restart after stale-code detection. - - Runs at most once per process. The restart request goes through - the normal drain path so in-flight agent turns finish before the - process exits; the service manager (systemd / launchd / detached - profile watcher) then respawns with fresh code. On manual - ``hermes gateway run`` installs without a supervisor, the - process exits and the user must restart by hand — but they get a - user-visible message telling them so. - """ - if self._stale_code_restart_triggered: - return - self._stale_code_restart_triggered = True - current_sha = None - try: - current_sha = self._current_git_sha_cached() - except Exception: - pass - logger.warning( - "Stale-code self-check: git HEAD moved since gateway boot " - "(boot=%s, current=%s) — requesting graceful restart. " - "See Issue #17648.", - (self._boot_git_sha or "?")[:12], - (current_sha or "?")[:12], - ) - try: - self.request_restart(detached=False, via_service=True) - except Exception as exc: - logger.error("Stale-code restart request failed: %s", exc) - async def start(self) -> bool: """ Start the gateway and all configured platform adapters. @@ -4878,27 +4605,6 @@ class GatewayRunner: """ source = event.source - # Stale-code self-check (Issue #17648). A gateway that survives - # ``hermes update`` keeps old modules cached in sys.modules; the - # first inbound message is our earliest safe chance to detect - # this and restart gracefully before we dispatch to the agent - # and hit ImportError on freshly-added names (e.g. cfg_get). - # Idempotent — runs the real check at most once per message, and - # request_restart() no-ops after the first call. - try: - if self._detect_stale_code(): - self._trigger_stale_code_restart() - # Acknowledge to the user so they don't see a silent - # drop; the gateway will be back up in a moment via the - # service manager / profile-watcher respawn. - return ( - "⟳ Gateway code was updated in the background — " - "restarting this gateway so your next message runs " - "on the new code. Please retry in a moment." - ) - except Exception as _stale_exc: - logger.debug("Stale-code self-check failed: %s", _stale_exc) - # Internal events (e.g. background-process completion notifications) # are system-generated and must skip user authorization. is_internal = bool(getattr(event, "internal", False)) diff --git a/run_agent.py b/run_agent.py index 4d8ffa1908fa..3554ff665d50 100644 --- a/run_agent.py +++ b/run_agent.py @@ -128,6 +128,7 @@ from tools.browser_tool import cleanup_browser # Agent internals extracted to agent/ package for modularity from agent.memory_manager import StreamingContextScrubber, build_memory_context_block, sanitize_context +from agent.think_scrubber import StreamingThinkScrubber from agent.retry_utils import jittered_backoff from agent.error_classifier import classify_api_error, FailoverReason from agent.prompt_builder import ( @@ -1297,6 +1298,13 @@ class AIAgent: # deltas (#5719). sanitize_context() alone can't survive chunk # boundaries because the block regex needs both tags in one string. self._stream_context_scrubber = StreamingContextScrubber() + # Stateful scrubber for reasoning/thinking tags in streamed deltas + # (#17924). Replaces the per-delta _strip_think_blocks regex that + # destroyed downstream state (e.g. MiniMax-M2.7 streaming + # '' as delta1 and 'Let me check' as delta2 — the regex + # erased delta1, so downstream state machines never learned a + # block was open and leaked delta2 as content). + self._stream_think_scrubber = StreamingThinkScrubber() # Visible assistant text already delivered through live token callbacks # during the current model response. Used to avoid re-sending the same # commentary when the provider later returns it as a completed interim @@ -6543,6 +6551,29 @@ class AIAgent: def _reset_stream_delivery_tracking(self) -> None: """Reset tracking for text delivered during the current model response.""" + # Flush any benign partial-tag tail held by the think scrubber + # first (#17924): an innocent '<' at the end of the stream that + # turned out not to be a tag prefix should reach the UI. Then + # flush the context scrubber. Order matters — the think + # scrubber's output feeds into the context scrubber's state. + think_scrubber = getattr(self, "_stream_think_scrubber", None) + if think_scrubber is not None: + think_tail = think_scrubber.flush() + if think_tail: + # Route the tail through the context scrubber too so a + # memory-context span straddling the final boundary is + # still caught. + ctx_scrubber = getattr(self, "_stream_context_scrubber", None) + if ctx_scrubber is not None: + think_tail = ctx_scrubber.feed(think_tail) + if think_tail: + callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None] + for cb in callbacks: + try: + cb(think_tail) + except Exception: + pass + self._record_streamed_assistant_text(think_tail) # Flush any benign partial-tag tail held by the context scrubber so it # reaches the UI before we clear state for the next model call. If # the scrubber is mid-span, flush() drops the orphaned content. @@ -6611,11 +6642,22 @@ class AIAgent: else: prepended_break = False if isinstance(text, str): - # Strip blocks first (per-delta is safe for closed pairs; the - # unterminated-tag path is handled downstream by stream_consumer). + # Suppress reasoning/thinking blocks via the stateful + # scrubber (#17924). Earlier versions ran _strip_think_blocks + # per-delta here, which destroyed downstream state machines + # when a tag was split across deltas (e.g. MiniMax-M2.7 + # sends '' and its content as separate deltas — + # regex case 2 erased the first delta, so the CLI/gateway + # state machine never saw the open tag and leaked the + # reasoning content as regular response text). + think_scrubber = getattr(self, "_stream_think_scrubber", None) + if think_scrubber is not None: + text = think_scrubber.feed(text or "") + else: + # Defensive: legacy callers without the scrubber attribute. + text = self._strip_think_blocks(text or "") # Then feed through the stateful context scrubber so memory-context # spans split across chunks cannot leak to the UI (#5719). - text = self._strip_think_blocks(text or "") scrubber = getattr(self, "_stream_context_scrubber", None) if scrubber is not None: text = scrubber.feed(text) @@ -10576,6 +10618,11 @@ class AIAgent: scrubber = getattr(self, "_stream_context_scrubber", None) if scrubber is not None: scrubber.reset() + # Reset the think scrubber for the same reason — an interrupted + # prior stream may have left us inside an unterminated block. + think_scrubber = getattr(self, "_stream_think_scrubber", None) + if think_scrubber is not None: + think_scrubber.reset() # Preserve the original user message (no nudge injection). original_user_message = persist_user_message if persist_user_message is not None else user_message diff --git a/tests/agent/test_think_scrubber.py b/tests/agent/test_think_scrubber.py new file mode 100644 index 000000000000..0f9937d11d70 --- /dev/null +++ b/tests/agent/test_think_scrubber.py @@ -0,0 +1,229 @@ +"""Tests for StreamingThinkScrubber. + +These tests lock in the contract the scrubber must satisfy so downstream +consumers (ACP, api_server, TTS, CLI, gateway) never see reasoning +blocks leaking through the stream_delta_callback. The scenarios map +directly to the MiniMax-M2.7 / DeepSeek / Qwen3 streaming patterns that +break the older per-delta regex strip. +""" + +from __future__ import annotations + +import pytest + +from agent.think_scrubber import StreamingThinkScrubber + + +def _drive(scrubber: StreamingThinkScrubber, deltas: list[str]) -> str: + """Feed a sequence of deltas and return the concatenated visible output.""" + out = [scrubber.feed(d) for d in deltas] + out.append(scrubber.flush()) + return "".join(out) + + +class TestClosedPairs: + """Closed ... pairs are always stripped, regardless of boundary.""" + + def test_closed_pair_single_delta(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["reasoningHello world"]) == "Hello world" + + def test_closed_pair_surrounded_by_content(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["Hello note world"]) == "Hello world" + + @pytest.mark.parametrize( + "tag", + ["think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD"], + ) + def test_all_tag_variants(self, tag: str) -> None: + s = StreamingThinkScrubber() + delta = f"<{tag}>xHello" + assert _drive(s, [delta]) == "Hello" + + def test_case_insensitive_pair(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["xHello"]) == "Hello" + + +class TestUnterminatedOpen: + """Unterminated open tag discards all subsequent content to end of stream.""" + + def test_open_at_stream_start(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["reasoning text with no close"]) == "" + + def test_open_after_newline(self) -> None: + s = StreamingThinkScrubber() + # 'Hello\n' is a block boundary for the that follows + assert _drive(s, ["Hello\nreasoning"]) == "Hello\n" + + def test_open_after_newline_then_whitespace(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["Hello\n reasoning"]) == "Hello\n " + + def test_prose_mentioning_tag_not_stripped(self) -> None: + """Mid-line '' in prose is preserved (no boundary).""" + s = StreamingThinkScrubber() + text = "Use the element for reasoning" + assert _drive(s, [text]) == text + + +class TestOrphanClose: + """Orphan close tags (no prior open) are stripped without boundary check.""" + + def test_orphan_close_alone(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["Helloworld"]) == "Helloworld" + + def test_orphan_close_with_trailing_space_consumed(self) -> None: + """Matches _strip_think_blocks case 3 \\s* behaviour.""" + s = StreamingThinkScrubber() + assert _drive(s, ["Hello world"]) == "Helloworld" + + def test_multiple_orphan_closes(self) -> None: + s = StreamingThinkScrubber() + assert _drive(s, ["ABC"]) == "ABC" + + +class TestPartialTagsAcrossDeltas: + """Partial tags at delta boundaries must be held back, not emitted raw.""" + + def test_split_open_tag_held_back(self) -> None: + """'<' arrives alone, 'think>' completes it on next delta.""" + s = StreamingThinkScrubber() + # At stream start, last_emitted_ended_newline=True, so at 0 is boundary + assert ( + _drive(s, ["<", "think>reasoningdone"]) + == "done" + ) + + def test_split_open_tag_not_at_boundary(self) -> None: + """Mid-line split '<' + 'think>X' is a closed pair. + + Closed pairs are always stripped (matching + ``_strip_think_blocks`` case 1), even without a block + boundary — a closed pair is an intentional bounded construct. + """ + s = StreamingThinkScrubber() + out = _drive(s, ["word<", "think>prosemore"]) + assert out == "wordmore" + + def test_split_close_tag_held_back(self) -> None: + """Close tag split across deltas still closes the block.""" + s = StreamingThinkScrubber() + assert ( + _drive(s, ["reasoning<", "/think>after"]) + == "after" + ) + + def test_split_close_tag_deep(self) -> None: + """Close tag can be split anywhere.""" + s = StreamingThinkScrubber() + assert ( + _drive(s, ["reasoningafter"]) + == "after" + ) + + +class TestTheMiniMaxScenario: + """The exact pattern run_agent per-delta regex strip breaks.""" + + def test_minimax_split_open(self) -> None: + """delta1='', delta2='Let me check', delta3='done'.""" + s = StreamingThinkScrubber() + out = _drive(s, ["", "Let me check their config", "", "done"]) + assert out == "done" + + def test_minimax_split_open_with_trailing_content(self) -> None: + """Reasoning then closes and hands off to final content.""" + s = StreamingThinkScrubber() + out = _drive( + s, + [ + "", + "The user wants to know if thinking is on", + "", + "\n\nshow_reasoning: false — thinking is OFF.", + ], + ) + assert out == "\n\nshow_reasoning: false — thinking is OFF." + + def test_minimax_unterminated_reasoning_at_end(self) -> None: + """Unclosed reasoning at stream end is dropped entirely.""" + s = StreamingThinkScrubber() + out = _drive(s, ["", "The user wants", " to know something"]) + assert out == "" + + +class TestResetAndReentry: + def test_reset_clears_in_block_state(self) -> None: + s = StreamingThinkScrubber() + s.feed("hanging") + assert s._in_block is True + s.reset() + assert s._in_block is False + # After reset, a new turn works cleanly + assert _drive(s, ["Hello world"]) == "Hello world" + + def test_reset_clears_buffered_partial_tag(self) -> None: + s = StreamingThinkScrubber() + s.feed("word<") + assert s._buf == "<" + s.reset() + assert s._buf == "" + assert _drive(s, ["fresh content"]) == "fresh content" + + +class TestFlushBehaviour: + def test_flush_drops_unterminated_block(self) -> None: + s = StreamingThinkScrubber() + assert s.feed("reasoning with no close") == "" + assert s.flush() == "" + + def test_flush_emits_innocent_partial_tag_tail(self) -> None: + """If held-back tail turned out not to be a real tag, emit it.""" + s = StreamingThinkScrubber() + s.feed("word<") # '<' could be a tag prefix + # Stream ends with only '<' held back — emit it as prose. + assert s.flush() == "<" + + def test_flush_on_empty_scrubber(self) -> None: + s = StreamingThinkScrubber() + assert s.flush() == "" + + +class TestRealisticStreaming: + """Character-by-character streaming must work as well as larger chunks.""" + + def test_char_by_char_closed_pair(self) -> None: + s = StreamingThinkScrubber() + deltas = list("xHello world") + assert _drive(s, deltas) == "Hello world" + + def test_char_by_char_orphan_close(self) -> None: + s = StreamingThinkScrubber() + deltas = list("Helloworld") + assert _drive(s, deltas) == "Helloworld" + + def test_reasoning_then_real_response_first_word_preserved(self) -> None: + """Regression: the first word of the final response must NOT be eaten. + + Stefan's screenshot bug — 'Let me check' was being rendered as + ' me check'. The scrubber must not consume any character of + post-close content. + """ + s = StreamingThinkScrubber() + deltas = [ + "", + "User wants to know things", + "", + "Let me check their config.", + ] + assert _drive(s, deltas) == "Let me check their config." + + def test_no_tag_passthrough_is_identical(self) -> None: + """Streams without any reasoning tags pass through byte-for-byte.""" + s = StreamingThinkScrubber() + deltas = ["Hello ", "world ", "how ", "are ", "you?"] + assert _drive(s, deltas) == "Hello world how are you?" diff --git a/tests/gateway/test_stale_code_self_check.py b/tests/gateway/test_stale_code_self_check.py deleted file mode 100644 index 64ad347145df..000000000000 --- a/tests/gateway/test_stale_code_self_check.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Tests for the gateway stale-code self-check (Issue #17648). - -A gateway that survives ``hermes update`` keeps pre-update modules cached -in ``sys.modules``. Later imports of names added post-update (e.g. -``cfg_get`` from PR #17304) raise ImportError against the stale module -object. - -The self-check compares the git HEAD SHA at boot to the current SHA on -disk. ``hermes update`` always moves HEAD forward via ``git pull``; -agent-driven file edits (Hermes editing ``run_agent.py`` / ``gateway/run.py`` -during a self-dev session) never move HEAD — so the SHA signal is free of -the false-positive class that the earlier mtime-based check suffered from. -""" - -import os -import time -from pathlib import Path - -import pytest - -from gateway.run import ( - GatewayRunner, - _compute_repo_mtime, - _read_git_head_sha, - _STALE_CODE_SENTINELS, - _GIT_SHA_CACHE_TTL_SECS, -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _make_tmp_repo(tmp_path: Path) -> Path: - """Create a fake repo with all stale-code sentinel files.""" - for rel in _STALE_CODE_SENTINELS: - p = tmp_path / rel - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text("# test sentinel\n") - return tmp_path - - -def _make_git_repo(tmp_path: Path, sha: str = "a" * 40, branch: str = "main") -> Path: - """Stamp a minimal .git directory so _read_git_head_sha can resolve a SHA. - - We don't run real git — just lay down the files the reader walks - (.git/HEAD pointing at refs/heads/, refs/heads/ - containing the SHA). - """ - git_dir = tmp_path / ".git" - git_dir.mkdir(parents=True, exist_ok=True) - (git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n") - refs_dir = git_dir / "refs" / "heads" - refs_dir.mkdir(parents=True, exist_ok=True) - (refs_dir / branch).write_text(f"{sha}\n") - return tmp_path - - -def _set_head_sha(repo_root: Path, sha: str, branch: str = "main") -> None: - """Rewrite the current branch ref to a new SHA (simulates git pull).""" - (repo_root / ".git" / "refs" / "heads" / branch).write_text(f"{sha}\n") - - -def _make_runner( - repo_root: Path, - *, - boot_sha: str | None, - boot_wall: float = None, - boot_mtime: float = 0.0, -): - """Bare GatewayRunner with just the stale-check attributes set.""" - if boot_wall is None: - boot_wall = time.time() - runner = object.__new__(GatewayRunner) - runner._repo_root_for_staleness = repo_root - runner._boot_wall_time = boot_wall - runner._boot_git_sha = boot_sha - runner._boot_repo_mtime = boot_mtime - runner._stale_code_notified = set() - runner._stale_code_restart_triggered = False - runner._cached_current_sha = boot_sha - runner._cached_current_sha_at = boot_wall - return runner - - -# --------------------------------------------------------------------------- -# _read_git_head_sha — raw SHA reader -# --------------------------------------------------------------------------- - -def test_read_git_head_sha_branch_ref(tmp_path): - """Resolves ref: refs/heads/ → SHA from refs/heads/.""" - sha = "b" * 40 - _make_git_repo(tmp_path, sha=sha, branch="main") - assert _read_git_head_sha(tmp_path) == sha - - -def test_read_git_head_sha_detached_head(tmp_path): - """Detached HEAD: .git/HEAD contains the SHA directly.""" - sha = "c" * 40 - git_dir = tmp_path / ".git" - git_dir.mkdir() - (git_dir / "HEAD").write_text(f"{sha}\n") - assert _read_git_head_sha(tmp_path) == sha - - -def test_read_git_head_sha_packed_refs(tmp_path): - """Falls back to packed-refs when refs/heads/ is missing.""" - sha = "d" * 40 - git_dir = tmp_path / ".git" - git_dir.mkdir() - (git_dir / "HEAD").write_text("ref: refs/heads/main\n") - # No refs/heads/main file — only packed-refs - (git_dir / "packed-refs").write_text( - f"# pack-refs with: peeled fully-peeled sorted\n" - f"{sha} refs/heads/main\n" - ) - assert _read_git_head_sha(tmp_path) == sha - - -def test_read_git_head_sha_worktree_gitdir_file(tmp_path): - """Worktree: .git is a file with `gitdir: ` pointing to the real git dir. - - Real git worktrees store shared refs (refs/heads/*) in the main - checkout's .git/ and write a ``commondir`` pointer into the - worktree-gitdir. The reader must follow commondir to resolve the - branch ref — this is the layout Hermes dev sessions actually use. - """ - sha = "e" * 40 - # Main repo layout - main_repo = tmp_path / "main-repo" - main_git = main_repo / ".git" - (main_git / "refs" / "heads").mkdir(parents=True) - (main_git / "HEAD").write_text("ref: refs/heads/main\n") - (main_git / "refs" / "heads" / "main").write_text("0" * 40 + "\n") - - # Worktree lives in main-repo/.git/worktrees// - worktree_git_dir = main_git / "worktrees" / "feature" - worktree_git_dir.mkdir(parents=True) - (worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n") - # commondir points back at the main .git (relative path, "../..") - (worktree_git_dir / "commondir").write_text("../..\n") - # Feature branch ref lives in the shared refs/heads - (main_git / "refs" / "heads" / "feature").write_text(f"{sha}\n") - - # Worktree checkout with .git file pointing at worktree_git_dir - worktree = tmp_path / "wt" - worktree.mkdir() - (worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n") - - assert _read_git_head_sha(worktree) == sha - - -def test_read_git_head_sha_worktree_packed_refs_in_common(tmp_path): - """Worktree + packed-refs in common dir: fallback still resolves.""" - sha = "f" * 40 - main_repo = tmp_path / "main-repo" - main_git = main_repo / ".git" - main_git.mkdir(parents=True) - (main_git / "HEAD").write_text("ref: refs/heads/main\n") - # packed-refs in the common (main) .git - (main_git / "packed-refs").write_text( - f"# pack-refs with: peeled fully-peeled sorted\n" - f"{sha} refs/heads/feature\n" - ) - - worktree_git_dir = main_git / "worktrees" / "feature" - worktree_git_dir.mkdir(parents=True) - (worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n") - (worktree_git_dir / "commondir").write_text("../..\n") - - worktree = tmp_path / "wt" - worktree.mkdir() - (worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n") - - assert _read_git_head_sha(worktree) == sha - - -def test_read_git_head_sha_no_git_returns_none(tmp_path): - """No .git dir → None (non-git install, safely disables the check).""" - assert _read_git_head_sha(tmp_path) is None - - -def test_read_git_head_sha_malformed_head_returns_none(tmp_path): - """Empty HEAD file → None (don't loop on corrupt repos).""" - git_dir = tmp_path / ".git" - git_dir.mkdir() - (git_dir / "HEAD").write_text("") - assert _read_git_head_sha(tmp_path) is None - - -# --------------------------------------------------------------------------- -# _detect_stale_code — the main regression guard -# --------------------------------------------------------------------------- - -def test_detect_stale_code_false_when_sha_unchanged(tmp_path): - """Boot SHA == current SHA → not stale (no restart).""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - # Force fresh read by expiring the cache - runner._cached_current_sha_at = 0.0 - assert runner._detect_stale_code() is False - - -def test_detect_stale_code_true_after_git_pull(tmp_path): - """Boot SHA != current SHA → stale (hermes update happened).""" - boot_sha = "a" * 40 - _make_git_repo(tmp_path, sha=boot_sha) - runner = _make_runner(tmp_path, boot_sha=boot_sha) - # Simulate git pull moving HEAD forward - _set_head_sha(tmp_path, "b" * 40) - runner._cached_current_sha_at = 0.0 # expire cache - assert runner._detect_stale_code() is True - - -def test_detect_stale_code_ignores_agent_file_edits(tmp_path): - """THE CORE REGRESSION: agent edits to source files do NOT trigger restart. - - This is the motivating incident for the SHA-based check. Under the - previous mtime-based scheme, any ``patch`` / ``write_file`` call - against run_agent.py / gateway/run.py / hermes_cli/config.py would - flip the stale-check to True and force a gateway restart on the - next message — even though no update actually happened. SHA - comparison decouples the two: git HEAD only moves on ``git pull``, - never on file writes. - """ - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - _make_tmp_repo(tmp_path) # lay down sentinel files too - runner = _make_runner(tmp_path, boot_sha=sha) - - # Simulate the agent editing run_agent.py and gateway/run.py with - # mtimes far into the future — exactly the scenario that used to - # false-positive the old mtime check. - future = time.time() + 10_000 - for rel in _STALE_CODE_SENTINELS: - p = tmp_path / rel - if p.is_file(): - p.write_text("# agent just edited this\n") - os.utime(p, (future, future)) - - # HEAD SHA has NOT moved — check must stay False. - runner._cached_current_sha_at = 0.0 # expire cache - assert runner._detect_stale_code() is False - - -def test_detect_stale_code_false_for_non_git_install(tmp_path): - """Non-git install (no .git dir) → check disabled, never fires.""" - # No .git dir at all; runner's boot_sha is None - runner = _make_runner(tmp_path, boot_sha=None) - # Even if we pretended the current SHA differed, the check should - # short-circuit on boot_sha=None and return False. - assert runner._detect_stale_code() is False - - -def test_detect_stale_code_false_when_no_boot_wall_time(tmp_path): - """No boot snapshot at all → can't tell → not stale (no restart loop).""" - runner = _make_runner(tmp_path, boot_sha="a" * 40, boot_wall=0.0) - assert runner._detect_stale_code() is False - - -def test_detect_stale_code_handles_disappearing_git_dir(tmp_path): - """.git vanishes mid-run → current_sha = None → not stale (don't loop).""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - # Nuke the git dir after boot - import shutil - shutil.rmtree(tmp_path / ".git") - runner._cached_current_sha_at = 0.0 # expire cache - assert runner._detect_stale_code() is False - - -# --------------------------------------------------------------------------- -# SHA cache -# --------------------------------------------------------------------------- - -def test_current_sha_cache_collapses_bursts(tmp_path, monkeypatch): - """Consecutive calls inside the TTL window reuse the cached SHA.""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - - read_calls = {"n": 0} - real_reader = _read_git_head_sha - - def counting_reader(repo_root): - read_calls["n"] += 1 - return real_reader(repo_root) - - from gateway import run as run_mod - monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader) - - # Force cache expiry so the first call definitely reads - runner._cached_current_sha_at = 0.0 - runner._current_git_sha_cached() - first_count = read_calls["n"] - - # Immediate second/third calls should hit cache (no new read) - runner._current_git_sha_cached() - runner._current_git_sha_cached() - assert read_calls["n"] == first_count - - -def test_current_sha_cache_expires_after_ttl(tmp_path, monkeypatch): - """After _GIT_SHA_CACHE_TTL_SECS elapses, a fresh read happens.""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - - read_calls = {"n": 0} - real_reader = _read_git_head_sha - - def counting_reader(repo_root): - read_calls["n"] += 1 - return real_reader(repo_root) - - from gateway import run as run_mod - monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader) - - runner._cached_current_sha_at = 0.0 - runner._current_git_sha_cached() - first = read_calls["n"] - - # Age the cache past the TTL - runner._cached_current_sha_at = time.time() - (_GIT_SHA_CACHE_TTL_SECS + 1.0) - runner._current_git_sha_cached() - assert read_calls["n"] == first + 1 - - -# --------------------------------------------------------------------------- -# _trigger_stale_code_restart — idempotency preserved -# --------------------------------------------------------------------------- - -def test_trigger_stale_code_restart_is_idempotent(tmp_path): - """Calling _trigger_stale_code_restart twice only requests restart once.""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - - calls = [] - - def fake_request_restart(*, detached=False, via_service=False): - calls.append((detached, via_service)) - return True - - runner.request_restart = fake_request_restart - - runner._trigger_stale_code_restart() - runner._trigger_stale_code_restart() - runner._trigger_stale_code_restart() - - assert len(calls) == 1 - assert runner._stale_code_restart_triggered is True - - -def test_trigger_stale_code_restart_survives_request_failure(tmp_path): - """If request_restart raises, we swallow and mark as triggered anyway.""" - sha = "a" * 40 - _make_git_repo(tmp_path, sha=sha) - runner = _make_runner(tmp_path, boot_sha=sha) - - def boom(*, detached=False, via_service=False): - raise RuntimeError("no event loop") - - runner.request_restart = boom - - # Should not raise - runner._trigger_stale_code_restart() - - # Marked triggered so we don't retry on every subsequent message - assert runner._stale_code_restart_triggered is True - - -# --------------------------------------------------------------------------- -# Class-level defaults — tests that build bare runners via object.__new__ -# --------------------------------------------------------------------------- - -def test_class_level_defaults_prevent_uninitialized_access(): - """Partial construction via object.__new__ must not crash _detect_stale_code.""" - runner = object.__new__(GatewayRunner) - # Don't set any instance attrs — class-level defaults should kick in - runner._repo_root_for_staleness = Path(".") - # _boot_wall_time / _boot_git_sha fall through to class defaults - # (0.0 and None respectively) - assert runner._detect_stale_code() is False - # _stale_code_restart_triggered falls through to class default (False) - assert runner._stale_code_restart_triggered is False - - -# --------------------------------------------------------------------------- -# Legacy mtime reader kept for compatibility — light sanity check only -# --------------------------------------------------------------------------- - -def test_compute_repo_mtime_still_returns_newest(tmp_path): - """_compute_repo_mtime remains available for any legacy callers.""" - repo = _make_tmp_repo(tmp_path) - - baseline = time.time() - 100 - for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) - - newer = time.time() - os.utime(repo / "hermes_cli/config.py", (newer, newer)) - - result = _compute_repo_mtime(repo) - assert abs(result - newer) < 1.0 - - -def test_compute_repo_mtime_missing_files_returns_zero(tmp_path): - """Legacy sanity: missing sentinels → 0.0.""" - assert _compute_repo_mtime(tmp_path) == 0.0 From 8c82d0664dd288b5900502c34dd28dbbdcc450eb Mon Sep 17 00:00:00 2001 From: Steve Kelly Date: Tue, 5 May 2026 00:54:07 -0400 Subject: [PATCH 12/99] fix(kanban): ignore stale current board pointers --- hermes_cli/kanban_db.py | 10 +++++----- tests/hermes_cli/test_kanban_boards.py | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index e39808546f7b..ac44a3d19f1e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -190,12 +190,12 @@ def get_current_board() -> str: 1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker spawn, or manually for ad-hoc overrides). 2. ``/kanban/current`` on disk (set by ``hermes kanban boards - switch``). + switch``), but only when that board still exists. 3. ``DEFAULT_BOARD`` (``"default"``). - A malformed slug at any step falls through to the next layer with a - best-effort warning — the dispatcher must never crash because a user - hand-edited a file. + A malformed or stale slug at any step falls through to the next layer + with a best-effort warning — the dispatcher must never crash because a + user hand-edited a file or removed a board directory. """ env = os.environ.get("HERMES_KANBAN_BOARD", "").strip() if env: @@ -212,7 +212,7 @@ def get_current_board() -> str: if val: try: normed = _normalize_board_slug(val) - if normed: + if normed and board_exists(normed): return normed except ValueError: pass diff --git a/tests/hermes_cli/test_kanban_boards.py b/tests/hermes_cli/test_kanban_boards.py index a86a8713302b..28b3fd3f8dc0 100644 --- a/tests/hermes_cli/test_kanban_boards.py +++ b/tests/hermes_cli/test_kanban_boards.py @@ -160,6 +160,15 @@ class TestCurrentBoard: kb.set_current_board("filepick") assert kb.get_current_board() == "filepick" + def test_stale_file_pointer_falls_back_to_default(self, fresh_home): + current = fresh_home / "kanban" / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.write_text("missing-board\n", encoding="utf-8") + + assert kb.get_current_board() == "default" + assert not kb.board_exists("missing-board") + assert [b["slug"] for b in kb.list_boards()] == ["default"] + def test_env_beats_file(self, fresh_home, monkeypatch): kb.create_board("a") kb.create_board("b") From d472d697cd086d29ee5c696e197e4189628de93b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:31:44 -0700 Subject: [PATCH 13/99] =?UTF-8?q?chore(release):=20map=20stevekelly622@gma?= =?UTF-8?q?il.com=20=E2=86=92=20@steezkelly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 1b0cf0b8454a..a21355d71207 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -81,6 +81,7 @@ AUTHOR_MAP = { # Curator fixes (Apr 30 2026) "yuxiangl490@gmail.com": "y0shua1ee", "manmit0x@gmail.com": "0xDevNinja", + "stevekelly622@gmail.com": "steezkelly", "aamirjawaid@microsoft.com": "heyitsaamir", "johnnncenaaa77@gmail.com": "johnncenae", "thomasjhon6666@gmail.com": "ThomassJonax", From b22b3f506a34cf848d68bb2ab17b68b0bc8ec152 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Tue, 5 May 2026 12:23:43 +0530 Subject: [PATCH 14/99] fix(cli): pin HERMES_KANBAN_BOARD at chat boot to stop subprocess board drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without an explicit pin, in-process kanban tools and shelled-out `hermes kanban …` subprocesses resolve the active board on different paths: the env var when set, otherwise the global `/kanban/current` file. When a concurrent session toggles the current-board pointer mid-turn, the same chat ends up routing tool calls to board A while its shell calls hit board B, surfacing as phantom "no such task" errors. Pin the resolved board into env once at `cmd_chat` boot when HERMES_KANBAN_BOARD isn't already set. Mirrors what the dispatcher does for spawned workers (kanban_db.py:2622-2623). Idempotent and a no-op when the env is already pinned by the caller. Closes #20074 --- hermes_cli/main.py | 22 ++++++++ tests/hermes_cli/test_pin_kanban_board_env.py | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/hermes_cli/test_pin_kanban_board_env.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4e709a8f83a5..112d839db28d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1216,6 +1216,26 @@ def _launch_tui( sys.exit(code) +def _pin_kanban_board_env() -> None: + """Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session. + + Without this, in-process tools (``kanban_*``) and shelled-out CLI calls + (``hermes kanban …``) resolve the board on different paths: the env-pin if + set, otherwise the global ``/kanban/current`` file. A concurrent + ``hermes kanban boards switch`` from another session can flip the file + mid-turn, so the same chat sees its tool calls hit board A while its shell + calls hit board B (#20074). Pinning at chat boot mirrors what the + dispatcher already does for spawned workers. + """ + if os.environ.get("HERMES_KANBAN_BOARD"): + return + try: + from hermes_cli.kanban_db import get_current_board + os.environ["HERMES_KANBAN_BOARD"] = get_current_board() + except Exception: + pass + + def cmd_chat(args): """Run interactive chat CLI.""" use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" @@ -1324,6 +1344,8 @@ def cmd_chat(args): if getattr(args, "source", None): os.environ["HERMES_SESSION_SOURCE"] = args.source + _pin_kanban_board_env() + if use_tui: _launch_tui( getattr(args, "resume", None), diff --git a/tests/hermes_cli/test_pin_kanban_board_env.py b/tests/hermes_cli/test_pin_kanban_board_env.py new file mode 100644 index 000000000000..c4965ecf4d72 --- /dev/null +++ b/tests/hermes_cli/test_pin_kanban_board_env.py @@ -0,0 +1,54 @@ +"""Tests for `_pin_kanban_board_env` helper invoked by `cmd_chat`. + +Regression coverage for #20074: a chat session must export the active kanban +board into `HERMES_KANBAN_BOARD` at boot so subprocess shell-outs (e.g. +`hermes kanban …`) inherit the same board the in-process kanban tools resolve. +Without this, a concurrent `hermes kanban boards switch` from another session +can flip the global current-board file mid-turn and silently divert the +shell calls to a different DB. +""" +import importlib + + +def test_pin_writes_resolved_board_when_env_unset(monkeypatch): + monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) + main_mod = importlib.import_module("hermes_cli.main") + + import hermes_cli.kanban_db as kdb + monkeypatch.setattr(kdb, "get_current_board", lambda: "space") + + main_mod._pin_kanban_board_env() + + assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "space" + + +def test_pin_does_not_overwrite_existing_env(monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_BOARD", "preset") + main_mod = importlib.import_module("hermes_cli.main") + + import hermes_cli.kanban_db as kdb + + def _explode(): + raise AssertionError("get_current_board must not be called when env is set") + + monkeypatch.setattr(kdb, "get_current_board", _explode) + + main_mod._pin_kanban_board_env() + + assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "preset" + + +def test_pin_swallows_resolution_failures(monkeypatch): + monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) + main_mod = importlib.import_module("hermes_cli.main") + + import hermes_cli.kanban_db as kdb + + def _boom(): + raise RuntimeError("disk gone") + + monkeypatch.setattr(kdb, "get_current_board", _boom) + + main_mod._pin_kanban_board_env() + + assert "HERMES_KANBAN_BOARD" not in main_mod.os.environ From f8a6db68ca7aaafbbc4c952ac96c483d2b5399ca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:36:38 -0700 Subject: [PATCH 15/99] test(kanban): isolate HERMES_KANBAN_BOARD writes in pin-env tests The helper under test writes to os.environ directly, bypassing monkeypatch tracking. Without an explicit snapshot/restore fixture, the mutation leaks into subsequent tests and breaks TestSharedBoardPaths (kanban path resolution reads HERMES_KANBAN_BOARD and routes through boards// instead of the test's own HERMES_HOME). Add an autouse fixture that snapshots the env var before the test and restores (or pops) it after, regardless of what the helper did. --- tests/hermes_cli/test_pin_kanban_board_env.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/hermes_cli/test_pin_kanban_board_env.py b/tests/hermes_cli/test_pin_kanban_board_env.py index c4965ecf4d72..1f6b2fc6ed4b 100644 --- a/tests/hermes_cli/test_pin_kanban_board_env.py +++ b/tests/hermes_cli/test_pin_kanban_board_env.py @@ -8,10 +8,32 @@ can flip the global current-board file mid-turn and silently divert the shell calls to a different DB. """ import importlib +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_kanban_board_env(): + """Snapshot `HERMES_KANBAN_BOARD` and restore it after the test. + + `_pin_kanban_board_env()` writes to ``os.environ`` directly, bypassing + any ``monkeypatch.setenv`` tracking. Without this fixture the mutation + leaks into subsequent tests and breaks anything that resolves a kanban + path from the env (e.g. ``TestSharedBoardPaths`` in test_kanban_db.py). + """ + prev = os.environ.get("HERMES_KANBAN_BOARD") + os.environ.pop("HERMES_KANBAN_BOARD", None) + try: + yield + finally: + if prev is None: + os.environ.pop("HERMES_KANBAN_BOARD", None) + else: + os.environ["HERMES_KANBAN_BOARD"] = prev def test_pin_writes_resolved_board_when_env_unset(monkeypatch): - monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) main_mod = importlib.import_module("hermes_cli.main") import hermes_cli.kanban_db as kdb @@ -39,7 +61,6 @@ def test_pin_does_not_overwrite_existing_env(monkeypatch): def test_pin_swallows_resolution_failures(monkeypatch): - monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) main_mod = importlib.import_module("hermes_cli.main") import hermes_cli.kanban_db as kdb From 4a3e3e20e5b2ed2fd0c2e727f8204efea4de8a5a Mon Sep 17 00:00:00 2001 From: revaraver <29756950+revaraver@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:04:47 +0800 Subject: [PATCH 16/99] fix(compression): preserve iterative summary continuity --- agent/context_compressor.py | 43 ++++++++++-- ...t_context_compressor_summary_continuity.py | 67 +++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tests/agent/test_context_compressor_summary_continuity.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f9111f96004f..7475c5f5d4e1 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio return None @staticmethod - def _with_summary_prefix(summary: str) -> str: - """Normalize summary text to the current compaction handoff format.""" + def _strip_summary_prefix(summary: str) -> str: + """Return summary body without the current or legacy handoff prefix.""" text = (summary or "").strip() - for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX): + for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX): if text.startswith(prefix): - text = text[len(prefix):].lstrip() - break + return text[len(prefix):].lstrip() + return text + + @classmethod + def _with_summary_prefix(cls, summary: str) -> str: + """Normalize summary text to the current compaction handoff format.""" + text = cls._strip_summary_prefix(summary) return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX + @staticmethod + def _is_context_summary_content(content: Any) -> bool: + text = _content_text_for_contains(content).lstrip() + return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX) + + @classmethod + def _find_latest_context_summary( + cls, + messages: List[Dict[str, Any]], + start: int, + end: int, + ) -> tuple[Optional[int], str]: + """Find the newest handoff summary inside a compression window.""" + for idx in range(end - 1, start - 1, -1): + content = messages[idx].get("content") + if cls._is_context_summary_content(content): + return idx, cls._strip_summary_prefix(_content_text_for_contains(content)) + return None, "" + # ------------------------------------------------------------------ # Tool-call / tool-result pair integrity helpers # ------------------------------------------------------------------ @@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio return messages turns_to_summarize = messages[compress_start:compress_end] + summary_idx, summary_body = self._find_latest_context_summary( + messages, + compress_start, + compress_end, + ) + if summary_idx is not None: + if summary_body and not self._previous_summary: + self._previous_summary = summary_body + turns_to_summarize = messages[summary_idx + 1:compress_end] if not self.quiet_mode: logger.info( diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py new file mode 100644 index 000000000000..d9a273758347 --- /dev/null +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -0,0 +1,67 @@ +"""Regression tests for iterative context-summary continuity.""" + +from unittest.mock import MagicMock, patch + +from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX + + +def _compressor() -> ContextCompressor: + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + return ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + +def _response(content: str): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = content + return mock_response + + +def _messages_with_handoff(summary_body: str): + return [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"}, + {"role": "user", "content": "new user turn after resume"}, + {"role": "assistant", "content": "new assistant work after resume"}, + {"role": "user", "content": "more new work after resume"}, + {"role": "assistant", "content": "latest tail response"}, + ] + + +def test_existing_previous_summary_is_not_serialized_again_as_new_turn(): + """Same-process iterative compression should not feed the old handoff twice.""" + compressor = _compressor() + old_summary = "OLD-SUMMARY-BODY unique continuity facts" + compressor._previous_summary = old_summary + + with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call: + compressor.compress(_messages_with_handoff(old_summary)) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert "NEW TURNS TO INCORPORATE:" in prompt + assert prompt.count(old_summary) == 1 + assert f"[USER]: {SUMMARY_PREFIX}" not in prompt + + +def test_resume_rehydrates_previous_summary_from_handoff_message(): + """After restart/resume, the persisted handoff should regain summary identity.""" + compressor = _compressor() + old_summary = "RESUMED-SUMMARY-BODY durable continuity facts" + assert compressor._previous_summary is None + + with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call: + compressor.compress(_messages_with_handoff(old_summary)) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert "NEW TURNS TO INCORPORATE:" in prompt + assert "TURNS TO SUMMARIZE:" not in prompt + assert prompt.count(old_summary) == 1 + assert f"[USER]: {SUMMARY_PREFIX}" not in prompt From fe8dc26bc99e1e9d84a029846f45623fe289e4a5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:09:21 -0700 Subject: [PATCH 17/99] chore: AUTHOR_MAP entry for revaraver noreply --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a21355d71207..0a99b54bcb92 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,6 +55,7 @@ AUTHOR_MAP = { "14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel", "657290301@qq.com": "IMHaoyan", "revar@users.noreply.github.com": "revaraver", + "29756950+revaraver@users.noreply.github.com": "revaraver", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From aacf36e94309df06bd9221e5b9027e8b3c3f0b0b Mon Sep 17 00:00:00 2001 From: revaraver <29756950+revaraver@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:02:25 +0800 Subject: [PATCH 18/99] fix(cli): persist manual compress handoff --- cli.py | 4 +++ tests/cli/test_manual_compress.py | 51 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/cli.py b/cli.py index e5af3c88f235..76e10e29f43d 100644 --- a/cli.py +++ b/cli.py @@ -7659,6 +7659,10 @@ class HermesCLI: ): self.session_id = self.agent.session_id self._pending_title = None + # Manual /compress replaces conversation_history with a new + # compressed handoff for the child session. Persist it from + # offset 0 so resume can recover the continuation after exit. + self.agent._flush_messages_to_session_db(self.conversation_history, None) new_tokens = estimate_request_tokens_rough( self.conversation_history, system_prompt=_sys_prompt, diff --git a/tests/cli/test_manual_compress.py b/tests/cli/test_manual_compress.py index afbde073306c..d68106ffd5a1 100644 --- a/tests/cli/test_manual_compress.py +++ b/tests/cli/test_manual_compress.py @@ -111,6 +111,57 @@ def test_manual_compress_syncs_session_id_after_split(): assert shell._pending_title is None +def test_manual_compress_flushes_compressed_history_to_child_session_db(): + """Manual /compress must persist the handoff in the continuation DB. + + _compress_context rotates the agent to a new child session and returns a + compressed transcript whose first messages include the handoff summary. The + CLI then replaces its in-memory conversation_history with that transcript. + Because the child DB starts empty, the flush must start from offset 0 rather + than treating the compressed history as already persisted. + """ + shell = _make_cli() + history = _make_history() + old_id = shell.session_id + new_child_id = "20260101_000000_child1" + compressed = [ + {"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"}, + history[-1], + ] + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = True + shell.agent._cached_system_prompt = "" + shell.agent.session_id = old_id + + def _fake_compress(*args, **kwargs): + shell.agent.session_id = new_child_id + return (compressed, "") + + shell.agent._compress_context.side_effect = _fake_compress + + with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100): + shell._manual_compress() + + shell.agent._flush_messages_to_session_db.assert_called_once_with(compressed, None) + + +def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged(): + shell = _make_cli() + history = _make_history() + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = True + shell.agent._cached_system_prompt = "" + shell.agent.session_id = shell.session_id + shell.agent._compress_context.return_value = (list(history), "") + + with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100): + shell._manual_compress() + + shell.agent._flush_messages_to_session_db.assert_not_called() + + def test_manual_compress_no_sync_when_session_id_unchanged(): """If compression is a no-op (agent.session_id didn't change), the CLI must NOT clear _pending_title or otherwise disturb session state. From f6b68f0f5079eeb10ce00253b29236015f96d9bb Mon Sep 17 00:00:00 2001 From: 0xsir0000 <59465365+0xsir0000@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:50 +0800 Subject: [PATCH 19/99] fix(gateway): keep DoH-confirmed Telegram IPs that match system DNS (#14520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discover_fallback_ips() filtered out any DoH-resolved IP that also appeared in the system resolver's answer set, on the assumption that the system IP was unreachable. When DoH and system DNS agreed (a common case), the function returned the hardcoded _SEED_FALLBACK_IPS list instead — and on networks where those seed addresses are not routable, the Telegram fallback transport had nothing usable to retry against and polling failed. Drop the system_ips exclusion so DoH-confirmed IPs are preserved regardless of system DNS overlap. The TelegramFallbackTransport already tries the primary path first via system DNS, then falls through to the IP-rewrite path on connect failure; including the same IP in both lanes lets a transient primary failure recover via the explicit IP route instead of escalating to seed addresses. Update the two tests that codified the old exclusion to reflect the new, inclusion-by-default behaviour. Fixes #14520 --- gateway/platforms/telegram_network.py | 17 ++++++++++------- tests/gateway/test_telegram_network.py | 23 +++++++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index b099adc50e05..8fe4c2809345 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -185,10 +185,13 @@ async def _query_doh_provider( async def discover_fallback_ips() -> list[str]: """Auto-discover Telegram API IPs via DNS-over-HTTPS. - Resolves api.telegram.org through Google and Cloudflare DoH, collects all - unique IPs, and excludes the system-DNS-resolved IP (which is presumably - unreachable on this network). Falls back to a hardcoded seed list when DoH - is also unavailable. + Resolves api.telegram.org through Google and Cloudflare DoH and returns all + unique A records. IPs that match the local system resolver are kept rather + than excluded: in many networks the system-DNS IP is the most reliable path + to api.telegram.org and a transient primary-path failure should be retried + against the same address via the IP-rewrite path before the seed list is + consulted (#14520). Falls back to a hardcoded seed list only when DoH + yields no usable answers. """ async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client: doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS] @@ -203,11 +206,11 @@ async def discover_fallback_ips() -> list[str]: if isinstance(r, list): doh_ips.extend(r) - # Deduplicate preserving order, exclude system-DNS IPs + # Deduplicate preserving order seen: set[str] = set() candidates: list[str] = [] for ip in doh_ips: - if ip not in seen and ip not in system_ips: + if ip not in seen: seen.add(ip) candidates.append(ip) @@ -219,7 +222,7 @@ async def discover_fallback_ips() -> list[str]: return validated logger.info( - "DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s", + "DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s", ", ".join(system_ips) or "unknown", ", ".join(_SEED_FALLBACK_IPS), ) diff --git a/tests/gateway/test_telegram_network.py b/tests/gateway/test_telegram_network.py index be0abb57b805..f464c337fd9d 100644 --- a/tests/gateway/test_telegram_network.py +++ b/tests/gateway/test_telegram_network.py @@ -534,15 +534,20 @@ class TestDiscoverFallbackIps: assert "149.154.167.221" in ips @pytest.mark.asyncio - async def test_system_dns_ip_excluded(self, monkeypatch): - """The IP from system DNS is the one that doesn't work — exclude it.""" + async def test_system_dns_ip_kept_when_doh_confirms(self, monkeypatch): + """DoH-confirmed IPs are kept even when they match system DNS (#14520). + + The system-DNS IP is often the most reliable path; including it as a + fallback lets the IP-rewrite retry recover from transient primary-path + failures instead of jumping straight to the hardcoded seed list. + """ self._patch_doh(monkeypatch, { "https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")), "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")), }, system_dns_ips=["149.154.166.110"]) ips = await tnet.discover_fallback_ips() - assert ips == ["149.154.167.220"] + assert ips == ["149.154.166.110", "149.154.167.220"] @pytest.mark.asyncio async def test_doh_results_deduplicated(self, monkeypatch): @@ -607,15 +612,21 @@ class TestDiscoverFallbackIps: assert "149.154.167.220" in ips @pytest.mark.asyncio - async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch): - """DoH returns only the same blocked IP — seed list is the fallback.""" + async def test_all_doh_ips_same_as_system_dns_kept(self, monkeypatch): + """DoH agrees with system DNS — keep that IP instead of seed list (#14520). + + Previous behavior fell through to ``_SEED_FALLBACK_IPS`` here, but the + seed addresses are not routable on every network. When DoH confirms + the system IP, that IP is the best candidate we have and should be + used as the fallback target. + """ self._patch_doh(monkeypatch, { "https://dns.google": (200, _doh_answer("149.154.166.110")), "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")), }, system_dns_ips=["149.154.166.110"]) ips = await tnet.discover_fallback_ips() - assert ips == tnet._SEED_FALLBACK_IPS + assert ips == ["149.154.166.110"] @pytest.mark.asyncio async def test_cloudflare_gets_accept_header(self, monkeypatch): From 1a03e3b1c667600c4bb509e8afb01620b0999bec Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 5 May 2026 11:16:38 +0800 Subject: [PATCH 20/99] fix(kanban): detect darwin zombie workers --- hermes_cli/kanban_db.py | 29 +++++++++++++++---- .../test_kanban_core_functionality.py | 16 ++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index ac44a3d19f1e..6ca7894ee125 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -76,6 +76,7 @@ import os import re import secrets import sqlite3 +import subprocess import sys import time from dataclasses import dataclass, field @@ -2141,16 +2142,16 @@ def _pid_alive(pid: Optional[int]) -> bool: Cross-platform: uses ``os.kill(pid, 0)`` on POSIX and ``OpenProcess`` on Windows. Returns False for falsy PIDs or on any OS error. - **Zombie handling (Linux):** ``os.kill(pid, 0)`` succeeds against + **Zombie handling:** ``os.kill(pid, 0)`` succeeds against zombie processes (post-exit, pre-reap) because the process table entry still exists. A worker that exits without being reaped by its parent would stay "alive" to the dispatcher forever. Dispatcher workers are started via ``start_new_session=True`` + intentional Popen handle abandonment, so init reaps them quickly — but during the window between exit and reap, we'd otherwise see stale "alive" - signals. On Linux we additionally peek at ``/proc//status`` - and treat ``State: Z`` as dead. On other POSIX or on Windows the - zombie check is a no-op. + signals. On Linux we peek at ``/proc//status`` and treat + ``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat`` + field and treat values containing ``Z`` as dead. """ if not pid or pid <= 0: return False @@ -2164,7 +2165,8 @@ def _pid_alive(pid: Optional[int]) -> bool: return True except OSError: return False - # Still here → kill(0) succeeded. Check for zombie on Linux. + # Still here → kill(0) succeeded. Check for zombie on platforms + # where we have a cheap, deterministic process-state probe. if sys.platform == "linux": try: with open(f"/proc/{int(pid)}/status", "r") as f: @@ -2179,6 +2181,23 @@ def _pid_alive(pid: Optional[int]) -> bool: # PermissionError shouldn't happen for our own children but # be defensive. pass + elif sys.platform == "darwin": + try: + proc = subprocess.run( + ["ps", "-o", "stat=", "-p", str(int(pid))], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=1, + check=False, + ) + if proc.returncode != 0: + return False + if "Z" in (proc.stdout or "").strip(): + return False + except (OSError, subprocess.SubprocessError, TimeoutError): + # If the secondary probe fails, keep the kill(0) answer. + pass return True diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 3fe09086e50b..6bc198ab995d 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -13,9 +13,11 @@ from __future__ import annotations import argparse import json import os +import subprocess import threading import time from pathlib import Path +from types import SimpleNamespace from typing import Optional import pytest @@ -183,6 +185,20 @@ def test_pid_alive_helper(): assert not kb._pid_alive(2 ** 30) +def test_pid_alive_detects_darwin_zombie(monkeypatch): + monkeypatch.setattr(kb.sys, "platform", "darwin") + monkeypatch.setattr(kb.os, "kill", lambda pid, sig: None) + + def fake_run(args, **kwargs): + assert args == ["ps", "-o", "stat=", "-p", "123"] + assert kwargs["stdout"] is subprocess.PIPE + return SimpleNamespace(returncode=0, stdout="Z+\n") + + monkeypatch.setattr(kb.subprocess, "run", fake_run) + + assert kb._pid_alive(123) is False + + def test_detect_crashed_workers_reclaims(kanban_home): """A running task whose pid vanished gets dropped to ready with a ``crashed`` event, independent of the claim TTL.""" From 660ce7c54b9c397b2b1d23d9904159b645f04d30 Mon Sep 17 00:00:00 2001 From: Nexus Date: Tue, 28 Apr 2026 20:20:16 +0100 Subject: [PATCH 21/99] fix(ui-tui): prevent React effect cleanup from killing python TUI gateway subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useEffect at useMainApp.ts:546-565 calls gw.kill() in its cleanup function. React calls cleanup on every re-render when the dependency array ([gw, sys]) shifts — which happens whenever sys changes identity (any system message). This sends SIGTERM to the Python TUI gateway subprocess, silently killing the backend mid-session. The kill path was already handled by entry.tsx's setupGracefulExit for real app exits (SIGINT, uncaught exception). The die() function also calls gw.kill() for explicit user exit. Removing the cleanup kill leaves all exit paths covered while preventing accidental mid-session kills on ordinary React re-renders. --- ui-tui/src/app/useMainApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index b39cc29a323b..282f8da208c7 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -606,10 +606,10 @@ export function useMainApp(gw: GatewayClient) { gw.on('exit', exitHandler) gw.drain() + // entry.tsx's setupGracefulExit handles process cleanup on real exit. return () => { gw.off('event', handler) gw.off('exit', exitHandler) - gw.kill() } }, [gw, sys]) From c725d7d648a031dbcedf0d55a2f5a221ecce44e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:14:40 -0700 Subject: [PATCH 22/99] chore: AUTHOR_MAP entry for TheEpTic --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 0a99b54bcb92..c221c52ec47d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -56,6 +56,7 @@ AUTHOR_MAP = { "657290301@qq.com": "IMHaoyan", "revar@users.noreply.github.com": "revaraver", "29756950+revaraver@users.noreply.github.com": "revaraver", + "nexus@eptic.me": "TheEpTic", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From 2eef395e1cafd32ebfcf91fb6e5c6ecbd4c1e7df Mon Sep 17 00:00:00 2001 From: wmagev <74554762+wmagev@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:09:09 +0900 Subject: [PATCH 23/99] fix(compaction): mark end of context summary in role=user fallback When the head ends with assistant/tool and the tail starts with assistant, the summary is inserted as a standalone role="user" message. The body's verbatim "## Active Task" quote then gets read as fresh user input by weak/local models (#11475, #14521). The merge-into-tail path already appends an explicit end-of-summary marker for this reason. Mirror it on the standalone path so both insertion routes give the model the same "summary above, not new input" signal. --- agent/context_compressor.py | 13 +++++++++ tests/agent/test_context_compressor.py | 38 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7475c5f5d4e1..20f35fed5f14 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1418,6 +1418,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Merge the summary into the first tail message instead # of inserting a standalone message that breaks alternation. _merge_summary_into_tail = True + + # When the summary lands as a standalone role="user" message, + # weak models read the verbatim "## Active Task" quote of a past + # user request as fresh input (#11475, #14521). Append the explicit + # end marker — the same one used in the merge-into-tail path — so + # the model has a clear "summary above, not new input" signal. + if not _merge_summary_into_tail and summary_role == "user": + summary = ( + summary + + "\n\n--- END OF CONTEXT SUMMARY — " + "respond to the message below, not the summary above ---" + ) + if not _merge_summary_into_tail: compressed.append({"role": summary_role, "content": summary}) diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index fd88cc7a96ed..75a7594a0df6 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -664,6 +664,44 @@ class TestCompressWithClient: "call_123" ] + def test_user_role_summary_carries_end_marker(self): + """When the summary lands as standalone role='user' (e.g. head ends + with assistant/tool), the message body must include the explicit + '--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models + read the verbatim past user request quoted in '## Active Task' as + fresh input (#11475, #14521). + """ + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + # head_last=assistant, tail_first=assistant (same shape as the + # existing consecutive-user test) → role resolves to "user". + msgs = [ + {"role": "user", "content": "msg 0"}, + {"role": "assistant", "content": "msg 1"}, + {"role": "user", "content": "msg 2"}, + {"role": "assistant", "content": "msg 3"}, + {"role": "user", "content": "msg 4"}, + {"role": "assistant", "content": "msg 5"}, + {"role": "user", "content": "msg 6"}, + {"role": "assistant", "content": "msg 7"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + summary_msg = next( + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ) + assert summary_msg["role"] == "user" + assert "END OF CONTEXT SUMMARY" in summary_msg["content"] + assert summary_msg["content"].rstrip().endswith( + "respond to the message below, not the summary above ---" + ) + def test_summary_role_avoids_consecutive_user_messages(self): """Summary role should alternate with the last head message to avoid consecutive same-role messages.""" mock_client = MagicMock() From b93643c8fe8236f83c8c941435557b84bbe85468 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:16:12 -0700 Subject: [PATCH 24/99] chore: AUTHOR_MAP entry for wmagev --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index c221c52ec47d..662c285f2ae4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -57,6 +57,7 @@ AUTHOR_MAP = { "revar@users.noreply.github.com": "revaraver", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", + "74554762+wmagev@users.noreply.github.com": "wmagev", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From 5168226d60f66dac01dabe151104cb8e958c99c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:54:17 -0700 Subject: [PATCH 25/99] feat(file_tools): post-write delta lint on write_file + patch, add JSON/YAML/TOML/Python in-process linters (#20191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where write_file skipped the post-edit syntax check that patch already ran, so silent file corruption (bad quote escaping, truncated writes, etc.) would persist on disk until a later read. ## Changes tools/file_operations.py: - Add in-process linters for .py, .json, .yaml, .toml (LINTERS_INPROC). Python uses ast.parse, JSON/YAML/TOML use stdlib/PyYAML parsers. Zero subprocess overhead; preferred over shell linters when both apply. - _check_lint() now accepts optional content and routes to in-process linter first. Shell linter (py_compile, node --check, tsc, go vet, rustfmt) remains the fallback for languages without an in-process equivalent. - New _check_lint_delta() implements the post-first/pre-lazy pattern borrowed from Cline and OpenCode: lint post-write state first; only if errors are found AND pre-content was captured does it lint the pre-state and diff. If the pre-existing file had the SAME errors the edit didn't introduce anything new, so the file is reported as 'still broken, pre-existing' with success=False but a message explaining the errors were pre-existing. If the edit introduced genuinely new errors, those are surfaced and pre-existing ones are filtered out. - WriteResult gains a lint field. - write_file() captures pre-content for in-process-lintable extensions and calls _check_lint_delta after a successful write. - patch_replace() switches from _check_lint to _check_lint_delta, reusing the pre-edit content it already has in scope. tools/file_tools.py: - Update write_file schema description to mention the post-write lint. tests/tools/test_file_operations_edge_cases.py: - Update existing brace-path tests to use .js (shell linter) now that .py is in-process. - Add TestCheckLintInproc (9 tests) covering Python/JSON/YAML/TOML in-process linters. - Add TestCheckLintDelta (5 tests) covering the post-first/pre-lazy short-circuit, new-file path, and the single-error-parser caveat. ## Performance In-process linters are microseconds per call (ast.parse, json.loads). The hot path (clean write) runs exactly one lint — matches main's cost for patch. Pre-state capture is skipped when the file has no applicable linter. Measured 4.89ms/write average over 100 .py writes including lint. ## Inspiration - Cline's DiffViewProvider.getNewDiagnosticProblems() — filters pre-write diagnostics from post-write diagnostics (src/integrations/editor/DiffViewProvider.ts). - OpenCode's WriteTool — runs lsp.diagnostics() after write and appends errors to tool output (packages/opencode/src/tool/write.ts). - Claude Code's DiagnosticTrackingService — captures baseline via beforeFileEdited() and returns new-diagnostics-only from getNewDiagnostics() (src/services/diagnosticTracking.ts). ## Validation - tests/tools/test_file_operations.py + test_file_operations_edge_cases.py + test_file_tools.py + test_file_tools_live.py + test_file_write_safety.py + test_write_deny.py + test_patch_parser.py + test_file_ops_cwd_tracking.py: 228 passed locally. - Live E2E reproduction of the tips.py corruption incident: broken content written; lint field surfaces 'SyntaxError: invalid syntax. Perhaps you forgot a comma? (line 6, column 5)' — the exact error that would have self-corrected the bug on the next turn. --- .../tools/test_file_operations_edge_cases.py | 128 +++++++- tools/file_operations.py | 278 ++++++++++++++++-- tools/file_tools.py | 2 +- 3 files changed, 371 insertions(+), 37 deletions(-) diff --git a/tests/tools/test_file_operations_edge_cases.py b/tests/tools/test_file_operations_edge_cases.py index a53450a81433..bad72f4b6d4b 100644 --- a/tests/tools/test_file_operations_edge_cases.py +++ b/tests/tools/test_file_operations_edge_cases.py @@ -82,7 +82,11 @@ class TestIsLikelyBinary: class TestCheckLintBracePaths: - """Verify _check_lint handles file paths with curly braces safely.""" + """Verify _check_lint handles file paths with curly braces safely. + + Uses ``.js`` to exercise the shell-linter path since ``.py`` now goes + through the in-process ast.parse linter (see TestCheckLintInproc). + """ @pytest.fixture() def ops(self): @@ -95,12 +99,12 @@ class TestCheckLintBracePaths: with patch.object(ops, "_has_command", return_value=True), \ patch.object(ops, "_exec") as mock_exec: mock_exec.return_value = MagicMock(exit_code=0, stdout="") - result = ops._check_lint("/tmp/test_file.py") + result = ops._check_lint("/tmp/test_file.js") assert result.success is True # Verify the command was built correctly cmd_arg = mock_exec.call_args[0][0] - assert "'/tmp/test_file.py'" in cmd_arg + assert "'/tmp/test_file.js'" in cmd_arg def test_path_with_curly_braces(self, ops): """Path containing ``{`` and ``}`` must not raise KeyError/ValueError.""" @@ -108,7 +112,7 @@ class TestCheckLintBracePaths: patch.object(ops, "_exec") as mock_exec: mock_exec.return_value = MagicMock(exit_code=0, stdout="") # This would raise KeyError with .format() but works with .replace() - result = ops._check_lint("/tmp/{test}_file.py") + result = ops._check_lint("/tmp/{test}_file.js") assert result.success is True cmd_arg = mock_exec.call_args[0][0] @@ -119,7 +123,7 @@ class TestCheckLintBracePaths: with patch.object(ops, "_has_command", return_value=True), \ patch.object(ops, "_exec") as mock_exec: mock_exec.return_value = MagicMock(exit_code=0, stdout="") - result = ops._check_lint("/tmp/{{var}}.py") + result = ops._check_lint("/tmp/{{var}}.js") assert result.success is True @@ -131,7 +135,7 @@ class TestCheckLintBracePaths: def test_missing_linter_skipped(self, ops): """When the linter binary is not installed, skip gracefully.""" with patch.object(ops, "_has_command", return_value=False): - result = ops._check_lint("/tmp/test.py") + result = ops._check_lint("/tmp/test.js") assert result.skipped is True def test_lint_failure_returns_output(self, ops): @@ -142,12 +146,122 @@ class TestCheckLintBracePaths: exit_code=1, stdout="SyntaxError: invalid syntax", ) - result = ops._check_lint("/tmp/bad.py") + result = ops._check_lint("/tmp/bad.js") assert result.success is False assert "SyntaxError" in result.output +class TestCheckLintInproc: + """Verify in-process linters (.py via ast.parse, .json, .yaml, .toml). + + These bypass the shell linter table entirely and parse content + directly in Python — no subprocess, no toolchain dependency. + """ + + @pytest.fixture() + def ops(self): + obj = ShellFileOperations.__new__(ShellFileOperations) + obj._command_cache = {} + return obj + + def test_python_inproc_clean(self, ops): + """Valid Python content passes in-process ast.parse.""" + result = ops._check_lint("/tmp/ok.py", content="x = 1\n") + assert result.success is True + assert not result.skipped + assert result.output == "" + + def test_python_inproc_syntax_error(self, ops): + """Invalid Python content fails with SyntaxError + line info.""" + result = ops._check_lint("/tmp/bad.py", content="def foo(:\n pass\n") + assert result.success is False + assert "SyntaxError" in result.output + assert "line" in result.output.lower() + + def test_python_inproc_content_explicit(self, ops): + """When content is passed explicitly, the file is not re-read.""" + with patch.object(ops, "_exec") as mock_exec: + result = ops._check_lint("/tmp/explicit.py", content="y = 2\n") + # _exec must not have been called — content was supplied + mock_exec.assert_not_called() + assert result.success is True + + def test_json_inproc_clean(self, ops): + result = ops._check_lint("/tmp/a.json", content='{"a": 1}') + assert result.success is True + + def test_json_inproc_error(self, ops): + result = ops._check_lint("/tmp/b.json", content='{"a": 1') + assert result.success is False + assert "JSONDecodeError" in result.output + + def test_yaml_inproc_clean(self, ops): + result = ops._check_lint("/tmp/a.yaml", content="a: 1\nb: 2\n") + assert result.success is True + + def test_yaml_inproc_error(self, ops): + result = ops._check_lint("/tmp/b.yaml", content='key: "unclosed\n') + assert result.success is False + assert "YAMLError" in result.output + + def test_toml_inproc_clean(self, ops): + result = ops._check_lint("/tmp/a.toml", content='[section]\nk = "v"\n') + assert result.success is True + + def test_toml_inproc_error(self, ops): + result = ops._check_lint("/tmp/b.toml", content='[section\nk = "v"') + assert result.success is False + assert "TOMLDecodeError" in result.output + + +class TestCheckLintDelta: + """Verify _check_lint_delta() filters pre-existing errors from post-edit output.""" + + @pytest.fixture() + def ops(self): + obj = ShellFileOperations.__new__(ShellFileOperations) + obj._command_cache = {} + return obj + + def test_clean_post_no_pre_lint(self, ops): + """Hot path: post-write is clean, pre-lint should be skipped entirely.""" + with patch.object(ops, "_check_lint", wraps=ops._check_lint) as wrapped: + r = ops._check_lint_delta("/tmp/a.py", pre_content="x = 0\n", post_content="x = 1\n") + # Post-lint called exactly once (clean), pre-lint never called. + assert wrapped.call_count == 1 + assert r.success is True + + def test_new_file_reports_all_errors(self, ops): + """No pre-content means no delta refinement — all post errors surface.""" + r = ops._check_lint_delta("/tmp/new.py", pre_content=None, post_content="def x(:\n") + assert r.success is False + assert "SyntaxError" in r.output + + def test_broken_file_becomes_good(self, ops): + """Post-clean short-circuits without any delta refinement.""" + r = ops._check_lint_delta("/tmp/fix.py", pre_content="def x(:\n", post_content="def x():\n pass\n") + assert r.success is True + + def test_introduces_new_error_filters_pre(self, ops): + """Delta filter drops pre-existing errors, surfaces only new ones.""" + pre = 'def a(:\n pass\n' # line 1 broken + post = 'def a():\n pass\n\ndef b(:\n pass\n' # line 1 fixed, line 4 broken + r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post) + assert r.success is False + assert "New lint errors" in r.output or "line 4" in r.output + + def test_pre_existing_remains_flagged_but_not_new(self, ops): + """Single-error parsers (ast) may miss that post is OK — be cautious.""" + # Pre has line-1 error, post keeps it (and doesn't add anything new) + pre = 'def a(:\n pass\n' + post = 'def a(:\n pass\n\nprint(42)\n' # still line 1 broken + r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post) + # File is still broken — don't lie and claim success — but flag it as pre-existing + assert r.success is False + assert "pre-existing" in (r.message or "").lower() + + # ========================================================================= # Pagination bounds # ========================================================================= diff --git a/tools/file_operations.py b/tools/file_operations.py index 6c6dd91c6911..3f7343bb25f9 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -119,9 +119,10 @@ class WriteResult: """Result from writing a file.""" bytes_written: int = 0 dirs_created: bool = False + lint: Optional[Dict[str, Any]] = None error: Optional[str] = None warning: Optional[str] = None - + def to_dict(self) -> dict: return {k: v for k, v in self.__dict__.items() if v is not None} @@ -202,10 +203,10 @@ class LintResult: def to_dict(self) -> dict: if self.skipped: return {"status": "skipped", "message": self.message} - return { - "status": "ok" if self.success else "error", - "output": self.output - } + result = {"status": "ok" if self.success else "error", "output": self.output} + if self.message: + result["message"] = self.message + return result @dataclass @@ -303,7 +304,9 @@ class FileOperations(ABC): # Image extensions (subset of binary that we can return as base64) IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'} -# Linters by file extension +# Shell-based linters by file extension. Invoked via _exec() with the +# filesystem path. Cover languages where a compile/type check needs an +# external toolchain (py_compile, node, tsc, go vet, rustfmt). LINTERS = { '.py': 'python -m py_compile {file} 2>&1', '.js': 'node --check {file} 2>&1', @@ -312,6 +315,86 @@ LINTERS = { '.rs': 'rustfmt --check {file} 2>&1', } + +def _lint_json_inproc(content: str) -> tuple[bool, str]: + """In-process JSON syntax check. Returns (ok, error_message).""" + import json as _json + try: + _json.loads(content) + return True, "" + except _json.JSONDecodeError as e: + return False, f"JSONDecodeError: {e.msg} (line {e.lineno}, column {e.colno})" + except Exception as e: # noqa: BLE001 — any parse failure is a lint failure + return False, f"{type(e).__name__}: {e}" + + +def _lint_yaml_inproc(content: str) -> tuple[bool, str]: + """In-process YAML syntax check. Returns (ok, error_message). + + Skipped gracefully if PyYAML isn't installed — YAML parsing is optional. + """ + try: + import yaml as _yaml + except ImportError: + # PyYAML not available — skip silently, caller treats as no linter. + return True, "__SKIP__" + try: + _yaml.safe_load(content) + return True, "" + except _yaml.YAMLError as e: + return False, f"YAMLError: {e}" + except Exception as e: # noqa: BLE001 + return False, f"{type(e).__name__}: {e}" + + +def _lint_toml_inproc(content: str) -> tuple[bool, str]: + """In-process TOML syntax check (stdlib tomllib, Python 3.11+).""" + try: + import tomllib as _toml + except ImportError: + # Pre-3.11 fallback via tomli, if installed. + try: + import tomli as _toml # type: ignore[no-redef] + except ImportError: + return True, "__SKIP__" + try: + _toml.loads(content) + return True, "" + except Exception as e: # tomllib raises TOMLDecodeError, a ValueError subclass + return False, f"{type(e).__name__}: {e}" + + +def _lint_python_inproc(content: str) -> tuple[bool, str]: + """In-process Python syntax check via ast.parse. + + Catches SyntaxError, IndentationError, and everything else the + ast module rejects — matching py_compile's scope but with no + subprocess overhead and no dependency on a ``python`` in PATH. + """ + import ast as _ast + try: + _ast.parse(content) + return True, "" + except SyntaxError as e: + loc = f" (line {e.lineno}, column {e.offset})" if e.lineno else "" + return False, f"{type(e).__name__}: {e.msg}{loc}" + except Exception as e: # noqa: BLE001 + return False, f"{type(e).__name__}: {e}" + + +# In-process linters by file extension. Preferred over shell linters when +# present — no subprocess overhead, microseconds per call. Each callable +# takes file content (str) and returns (ok: bool, error: str). An error +# string of ``"__SKIP__"`` signals the linter isn't available (missing +# dependency) and should be treated as "no linter". +LINTERS_INPROC = { + '.py': _lint_python_inproc, + '.json': _lint_json_inproc, + '.yaml': _lint_yaml_inproc, + '.yml': _lint_yaml_inproc, + '.toml': _lint_toml_inproc, +} + # Max limits for read operations MAX_LINES = 2000 MAX_LINE_LENGTH = 2000 @@ -745,12 +828,19 @@ class ShellFileOperations(FileOperations): files. The content never appears in the shell command string — only the file path does. + After the write, runs a post-first / pre-lazy lint check via + ``_check_lint_delta()``. If the new content is clean, the lint + call is O(one parse). If the new content has errors, the pre-write + content is linted too and only errors newly introduced by this + write are surfaced — pre-existing problems are filtered out so + the agent isn't distracted chasing them. + Args: path: File path to write content: Content to write Returns: - WriteResult with bytes written or error + WriteResult with bytes written, lint summary, or error. """ # Expand ~ and other shell paths path = self._expand_path(path) @@ -759,36 +849,58 @@ class ShellFileOperations(FileOperations): if _is_write_denied(path): return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.") + # Capture pre-write content for lint-delta computation. Only do this + # when an in-process OR shell linter exists for this extension — no + # point paying for the read otherwise. For in-process linters we + # pass the content directly; for shell linters the pre-state isn't + # useful (we'd have to re-write-read to lint the old version, which + # defeats the purpose), so we skip the capture and accept the naive + # "all errors" report. + ext = os.path.splitext(path)[1].lower() + pre_content: Optional[str] = None + if ext in LINTERS_INPROC: + # Best-effort read; failure (file missing, permission) leaves + # pre_content as None which makes the delta step degrade + # gracefully to "report all errors". + read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null" + read_result = self._exec(read_cmd) + if read_result.exit_code == 0 and read_result.stdout: + pre_content = read_result.stdout + # Create parent directories parent = os.path.dirname(path) dirs_created = False - + if parent: mkdir_cmd = f"mkdir -p {self._escape_shell_arg(parent)}" mkdir_result = self._exec(mkdir_cmd) if mkdir_result.exit_code == 0: dirs_created = True - + # Write via stdin pipe — content bypasses shell arg parsing entirely, # so there's no ARG_MAX limit regardless of file size. write_cmd = f"cat > {self._escape_shell_arg(path)}" write_result = self._exec(write_cmd, stdin_data=content) - + if write_result.exit_code != 0: return WriteResult(error=f"Failed to write file: {write_result.stdout}") - + # Get bytes written (wc -c is POSIX, works on Linux + macOS) stat_cmd = f"wc -c < {self._escape_shell_arg(path)} 2>/dev/null" stat_result = self._exec(stat_cmd) - + try: bytes_written = int(stat_result.stdout.strip()) except ValueError: bytes_written = len(content.encode('utf-8')) - + + # Post-write lint with delta refinement. + lint_result = self._check_lint_delta(path, pre_content=pre_content, post_content=content) + return WriteResult( bytes_written=bytes_written, - dirs_created=dirs_created + dirs_created=dirs_created, + lint=lint_result.to_dict() if lint_result else None, ) # ========================================================================= @@ -864,10 +976,12 @@ class ShellFileOperations(FileOperations): # Generate diff diff = self._unified_diff(content, new_content, path) - - # Auto-lint - lint_result = self._check_lint(path) - + + # Auto-lint with delta refinement: only surface errors introduced + # by this patch, filtering out pre-existing lint failures so the + # agent isn't distracted by problems that were already there. + lint_result = self._check_lint_delta(path, pre_content=content, post_content=new_content) + return PatchResult( success=True, diff=diff, @@ -905,37 +1019,143 @@ class ShellFileOperations(FileOperations): result = apply_v4a_operations(operations, self) return result - def _check_lint(self, path: str) -> LintResult: + def _check_lint(self, path: str, content: Optional[str] = None) -> LintResult: """ Run syntax check on a file after editing. - + + Prefers the in-process linter for structured formats (JSON, YAML, + TOML) when possible — those parse via the Python stdlib in + microseconds and don't require a subprocess. Falls back to the + shell linter table for compiled/type-checked languages + (py_compile, node --check, tsc, go vet, rustfmt). + Args: - path: File path to lint - + path: File path (used to select the linter + for shell invocation). + content: Optional file content. If provided AND an in-process + linter matches the extension, we lint the content + directly without re-reading the file from disk. Ignored + for shell linters. + Returns: - LintResult with status and any errors + LintResult with status and any errors. """ ext = os.path.splitext(path)[1].lower() - + + # Prefer in-process linter when available. + inproc = LINTERS_INPROC.get(ext) + if inproc is not None: + # Need content — either passed in or read from disk. + if content is None: + read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null" + read_result = self._exec(read_cmd) + if read_result.exit_code != 0: + return LintResult(skipped=True, message=f"Failed to read {path} for lint") + content = read_result.stdout + ok, err = inproc(content) + if err == "__SKIP__": + return LintResult(skipped=True, message=f"No linter available for {ext} (missing dependency)") + return LintResult(success=ok, output="" if ok else err) + + # Fall back to shell linter. if ext not in LINTERS: return LintResult(skipped=True, message=f"No linter for {ext} files") - - # Check if linter command is available + linter_cmd = LINTERS[ext] # Extract the base command (first word) base_cmd = linter_cmd.split()[0] - + if not self._has_command(base_cmd): return LintResult(skipped=True, message=f"{base_cmd} not available") - + # Run linter cmd = linter_cmd.replace("{file}", self._escape_shell_arg(path)) result = self._exec(cmd, timeout=30) - + return LintResult( success=result.exit_code == 0, output=result.stdout.strip() if result.stdout.strip() else "" ) + + def _check_lint_delta(self, path: str, pre_content: Optional[str], + post_content: Optional[str] = None) -> LintResult: + """ + Run post-write lint with pre-write baseline comparison. + + Strategy (post-first, pre-lazy): + 1. Lint the post-write state. If clean → return clean immediately. + This is the hot path and matches _check_lint() in cost. + 2. If post-lint found errors AND we have pre-write content, lint + that too. If the pre-write file was already broken, return only + the *new* errors introduced by this edit — errors that existed + before aren't the agent's problem to chase right now. + 3. If pre_content is None (new file or unavailable), skip the delta + step and return all post-write errors. + + This mirrors Cline's and OpenCode's post-edit LSP pattern: surface + only the errors this specific edit introduced, so the agent doesn't + get distracted by pre-existing problems. + + Args: + path: File path (for linter selection). + pre_content: File content BEFORE the write. Pass None for new + files or when the pre-state isn't available — the + delta refinement is skipped and all post errors + are returned. + post_content: File content AFTER the write. Optional; if None, + the shell linter reads from disk (same as + _check_lint). + + Returns: + LintResult. ``output`` contains either the full post-lint + errors (no pre-state) or just the new-error lines (delta + refinement applied). + """ + post = self._check_lint(path, content=post_content) + + # Hot path: clean post-write, no pre-lint needed. + if post.success or post.skipped: + return post + + # Post-write has errors. If we have pre-content, run the delta + # refinement to filter out pre-existing errors. + if pre_content is None: + return post + + pre = self._check_lint(path, content=pre_content) + if pre.success or pre.skipped or not pre.output: + # Pre-write was clean (or we couldn't lint it) — post errors + # are all new. Return the full post output. + return post + + # Both pre- and post-write had errors. Compute the set-difference + # on non-empty stripped lines. Caveat: single-error parsers + # (ast.parse, json.loads) stop at the first error and don't report + # later ones — if the pre-existing error blocks parsing before + # reaching the edit region, we can't prove the edit is clean. So + # if every post error also appeared pre-edit, we report the file + # as still broken but annotate that this edit introduced nothing + # new on top — the agent knows it's inherited state, not fresh + # damage, without silently dropping the error. + pre_lines = {ln.strip() for ln in pre.output.splitlines() if ln.strip()} + post_lines = [ln for ln in post.output.splitlines() if ln.strip() and ln.strip() not in pre_lines] + + if not post_lines: + # Every error in post was also in pre — this edit didn't make + # anything obviously worse, but the file remains broken and + # the agent should know. + return LintResult( + success=False, + output=post.output, + message="Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.", + ) + + return LintResult( + success=False, + output=( + "New lint errors introduced by this edit " + "(pre-existing errors filtered out):\n" + "\n".join(post_lines) + ) + ) # ========================================================================= # SEARCH Implementation diff --git a/tools/file_tools.py b/tools/file_tools.py index 106bd295be96..200287dcbd5f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1042,7 +1042,7 @@ READ_FILE_SCHEMA = { WRITE_FILE_SCHEMA = { "name": "write_file", - "description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits.", + "description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits. Auto-runs syntax checks on .py/.json/.yaml/.toml and other linted languages; only NEW errors introduced by this write are surfaced (pre-existing errors are filtered out).", "parameters": { "type": "object", "properties": { From 68c1a08ad114e6d1cfdcecd09880b5b594230b77 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Mon, 4 May 2026 09:51:00 +0800 Subject: [PATCH 26/99] fix(curator): protect hub skills by frontmatter name --- tests/tools/test_skill_usage.py | 41 +++++++++++++++++++++++++++++++++ tools/skill_usage.py | 21 ++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_skill_usage.py b/tests/tools/test_skill_usage.py index b66e2bba765c..232a17a9cda8 100644 --- a/tests/tools/test_skill_usage.py +++ b/tests/tools/test_skill_usage.py @@ -225,6 +225,47 @@ def test_agent_created_excludes_hub_installed(skills_home): assert "hub-skill" not in names +def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home): + from tools.skill_usage import is_agent_created, list_agent_created_skill_names + + skills_dir = skills_home / "skills" + hub_skill = skills_dir / "productivity" / "getnote" + hub_skill.mkdir(parents=True) + (hub_skill / "SKILL.md").write_text( + """--- +name: Get笔记 +description: test skill +--- + +# body +""", + encoding="utf-8", + ) + _write_skill(skills_dir, "my-skill") + hub_dir = skills_dir / ".hub" + hub_dir.mkdir() + (hub_dir / "lock.json").write_text( + json.dumps( + { + "version": 1, + "installed": { + "getnote": { + "source": "taps/main", + "install_path": "productivity/getnote", + } + }, + } + ), + encoding="utf-8", + ) + + names = list_agent_created_skill_names() + assert "my-skill" in names + assert "Get笔记" not in names + assert is_agent_created("Get笔记") is False + assert is_agent_created("getnote") is False + + def test_is_agent_created(skills_home): from tools.skill_usage import is_agent_created skills_dir = skills_home / "skills" diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 0491f1d8b1a4..053f27b224c9 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -143,7 +143,26 @@ def _read_hub_installed_names() -> Set[str]: if isinstance(data, dict): installed = data.get("installed") or {} if isinstance(installed, dict): - return {str(k) for k in installed.keys()} + names = {str(k) for k in installed.keys()} + skills_dir = _skills_dir() + for entry in installed.values(): + if not isinstance(entry, dict): + continue + install_path = entry.get("install_path") + if not isinstance(install_path, str) or not install_path.strip(): + continue + skill_dir = Path(install_path) + if not skill_dir.is_absolute(): + skill_dir = skills_dir / skill_dir + try: + resolved = skill_dir.resolve() + resolved.relative_to(skills_dir.resolve()) + except (OSError, ValueError): + continue + skill_md = resolved / "SKILL.md" + if skill_md.exists(): + names.add(_read_skill_name(skill_md, fallback=resolved.name)) + return names except (OSError, json.JSONDecodeError) as e: logger.debug("Failed to read hub lock file: %s", e) return set() From 4d0f59fa5ae0d007bca1125d69f197c042cb84db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:54:59 -0700 Subject: [PATCH 27/99] test(skill_usage): add mark_agent_created to regression test The cherry-picked test predates #19618/#19621 which rewrote list_agent_created_skill_names() to require an explicit created_by: 'agent' provenance marker. Without mark_agent_created(), my-skill is excluded from the list and the positive assertion fails. --- tests/tools/test_skill_usage.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_skill_usage.py b/tests/tools/test_skill_usage.py index 232a17a9cda8..996aaa9d6de9 100644 --- a/tests/tools/test_skill_usage.py +++ b/tests/tools/test_skill_usage.py @@ -226,7 +226,11 @@ def test_agent_created_excludes_hub_installed(skills_home): def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home): - from tools.skill_usage import is_agent_created, list_agent_created_skill_names + from tools.skill_usage import ( + is_agent_created, + list_agent_created_skill_names, + mark_agent_created, + ) skills_dir = skills_home / "skills" hub_skill = skills_dir / "productivity" / "getnote" @@ -242,6 +246,7 @@ description: test skill encoding="utf-8", ) _write_skill(skills_dir, "my-skill") + mark_agent_created("my-skill") hub_dir = skills_dir / ".hub" hub_dir.mkdir() (hub_dir / "lock.json").write_text( From cca8587d355cce26a8f161a2a3b26dbfcac2108b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:56:54 -0700 Subject: [PATCH 28/99] docs(quickstart): link Onchain AI Garage Hermes tutorials playlist (#20192) * revert(gateway): remove stale-code self-check and auto-restart Removes the _detect_stale_code / _trigger_stale_code_restart mechanism introduced in #17648 and iterated in #19740. On every incoming message the gateway compared the boot-time git HEAD SHA to the current SHA on disk, and if they differed it would reply with Gateway code was updated in the background -- restarting this gateway so your next message runs on the new code. Please retry in a moment. and then kick off a graceful restart. This is unwanted behaviour: users who run a long-lived gateway and do their own ad-hoc git operations on the checkout end up with their chat interrupted and the current message dropped every time HEAD moves, with no way to opt out. If an operator really needs the old protection against stale sys.modules after "hermes update", the SIGKILL-survivor sweep in hermes update (hermes_cli/main.py, also tagged #17648) already handles the supervisor-respawn case on its own. Removed: gateway/run.py: - _STALE_CODE_SENTINELS, _GIT_SHA_CACHE_TTL_SECS - _read_git_head_sha(), _compute_repo_mtime() module helpers - class-level _boot_wall_time / _boot_repo_mtime / _boot_git_sha / _stale_code_restart_triggered defaults - __init__ boot-snapshot block (_boot_*, _cached_current_sha*, _repo_root_for_staleness, _stale_code_notified) - _current_git_sha_cached(), _detect_stale_code(), _trigger_stale_code_restart() methods - stale-code check + user-facing restart notice at the top of _handle_message() tests/gateway/test_stale_code_self_check.py (deleted, 412 lines) No new logic added. Zero remaining references to any removed symbol. Gateway test suite passes the same 4589 tests it passed before; the 3 pre-existing unrelated failures (discord free-channel, feishu bot admission, teams typing) are unchanged by this commit. * docs(quickstart): link Onchain AI Garage Hermes tutorials playlist Adds a 'Prefer to watch?' tip callout near the top of the quickstart page pointing to @OnchainAIGarage's Hermes Agent Tutorials + Use Cases playlist, which includes a Masterclass series covering install, setup, and basic commands. * docs(quickstart): embed Masterclass video in Prefer to watch section Swaps the plain-link tip callout for an inline responsive YouTube embed of the Hermes Agent Masterclass (R3YOGfTBcQg) plus a kept link to the full Onchain AI Garage tutorials playlist. --- website/docs/getting-started/quickstart.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 01c5239a1101..a65177f90192 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -8,6 +8,21 @@ description: "Your first conversation with Hermes Agent — from install to chat This guide gets you from zero to a working Hermes setup that survives real use. Install, choose a provider, verify a working chat, and know exactly what to do when something breaks. +## Prefer to watch? + +**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/channel/UCqB1bhMwGsW-yefBxYwFCCg) playlist. + +
+ +
+ ## Who this is for - Brand new and want the shortest path to a working setup From 354502ee483f4bd3a0b1b8ef650482762b2444e0 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 5 May 2026 11:07:13 +0800 Subject: [PATCH 29/99] fix(kanban): preserve dashboard completion summaries --- hermes_cli/kanban.py | 50 ++++++++++++++ hermes_cli/kanban_db.py | 67 +++++++++++++++++++ plugins/kanban/dashboard/dist/index.js | 28 +++++++- plugins/kanban/dashboard/plugin_api.py | 10 ++- .../test_kanban_core_functionality.py | 42 ++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 43 ++++++++++++ 6 files changed, 236 insertions(+), 4 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 4d738eaff0ff..eb761a2afb6b 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -343,6 +343,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help='JSON dict of structured facts (e.g. \'{"changed_files": [...], ' '"tests_run": 12}\'). Stored on the closing run.') + p_edit = sub.add_parser( + "edit", + help="Edit recovery fields on an already-completed task", + ) + p_edit.add_argument("task_id") + p_edit.add_argument( + "--result", + required=True, + help="Backfilled task result text for a done task", + ) + p_edit.add_argument( + "--summary", + default=None, + help="Structured handoff summary. Falls back to --result if omitted.", + ) + p_edit.add_argument( + "--metadata", + default=None, + help="JSON dict of structured facts to store on the latest completed run.", + ) + p_block = sub.add_parser("block", help="Mark one or more tasks blocked") p_block.add_argument("task_id") p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)") @@ -581,6 +602,7 @@ def kanban_command(args: argparse.Namespace) -> int: "claim": _cmd_claim, "comment": _cmd_comment, "complete": _cmd_complete, + "edit": _cmd_edit, "block": _cmd_block, "unblock": _cmd_unblock, "archive": _cmd_archive, @@ -1187,6 +1209,34 @@ def _cmd_complete(args: argparse.Namespace) -> int: return 0 if not failed else 1 +def _cmd_edit(args: argparse.Namespace) -> int: + raw_meta = getattr(args, "metadata", None) + metadata = None + if raw_meta: + try: + metadata = json.loads(raw_meta) + if not isinstance(metadata, dict): + raise ValueError("must be a JSON object") + except (ValueError, json.JSONDecodeError) as exc: + print(f"kanban: --metadata: {exc}", file=sys.stderr) + return 2 + with kb.connect() as conn: + if not kb.edit_completed_task_result( + conn, + args.task_id, + result=args.result, + summary=getattr(args, "summary", None), + metadata=metadata, + ): + print( + f"cannot edit {args.task_id} (unknown id or task is not done)", + file=sys.stderr, + ) + return 1 + print(f"Edited {args.task_id}") + return 0 + + def _cmd_block(args: argparse.Namespace) -> int: reason = " ".join(args.reason).strip() if args.reason else None author = _profile_author() diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6ca7894ee125..98607e3460c8 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1917,6 +1917,73 @@ def complete_task( return True +def edit_completed_task_result( + conn: sqlite3.Connection, + task_id: str, + *, + result: str, + summary: Optional[str] = None, + metadata: Optional[dict] = None, +) -> bool: + """Backfill the user-visible result for an already completed task.""" + handoff_summary = summary if summary is not None else result + with write_txn(conn): + row = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (task_id,), + ).fetchone() + if not row or row["status"] != "done": + return False + conn.execute( + "UPDATE tasks SET result = ? WHERE id = ?", + (result, task_id), + ) + run = conn.execute( + """ + SELECT id FROM task_runs + WHERE task_id = ? + AND outcome = 'completed' + ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC + LIMIT 1 + """, + (task_id,), + ).fetchone() + run_id = int(run["id"]) if run else None + if run_id is None: + run_id = _synthesize_ended_run( + conn, task_id, + outcome="completed", + summary=handoff_summary, + metadata=metadata, + ) + else: + conn.execute( + "UPDATE task_runs SET summary = ? WHERE id = ?", + (handoff_summary, run_id), + ) + if metadata is not None: + conn.execute( + "UPDATE task_runs SET metadata = ? WHERE id = ?", + (json.dumps(metadata, ensure_ascii=False), run_id), + ) + ev_summary = ( + handoff_summary.strip().splitlines()[0][:400] + if handoff_summary else "" + ) + _append_event( + conn, task_id, "edited", + { + "fields": ( + ["result", "summary"] + + (["metadata"] if metadata is not None else []) + ), + "result_len": len(result) if result else 0, + "summary": ev_summary or None, + }, + run_id=run_id, + ) + return True + + def block_task( conn: sqlite3.Connection, task_id: str, diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index ee1a25f06517..67bc6d6d23a2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -60,6 +60,22 @@ blocked: "Mark this task as blocked? The worker's claim is released.", }; + function withCompletionSummary(patch, count) { + if (!patch || patch.status !== "done") return patch; + const label = count && count > 1 ? `${count} selected task(s)` : "this task"; + const value = window.prompt( + `Completion summary for ${label}. This is stored as the task result.`, + "", + ); + if (value === null) return null; + const summary = value.trim(); + if (!summary) { + window.alert("Completion summary is required before marking a task done."); + return null; + } + return Object.assign({}, patch, { result: summary, summary }); + } + const API = "/api/plugins/kanban"; const MIME_TASK = "text/x-hermes-task"; @@ -480,6 +496,8 @@ const moveTask = useCallback(function (taskId, newStatus) { const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; if (confirmMsg && !window.confirm(confirmMsg)) return; + const patch = withCompletionSummary({ status: newStatus }, 1); + if (!patch) return; setBoardData(function (b) { if (!b) return b; let moved = null; @@ -499,7 +517,7 @@ SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: newStatus }), + body: JSON.stringify(patch), }).catch(function (err) { setError(`Move failed: ${err.message || err}`); loadBoard(); @@ -538,7 +556,9 @@ const applyBulk = useCallback(function (patch, confirmMsg) { if (selectedIds.size === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; - const body = Object.assign({ ids: Array.from(selectedIds) }, patch); + const finalPatch = withCompletionSummary(patch, selectedIds.size); + if (!finalPatch) return; + const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch); SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1426,10 +1446,12 @@ if (opts && opts.confirm && !window.confirm(opts.confirm)) { return Promise.resolve(); } + const finalPatch = withCompletionSummary(patch, 1); + if (!finalPatch) return Promise.resolve(); return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), + body: JSON.stringify(finalPatch), }).then(function () { load(); props.onRefresh(); }); }; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2378baaac7ac..e1dfd9c190ad 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -630,6 +630,9 @@ class BulkTaskBody(BaseModel): assignee: Optional[str] = None # "" or None = unassign priority: Optional[int] = None archive: bool = False + result: Optional[str] = None + summary: Optional[str] = None + metadata: Optional[dict] = None @router.post("/tasks/bulk") @@ -660,7 +663,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): if payload.status is not None and not payload.archive: s = payload.status if s == "done": - ok = kanban_db.complete_task(conn, tid) + ok = kanban_db.complete_task( + conn, tid, + result=payload.result, + summary=payload.summary, + metadata=payload.metadata, + ) elif s == "blocked": ok = kanban_db.block_task(conn, tid) elif s == "ready": diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 6bc198ab995d..6378af8e98d3 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -1389,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home): assert r.metadata == {"files": 3} +def test_cli_edit_backfills_result_on_done_task(kanban_home): + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + kb.complete_task(conn, tid) + finally: + conn.close() + + meta = '{"source": "dashboard-recovery"}' + out = run_slash( + "edit " + tid + + " --result \"DECIDED: done\"" + + " --summary \"DECIDED: done\"" + + " --metadata '" + meta + "'" + ) + + assert "Edited" in out + conn = kb.connect() + try: + task = kb.get_task(conn, tid) + run = kb.latest_run(conn, tid) + events = kb.list_events(conn, tid) + finally: + conn.close() + assert task.result == "DECIDED: done" + assert run.summary == "DECIDED: done" + assert run.metadata == {"source": "dashboard-recovery"} + assert events[-1].kind == "edited" + + +def test_cli_edit_rejects_non_done_task(kanban_home): + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + finally: + conn.close() + + out = run_slash(f"edit {tid} --result nope") + + assert "not done" in out + + def test_cli_complete_bad_metadata_exits_nonzero(kanban_home): conn = kb.connect() try: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 32e40ceb4ee3..ca8f59cccfe7 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -561,6 +561,49 @@ def test_bulk_status_ready(client): assert {a["id"], b["id"], c2["id"]}.issubset(ids) +def test_bulk_status_done_forwards_completion_summary(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + + r = client.post( + "/api/plugins/kanban/tasks/bulk", + json={ + "ids": [a["id"], b["id"]], + "status": "done", + "result": "DECIDED: ship it", + "summary": "DECIDED: ship it", + "metadata": {"source": "dashboard"}, + }, + ) + + assert r.status_code == 200 + assert all(r["ok"] for r in r.json()["results"]) + conn = kb.connect() + try: + for tid in (a["id"], b["id"]): + task = kb.get_task(conn, tid) + run = kb.latest_run(conn, tid) + assert task.status == "done" + assert task.result == "DECIDED: ship it" + assert run.summary == "DECIDED: ship it" + assert run.metadata == {"source": "dashboard"} + finally: + conn.close() + + +def test_dashboard_done_actions_prompt_for_completion_summary(): + repo_root = Path(__file__).resolve().parents[2] + bundle = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + ).read_text() + + assert "withCompletionSummary" in bundle + assert "Completion summary" in bundle + assert "result: summary" in bundle + assert "body: JSON.stringify(patch)" in bundle + assert "body: JSON.stringify(finalPatch)" in bundle + + def test_bulk_archive(client): a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] From 6b76ea4707e75cba4d8a1fd6094c89d778fae412 Mon Sep 17 00:00:00 2001 From: Asher Morse Date: Tue, 28 Apr 2026 13:55:32 -0500 Subject: [PATCH 30/99] fix(gateway): load reply_to_mode from config.yaml for Discord and Telegram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YAML-to-env-var bridge in load_gateway_config() mapped every Discord and Telegram config key (require_mention, auto_thread, reactions, etc.) except reply_to_mode. Users setting discord.reply_to_mode or telegram.reply_to_mode in ~/.hermes/config.yaml got no effect — the adapter only read the env var, which nothing populated from YAML. Add the missing bridge for both platforms, following the existing pattern. Top-level .reply_to_mode preferred, falls back to .extra.reply_to_mode, env var never overwritten. Handles YAML 1.1 bare `off` → Python False coercion. This is a re-submission of the work from #9837 and #13930, which both implemented the same fix but neither landed (see co-authors below). Co-authored-by: Matteo De Agazio Co-authored-by: ishardo <239075732+ishardo@users.noreply.github.com> --- gateway/config.py | 20 +++++++ tests/gateway/test_discord_reply_mode.py | 66 ++++++++++++++++++++++- tests/gateway/test_telegram_reply_mode.py | 66 ++++++++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index fa64b9046d4d..2e0e3276b7b2 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -845,6 +845,16 @@ def load_gateway_config() -> GatewayConfig: ): if yaml_key in allow_mentions_cfg and not os.getenv(env_key): os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() + # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode + # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". + _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {} + _discord_rtm = ( + discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg + else _discord_extra.get("reply_to_mode") + ) + if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"): + _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower() + os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str # Bridge top-level require_mention to Telegram when the telegram: section # does not already provide one. Users often write "require_mention: true" @@ -881,6 +891,16 @@ def load_gateway_config() -> GatewayConfig: os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"): os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip() + # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode + # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". + _telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {} + _telegram_rtm = ( + telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg + else _telegram_extra.get("reply_to_mode") + ) + if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"): + _rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower() + os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str allowed_users = telegram_cfg.get("allow_from") if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"): if isinstance(allowed_users, list): diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index 9060fe2940c5..64e27a27aa84 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock, AsyncMock, patch import pytest -from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides +from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config def _ensure_discord_mock(): @@ -396,3 +396,67 @@ class TestReplyToText: event = reply_text_adapter.handle_message.await_args.args[0] assert event.reply_to_message_id == "555" assert event.reply_to_text is None + + +class TestYamlConfigLoading: + """Tests for reply_to_mode loaded from config.yaml discord section.""" + + def _write_config(self, tmp_path, content: str): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(content, encoding="utf-8") + return hermes_home + + def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch): + """YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'.""" + hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: off\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off" + + def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch): + hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all" + + def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch): + """discord.extra.reply_to_mode is also honoured.""" + hermes_home = self._write_config( + tmp_path, "discord:\n extra:\n reply_to_mode: \"off\"\n" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off" + + def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch): + """Existing DISCORD_REPLY_TO_MODE env var is not overwritten by YAML.""" + hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("DISCORD_REPLY_TO_MODE", "first") + + load_gateway_config() + + assert os.environ.get("DISCORD_REPLY_TO_MODE") == "first" + + def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch): + """discord.reply_to_mode wins over discord.extra.reply_to_mode.""" + hermes_home = self._write_config( + tmp_path, + "discord:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all" diff --git a/tests/gateway/test_telegram_reply_mode.py b/tests/gateway/test_telegram_reply_mode.py index a433b1801633..1389736fe921 100644 --- a/tests/gateway/test_telegram_reply_mode.py +++ b/tests/gateway/test_telegram_reply_mode.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock, AsyncMock, patch import pytest -from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides +from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config def _ensure_telegram_mock(): @@ -240,3 +240,67 @@ class TestEnvVarOverride: with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": ""}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.TELEGRAM].reply_to_mode == "first" + + +class TestTelegramYamlConfigLoading: + """Tests for reply_to_mode loaded from config.yaml telegram section.""" + + def _write_config(self, tmp_path, content: str): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(content, encoding="utf-8") + return hermes_home + + def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch): + """YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'.""" + hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: off\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off" + + def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch): + hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all" + + def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch): + """telegram.extra.reply_to_mode is also honoured.""" + hermes_home = self._write_config( + tmp_path, "telegram:\n extra:\n reply_to_mode: \"off\"\n" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off" + + def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch): + """Existing TELEGRAM_REPLY_TO_MODE env var is not overwritten by YAML.""" + hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_REPLY_TO_MODE", "first") + + load_gateway_config() + + assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "first" + + def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch): + """telegram.reply_to_mode wins over telegram.extra.reply_to_mode.""" + hermes_home = self._write_config( + tmp_path, + "telegram:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False) + + load_gateway_config() + + assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all" From 4577f392f9e64416efa241f534357e0da9ab8c05 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:18:28 -0700 Subject: [PATCH 31/99] chore: AUTHOR_MAP entry for ashermorse --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 662c285f2ae4..64417195d1b9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -58,6 +58,7 @@ AUTHOR_MAP = { "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", + "ashermorse@icloud.com": "ashermorse", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From efe1cb00c88234ab4c81055a8aac07689a315508 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 5 May 2026 04:39:51 -0700 Subject: [PATCH 32/99] fix: prevent stale reasoning from being reused across turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning-box extraction loop in run_conversation() walked backwards through the entire message history looking for any assistant message with a non-empty 'reasoning' field. When the current turn produced no reasoning (e.g. the provider returned reasoning_content=null for a trivial response), the loop walked past the current turn and showed reasoning from a prior turn — stale text from minutes or hours ago displayed as if it belonged to the current reply. Fix: stop the walk at the user message that started the current turn. That picks the most recent reasoning WITHIN the turn (correct for tool-calling turns where reasoning lands on the tool-call step and the final-answer step has reasoning=None — common on Claude thinking, DeepSeek v4, Codex Responses), and returns None cleanly when the current turn genuinely had no reasoning. Co-authored-by: happy5318 --- run_agent.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 3554ff665d50..46197eee76f2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -13952,9 +13952,19 @@ class AIAgent: except Exception as exc: logger.warning("post_llm_call hook failed: %s", exc) - # Extract reasoning from the last assistant message (if any) + # Extract reasoning from the CURRENT turn only. Walk backwards + # but stop at the user message that started this turn — anything + # earlier is from a prior turn and must not leak into the reasoning + # box (confusing stale display; #17055). Within the current turn + # we still want the *most recent* non-empty reasoning: many + # providers (Claude thinking, DeepSeek v4, Codex Responses) emit + # reasoning on the tool-call step and leave the final-answer step + # with reasoning=None, so picking only the last assistant would + # silently drop legitimate same-turn reasoning. last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break # turn boundary — don't cross into prior turns if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break From 9e0ef2a1bcc068dfbc3b2624daafe66fa3cfea17 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:39:58 -0700 Subject: [PATCH 33/99] test: pin per-turn reasoning extraction semantics Covers four scenarios for the reasoning-box extraction loop: - simple turn with reasoning - simple turn with no reasoning - tool-calling turn where reasoning lives on the tool-call step - prior turn had reasoning, current turn does not (the stale-display bug the fix exists for) - tool-calling turn where reasoning lives on BOTH steps (latest wins) - empty-string reasoning treated as missing Also updates the four inline replica loops in tests/cli/test_reasoning_command.py to match the new turn-boundary shape so the test file reflects production semantics. --- tests/cli/test_reasoning_command.py | 10 ++ .../run_agent/test_last_reasoning_per_turn.py | 107 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/run_agent/test_last_reasoning_per_turn.py diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py index 228d2904b16a..f5f7e35cbe7d 100644 --- a/tests/cli/test_reasoning_command.py +++ b/tests/cli/test_reasoning_command.py @@ -178,6 +178,8 @@ class TestLastReasoningInResult(unittest.TestCase): messages = self._build_messages(reasoning="Let me think...") last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -187,6 +189,8 @@ class TestLastReasoningInResult(unittest.TestCase): messages = self._build_messages(reasoning=None) last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -201,6 +205,8 @@ class TestLastReasoningInResult(unittest.TestCase): ] last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -210,6 +216,8 @@ class TestLastReasoningInResult(unittest.TestCase): messages = self._build_messages(reasoning="") last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break @@ -584,6 +592,8 @@ class TestEndToEndPipeline(unittest.TestCase): last_reasoning = None for msg in reversed(messages): + if msg.get("role") == "user": + break if msg.get("role") == "assistant" and msg.get("reasoning"): last_reasoning = msg["reasoning"] break diff --git a/tests/run_agent/test_last_reasoning_per_turn.py b/tests/run_agent/test_last_reasoning_per_turn.py new file mode 100644 index 000000000000..c7ddca5fc6c1 --- /dev/null +++ b/tests/run_agent/test_last_reasoning_per_turn.py @@ -0,0 +1,107 @@ +"""Tests for per-turn reasoning extraction in AIAgent.run_conversation. + +Verifies the reasoning field returned to display layers (CLI reasoning box, +gateway reasoning footer, TUI reasoning event) only reflects the CURRENT +turn's reasoning — never leaks from a prior turn — and is picked up +correctly when reasoning is attached to a tool-calling assistant step +rather than the final-answer assistant step. +""" +from __future__ import annotations + + +def _extract_last_reasoning(messages): + """Replica of the extraction loop in run_agent.py (~line 13867). + + Tests pin the loop's behaviour so that refactors can't silently + regress the per-turn semantic. + """ + last_reasoning = None + for msg in reversed(messages): + if msg.get("role") == "user": + break + if msg.get("role") == "assistant" and msg.get("reasoning"): + last_reasoning = msg["reasoning"] + break + return last_reasoning + + +def test_simple_turn_reasoning_present(): + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi", "reasoning": "greeting the user"}, + ] + assert _extract_last_reasoning(messages) == "greeting the user" + + +def test_simple_turn_no_reasoning(): + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) is None + + +def test_tool_call_turn_reasoning_on_tool_call_step(): + """When the model reasons on the tool-call step and the final-answer + step has no reasoning (Claude thinking / DeepSeek v4 / Codex Responses + pattern), the box must show the tool-call-step reasoning, not empty. + """ + messages = [ + {"role": "user", "content": "search the repo for X"}, + { + "role": "assistant", + "content": "", + "reasoning": "I should use search_files", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "search_files", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "3 matches"}, + {"role": "assistant", "content": "Found 3 matches", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) == "I should use search_files" + + +def test_no_stale_reasoning_across_turns(): + """The regression the whole change exists for. Prior turn had + reasoning; current turn has none. The reasoning box must NOT show + the prior turn's text. + """ + messages = [ + # prior turn + {"role": "user", "content": "explain quantum tunneling"}, + {"role": "assistant", "content": "It's when...", + "reasoning": "tunneling happens when particles..."}, + # current turn + {"role": "user", "content": "thanks"}, + {"role": "assistant", "content": "You're welcome!", "reasoning": None}, + ] + assert _extract_last_reasoning(messages) is None + + +def test_tool_call_turn_picks_latest_reasoning_within_turn(): + """If BOTH the tool-call step and the final step have reasoning + (uncommon but possible), the final-step reasoning wins — it's the + most recent thought within the current turn. + """ + messages = [ + {"role": "user", "content": "search and summarize"}, + { + "role": "assistant", + "content": "", + "reasoning": "initial plan", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "search_files", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "results"}, + {"role": "assistant", "content": "Here's the summary", + "reasoning": "synthesized view of results"}, + ] + assert _extract_last_reasoning(messages) == "synthesized view of results" + + +def test_empty_string_reasoning_treated_as_missing(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello", "reasoning": ""}, + ] + assert _extract_last_reasoning(messages) is None From 83a07f4759849b3485f81e8afb4d2fafe547112a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:40:12 -0700 Subject: [PATCH 34/99] chore: AUTHOR_MAP entry for happy5318 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 64417195d1b9..801c35c023da 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", "ashermorse@icloud.com": "ashermorse", + "happy5318@users.noreply.github.com": "happy5318", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From 8f4c0bf0882c3c7258a65e3adade12d5b08068ea Mon Sep 17 00:00:00 2001 From: chengoak Date: Tue, 28 Apr 2026 22:02:16 +0800 Subject: [PATCH 35/99] fix(wecom): pad base64 AES key before decode WeCom doesn't pad base64 aeskey, causing Python strict mode decode failure on media/image/file messages. Add automatic padding before base64 decode: aes_key + '=' * ((4 - len(aes_key) % 4) % 4). Salvages the AES padding fix from @chengoak's PR #17040. The SSRF whitelist entry for a private COS bucket hostname was dropped as it belongs in user config, not the built-in trusted-private-IP-hosts list. The debug-level full-body info log was dropped to avoid logging potentially sensitive message content at INFO level. --- gateway/platforms/wecom.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 873284de7961..c93a8fe3d65b 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -1015,6 +1015,8 @@ class WeComAdapter(BasePlatformAdapter): if not aes_key: raise ValueError("aes_key is required") + # WeCom doesn't pad base64 keys; add padding if needed + aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4) key = base64.b64decode(aes_key) if len(key) != 32: raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}") From 046c2931831967ff604e5a92fe72f0deab500dc0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:22:59 -0700 Subject: [PATCH 36/99] chore: AUTHOR_MAP entry for chengoak --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 801c35c023da..ea29bac0bbfa 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -60,6 +60,7 @@ AUTHOR_MAP = { "74554762+wmagev@users.noreply.github.com": "wmagev", "ashermorse@icloud.com": "ashermorse", "happy5318@users.noreply.github.com": "happy5318", + "chengoak@users.noreply.github.com": "chengoak", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From 0a7cc85eab299667777489a31995eb324d8b7818 Mon Sep 17 00:00:00 2001 From: qxxaa Date: Tue, 28 Apr 2026 14:41:11 +0100 Subject: [PATCH 37/99] fix(honcho): pass user_message as search_query in get_prefetch_context The user_message parameter was accepted by get_prefetch_context but intentionally discarded, with the rationale that passing it would expose conversation content in server access logs. This rationale is inconsistent: Honcho already persists every message in full via saveMessages. The content is already in the database. A search query in an access log adds negligible additional exposure, and is moot for self-hosted Honcho deployments where the operator owns the logs. Without search_query, Honcho returns the full peer representation - all observations, deductive/inductive layers, and peer card - in insertion order. When contextTokens is set, the most useful parts (peer card, dialectic conclusions) are truncated because raw observations fill the budget first. Passing user_message as search_query enables Honcho's semantic retrieval to return only conclusions relevant to the current session topic, reducing injection noise and improving context quality on cold starts. The _fetch_peer_context method already accepts and passes search_query to the Honcho API. This change simply connects the two. --- plugins/memory/honcho/session.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index d76790a3e5bb..788be9c669b4 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -626,14 +626,15 @@ class HonchoSessionManager: Pre-fetch user and AI peer context from Honcho. Fetches peer_representation and peer_card for both peers, plus the - session summary when available. search_query is intentionally omitted - — it would only affect additional excerpts that this code does not - consume, and passing the raw message exposes conversation content in - server access logs. + session summary when available. When user_message is provided, it is + passed as search_query to the peer context call so Honcho returns + conclusions relevant to the session topic rather than the full + observation dump. Args: session_key: The session key to get context for. - user_message: Unused; kept for call-site compatibility. + user_message: Optional first user message used as search_query for + topic-relevant context retrieval. Returns: Dictionary with 'representation', 'card', 'ai_representation', @@ -659,7 +660,7 @@ class HonchoSessionManager: logger.debug("Failed to fetch session summary from Honcho: %s", e) try: - user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id) + user_ctx = self._fetch_peer_context(session.user_peer_id, search_query=user_message or None, target=session.user_peer_id) result["representation"] = user_ctx["representation"] result["card"] = "\n".join(user_ctx["card"]) except Exception as e: From 4f76166cf0189419bc0dc6b75054f16c496f066c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 04:24:10 -0700 Subject: [PATCH 38/99] chore: AUTHOR_MAP entry for qxxaa --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ea29bac0bbfa..d44ef0de7d7d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -61,6 +61,7 @@ AUTHOR_MAP = { "ashermorse@icloud.com": "ashermorse", "happy5318@users.noreply.github.com": "happy5318", "chengoak@users.noreply.github.com": "chengoak", + "mrhanoi@outlook.com": "qxxaa", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", From 436672de0efd8bcc50c6043a16223c102d30d71b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:15:54 -0700 Subject: [PATCH 39/99] feat(curator): add archive and prune subcommands (#20200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(curator): protect hub skills by frontmatter name * test(skill_usage): add mark_agent_created to regression test The cherry-picked test predates #19618/#19621 which rewrote list_agent_created_skill_names() to require an explicit created_by: 'agent' provenance marker. Without mark_agent_created(), my-skill is excluded from the list and the positive assertion fails. * feat(curator): add archive and prune subcommands Adds 'hermes curator archive ' and 'hermes curator prune [--days N] [--yes] [--dry-run]' alongside the existing status, run, pause, resume, pin, unpin, restore, backup, rollback verbs. These are the two genuinely new user-facing verbs requested in #19384. The other verbs proposed there ('stats' and 'restore') already exist as 'curator status' and 'curator restore', so no duplicate surface is added — all skill lifecycle commands live under the single 'hermes curator' namespace. - archive: manual archive of an agent-created skill. Refuses pinned skills with a hint pointing at 'hermes curator unpin'. - prune: bulk-archive unpinned skills idle for >= N days (default 90). Falls back to created_at when last_activity_at is null so never-used skills can still be pruned. --dry-run previews, --yes skips prompt. Adapted from @elmatadorgh's PR #19454 which placed the same verbs under 'hermes skills' with a separate hermes_cli/skills_config.py handler and rich table for stats. The 'stats' and 'restore' parts of that PR duplicated existing surface, so only archive and prune are kept, rewritten to match hermes_cli/curator.py's existing plain-text handler style. Tests rewritten from scratch against the new handlers. Closes #19384 Co-authored-by: elmatadorgh --------- Co-authored-by: LeonSGP43 Co-authored-by: elmatadorgh --- hermes_cli/curator.py | 130 +++++++++ scripts/release.py | 1 + .../hermes_cli/test_curator_archive_prune.py | 269 ++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 tests/hermes_cli/test_curator_archive_prune.py diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index df69aa7d5d90..50c297217c5a 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -245,6 +245,111 @@ def _cmd_restore(args) -> int: return 0 if ok else 1 +def _cmd_archive(args) -> int: + """Manually archive an agent-created skill. Refuses if pinned. + + The auto-curator archives stale skills on its own schedule; this verb is + for the user who wants to archive *now* without waiting for a run. + """ + from tools import skill_usage + if skill_usage.get_record(args.skill).get("pinned"): + print( + f"curator: '{args.skill}' is pinned — unpin first with " + f"`hermes curator unpin {args.skill}`" + ) + return 1 + ok, msg = skill_usage.archive_skill(args.skill) + print(f"curator: {msg}") + return 0 if ok else 1 + + +def _idle_days(record: dict) -> Optional[int]: + """Days since the skill's last activity (view / use / patch). + + Falls back to ``created_at`` so a skill that was authored but never used + can still be pruned — otherwise never-touched skills would be immortal. + Returns None only when both fields are missing or unparseable. + """ + ts = record.get("last_activity_at") or record.get("created_at") + if not ts: + return None + try: + dt = datetime.fromisoformat(str(ts)) + except (TypeError, ValueError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return max(0, (datetime.now(timezone.utc) - dt).days) + + +def _cmd_prune(args) -> int: + """Bulk-archive agent-created skills idle for >= N days. + + Pinned skills are exempt. Already-archived skills are skipped. Default + ``--days 90`` matches a conservative read of the curator's own archive + threshold; adjust with ``--days``. Use ``--dry-run`` to preview. + """ + from tools import skill_usage + days = getattr(args, "days", 90) + if days < 1: + print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr) + return 2 + + dry_run = bool(getattr(args, "dry_run", False)) + skip_confirm = bool(getattr(args, "yes", False)) + + candidates = [] + for r in skill_usage.agent_created_report(): + if r.get("pinned"): + continue + if r.get("state") == skill_usage.STATE_ARCHIVED: + continue + idle = _idle_days(r) + if idle is None or idle < days: + continue + candidates.append((r["name"], idle)) + + if not candidates: + print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)") + return 0 + + candidates.sort(key=lambda c: -c[1]) + print(f"curator: {len(candidates)} skill(s) idle >= {days}d:") + for name, idle in candidates: + print(f" {name:40s} idle {idle}d") + + if dry_run: + print("\n(dry run — no changes made)") + return 0 + + if not skip_confirm: + try: + reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\ncurator: aborted") + return 1 + if reply not in ("y", "yes"): + print("curator: aborted") + return 1 + + archived = 0 + failures = [] + for name, _ in candidates: + ok, msg = skill_usage.archive_skill(name) + if ok: + archived += 1 + else: + failures.append((name, msg)) + + print(f"\ncurator: archived {archived}/{len(candidates)}") + if failures: + print("failures:") + for name, msg in failures: + print(f" {name}: {msg}") + return 1 + return 0 + + def _cmd_backup(args) -> int: """Take a manual snapshot of the skills tree. Same mechanism as the automatic pre-run snapshot, just user-initiated.""" @@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_restore.add_argument("skill", help="Skill name") p_restore.set_defaults(func=_cmd_restore) + p_archive = subs.add_parser( + "archive", + help="Manually archive a skill (move to .archive/, excluded from prompt)", + ) + p_archive.add_argument("skill", help="Skill name") + p_archive.set_defaults(func=_cmd_archive) + + p_prune = subs.add_parser( + "prune", + help="Bulk-archive agent-created skills idle for >= N days (default 90)", + ) + p_prune.add_argument( + "--days", type=int, default=90, + help="Archive skills idle for at least N days (default: 90)", + ) + p_prune.add_argument( + "-y", "--yes", action="store_true", + help="Skip the confirmation prompt", + ) + p_prune.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="Show what would be archived without doing it", + ) + p_prune.set_defaults(func=_cmd_prune) + p_backup = subs.add_parser( "backup", help="Take a manual tar.gz snapshot of ~/.hermes/skills/ " diff --git a/scripts/release.py b/scripts/release.py index d44ef0de7d7d..b8f416928f68 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -268,6 +268,7 @@ AUTHOR_MAP = { "hakanerten02@hotmail.com": "teyrebaz33", "linux2010@users.noreply.github.com": "Linux2010", "elmatadorgh@users.noreply.github.com": "elmatadorgh", + "coktinbaran5@gmail.com": "elmatadorgh", "alexazzjjtt@163.com": "alexzhu0", "1180176+Swift42@users.noreply.github.com": "Swift42", "ruzzgarcn@gmail.com": "Ruzzgar", diff --git a/tests/hermes_cli/test_curator_archive_prune.py b/tests/hermes_cli/test_curator_archive_prune.py new file mode 100644 index 000000000000..1ab28fb1778d --- /dev/null +++ b/tests/hermes_cli/test_curator_archive_prune.py @@ -0,0 +1,269 @@ +"""Tests for `hermes curator archive` and `hermes curator prune`. + +Covers: +- archive refuses pinned skills with an `unpin` hint +- archive returns 0/1 based on archive_skill() success +- prune filters pinned and already-archived, applies --days threshold +- prune falls back to created_at when last_activity_at is null +- prune --dry-run makes no state changes +- prune --yes skips confirmation +- prune --days validation +""" + +from __future__ import annotations + +import io +from contextlib import redirect_stdout, redirect_stderr +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + + +def _ns(**kwargs): + return SimpleNamespace(**kwargs) + + +# ─── archive ──────────────────────────────────────────────────────────────── + + +def test_archive_refuses_pinned(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True}) + called = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: called.append(name) or (True, "should not get here"), + ) + + rc = curator_cli._cmd_archive(_ns(skill="pinned-skill")) + assert rc == 1 + assert called == [] + out = capsys.readouterr().out + assert "pinned" in out.lower() + assert "hermes curator unpin" in out + + +def test_archive_calls_archive_skill(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False}) + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: (True, f"archived to .archive/{name}"), + ) + rc = curator_cli._cmd_archive(_ns(skill="my-skill")) + assert rc == 0 + assert "archived to .archive/my-skill" in capsys.readouterr().out + + +def test_archive_reports_failure(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False}) + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"), + ) + rc = curator_cli._cmd_archive(_ns(skill="hub-slug")) + assert rc == 1 + assert "bundled or hub-installed" in capsys.readouterr().out + + +# ─── prune ────────────────────────────────────────────────────────────────── + + +def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None): + import datetime as _dt + now = _dt.datetime.now(_dt.timezone.utc) + last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None + created_delta = created_idle_days if created_idle_days is not None else idle_days + created = (now - _dt.timedelta(days=created_delta)).isoformat() + return { + "name": name, + "state": state, + "pinned": pinned, + "last_activity_at": last_activity, + "created_at": created, + "activity_count": 0 if idle_days == 0 and last_activity is None else 1, + } + + +def test_prune_days_validation(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False)) + assert rc == 2 + err = capsys.readouterr().err + assert "--days must be >= 1" in err + + +def test_prune_nothing_to_do(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: []) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 0 + assert "nothing to prune" in capsys.readouterr().out + + +def test_prune_filters_pinned_and_archived(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [ + _mk_record("old-pinned", idle_days=200, pinned=True), + _mk_record("old-archived", idle_days=200, state="archived"), + _mk_record("recent", idle_days=10), + _mk_record("old-active", idle_days=200), + ] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, f"archived {name}"), + ) + + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 0 + assert archived == ["old-active"] + out = capsys.readouterr().out + assert "old-active" in out + assert "old-pinned" not in out + assert "old-archived" not in out + assert "recent" not in out + assert "archived 1/1" in out + + +def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys): + """Never-used skills must be prunable via created_at — otherwise immortal.""" + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)] + # Force last_activity_at to None explicitly + rows[0]["last_activity_at"] = None + + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False)) + assert rc == 0 + assert archived == ["never-used"] + + +def test_prune_dry_run_makes_no_changes(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True)) + assert rc == 0 + assert archived == [] + out = capsys.readouterr().out + assert "old-skill" in out + assert "dry run" in out + + +def test_prune_prompts_without_yes(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False)) + assert rc == 1 + assert archived == [] + assert "aborted" in capsys.readouterr().out + + +def test_prune_confirms_with_y(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + monkeypatch.setattr("builtins.input", lambda _prompt: "y") + rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False)) + assert rc == 0 + assert archived == ["old-skill"] + + +def test_prune_reports_partial_failure(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [ + _mk_record("ok-skill", idle_days=200), + _mk_record("bad-skill", idle_days=200), + ] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + + def fake_archive(name): + if name == "bad-skill": + return False, "disk full" + return True, "ok" + + monkeypatch.setattr(skill_usage, "archive_skill", fake_archive) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 1 + out = capsys.readouterr().out + assert "archived 1/2" in out + assert "bad-skill: disk full" in out + + +# ─── argparse wiring ──────────────────────────────────────────────────────── + + +def test_archive_and_prune_registered(): + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + + args = parser.parse_args(["archive", "my-skill"]) + assert args.skill == "my-skill" + assert args.func.__name__ == "_cmd_archive" + + args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"]) + assert args.days == 45 + assert args.yes is True + assert args.dry_run is True + assert args.func.__name__ == "_cmd_prune" + + +def test_prune_defaults(): + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + args = parser.parse_args(["prune"]) + assert args.days == 90 + assert args.yes is False + assert args.dry_run is False From fe8560fc1249b4a7e448b5c3b80a7d213df9d78f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:34:47 -0700 Subject: [PATCH 40/99] feat(api-server): X-Hermes-Session-Key header for long-term memory scoping (#20199) * feat(api-server): X-Hermes-Session-Key header for long-term memory scoping API Server integrations (Open WebUI, custom web UIs) can now pass a stable per-channel identifier via X-Hermes-Session-Key that scopes long-term memory (Honcho, etc.) independently of the transcript-scoped X-Hermes-Session-Id. This matches the native gateway's session_key / session_id split: one stable key per assistant channel, many independent transcripts that rotate on /new. - _create_agent and _run_agent accept gateway_session_key and pass it to AIAgent(gateway_session_key=...), which is already honored by the Honcho memory provider (plugins/memory/honcho/client.py resolve_session_name). - New shared helper _parse_session_key_header applies the same API-key gate, control-character sanitization, and a 256-char length cap as the existing session-id header. - All three agent endpoints honor the header: /v1/chat/completions, /v1/responses, /v1/runs. JSON and SSE responses echo it back. - /v1/capabilities advertises session_key_header so clients can feature-detect. Closes #20060. Co-authored-by: Andy Stewart * chore: AUTHOR_MAP entry for manateelazycat --------- Co-authored-by: Andy Stewart --- gateway/platforms/api_server.py | 132 +++++++++++++++++++++- scripts/release.py | 1 + tests/gateway/test_api_server.py | 182 +++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 230859023b7d..d3beb1d1d2f8 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2,8 +2,8 @@ OpenAI-compatible API server platform adapter. Exposes an HTTP server with endpoints: -- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header) -- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id) +- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header; opt-in long-term memory scoping via X-Hermes-Session-Key header) +- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported) - GET /v1/responses/{response_id} — Retrieve a stored response - DELETE /v1/responses/{response_id} — Delete a stored response - GET /v1/models — lists hermes-agent as an available model @@ -698,6 +698,71 @@ class APIServerAdapter(BasePlatformAdapter): status=401, ) + # ------------------------------------------------------------------ + # Session header helpers + # ------------------------------------------------------------------ + + # Soft length cap for session identifiers. Headers are bounded in + # aggregate by aiohttp (``client_max_size`` / default 8 KiB per + # header), but we impose a tighter limit on the session headers so a + # caller can't burn memory by passing a multi-kilobyte "session key". + # 256 chars is well above any realistic stable channel identifier + # (e.g. ``agent:main:webui:dm:user-42``) while staying small enough + # that the sanitized form is safe to pass into Honcho / state.db. + _MAX_SESSION_HEADER_LEN = 256 + + def _parse_session_key_header( + self, request: "web.Request" + ) -> tuple[Optional[str], Optional["web.Response"]]: + """Extract and validate the ``X-Hermes-Session-Key`` header. + + The session key is a stable per-channel identifier that scopes + long-term memory (e.g. Honcho sessions) across transcripts. It + is independent of ``X-Hermes-Session-Id``: callers may send + either, both, or neither. + + Returns ``(session_key, None)`` on success (with an empty/absent + header yielding ``None`` for the key), or ``(None, error_response)`` + on validation failure. + + Security: like session continuation, accepting a caller-supplied + memory scope requires API-key authentication so that an + unauthenticated client on a local-only server can't inject itself + into another user's long-term memory scope by guessing a key. + """ + raw = request.headers.get("X-Hermes-Session-Key", "").strip() + if not raw: + return None, None + + if not self._api_key: + logger.warning( + "X-Hermes-Session-Key rejected: no API key configured. " + "Set API_SERVER_KEY to enable long-term memory scoping." + ) + return None, web.json_response( + _openai_error( + "X-Hermes-Session-Key requires API key authentication. " + "Configure API_SERVER_KEY to enable this feature." + ), + status=403, + ) + + # Reject control characters that could enable header injection on + # the echo path. + if re.search(r'[\r\n\x00]', raw): + return None, web.json_response( + {"error": {"message": "Invalid session key", "type": "invalid_request_error"}}, + status=400, + ) + + if len(raw) > self._MAX_SESSION_HEADER_LEN: + return None, web.json_response( + {"error": {"message": "Session key too long", "type": "invalid_request_error"}}, + status=400, + ) + + return raw, None + # ------------------------------------------------------------------ # Session DB helper # ------------------------------------------------------------------ @@ -728,6 +793,7 @@ class APIServerAdapter(BasePlatformAdapter): tool_progress_callback=None, tool_start_callback=None, tool_complete_callback=None, + gateway_session_key: Optional[str] = None, ) -> Any: """ Create an AIAgent instance using the gateway's runtime config. @@ -736,6 +802,13 @@ class APIServerAdapter(BasePlatformAdapter): base_url, etc. from config.yaml / env vars. Toolsets are resolved from config.yaml platform_toolsets.api_server (same as all other gateway platforms), falling back to the hermes-api-server default. + + ``gateway_session_key`` is a stable per-channel identifier supplied + by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id`` + which scopes the short-term transcript and rotates on /new, this + key is meant to persist across transcripts so long-term memory + providers (e.g. Honcho) can scope their per-chat state correctly + — matching the semantics of the native gateway's ``session_key``. """ from run_agent import AIAgent from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner @@ -771,6 +844,7 @@ class APIServerAdapter(BasePlatformAdapter): session_db=self._ensure_session_db(), fallback_model=fallback_model, reasoning_config=reasoning_config, + gateway_session_key=gateway_session_key, ) return agent @@ -854,6 +928,7 @@ class APIServerAdapter(BasePlatformAdapter): "run_stop": True, "tool_progress_events": True, "session_continuity_header": "X-Hermes-Session-Id", + "session_key_header": "X-Hermes-Session-Key", "cors": bool(self._cors_origins), }, "endpoints": { @@ -925,6 +1000,15 @@ class APIServerAdapter(BasePlatformAdapter): status=400, ) + # Allow caller to scope long-term memory (e.g. Honcho) with a + # stable per-channel identifier via X-Hermes-Session-Key. This + # is independent of X-Hermes-Session-Id: the key persists across + # transcripts while the id rotates when the caller starts a new + # transcript (i.e. /new semantics). See _parse_session_key_header. + gateway_session_key, key_err = self._parse_session_key_header(request) + if key_err is not None: + return key_err + # Allow caller to continue an existing session by passing X-Hermes-Session-Id. # When provided, history is loaded from state.db instead of from the request body. # @@ -1059,11 +1143,13 @@ class APIServerAdapter(BasePlatformAdapter): tool_start_callback=_on_tool_start, tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, + gateway_session_key=gateway_session_key, )) return await self._write_sse_chat_completion( request, completion_id, model_name, created, _stream_q, agent_task, agent_ref, session_id=session_id, + gateway_session_key=gateway_session_key, ) # Non-streaming: run the agent (with optional Idempotency-Key) @@ -1073,6 +1159,7 @@ class APIServerAdapter(BasePlatformAdapter): conversation_history=history, ephemeral_system_prompt=system_prompt, session_id=session_id, + gateway_session_key=gateway_session_key, ) idempotency_key = request.headers.get("Idempotency-Key") @@ -1122,11 +1209,15 @@ class APIServerAdapter(BasePlatformAdapter): }, } - return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id}) + response_headers = {"X-Hermes-Session-Id": session_id} + if gateway_session_key: + response_headers["X-Hermes-Session-Key"] = gateway_session_key + return web.json_response(response_data, headers=response_headers) async def _write_sse_chat_completion( self, request: "web.Request", completion_id: str, model: str, created: int, stream_q, agent_task, agent_ref=None, session_id: str = None, + gateway_session_key: str = None, ) -> "web.StreamResponse": """Write real streaming SSE from agent's stream_delta_callback queue. @@ -1149,6 +1240,8 @@ class APIServerAdapter(BasePlatformAdapter): sse_headers.update(cors) if session_id: sse_headers["X-Hermes-Session-Id"] = session_id + if gateway_session_key: + sse_headers["X-Hermes-Session-Key"] = gateway_session_key response = web.StreamResponse(status=200, headers=sse_headers) await response.prepare(request) @@ -1272,6 +1365,7 @@ class APIServerAdapter(BasePlatformAdapter): conversation: Optional[str], store: bool, session_id: str, + gateway_session_key: Optional[str] = None, ) -> "web.StreamResponse": """Write an SSE stream for POST /v1/responses (OpenAI Responses API). @@ -1314,6 +1408,8 @@ class APIServerAdapter(BasePlatformAdapter): sse_headers.update(cors) if session_id: sse_headers["X-Hermes-Session-Id"] = session_id + if gateway_session_key: + sse_headers["X-Hermes-Session-Key"] = gateway_session_key response = web.StreamResponse(status=200, headers=sse_headers) await response.prepare(request) @@ -1763,6 +1859,11 @@ class APIServerAdapter(BasePlatformAdapter): if auth_err: return auth_err + # Long-term memory scope header (see chat_completions for details). + gateway_session_key, key_err = self._parse_session_key_header(request) + if key_err is not None: + return key_err + # Parse request body try: body = await request.json() @@ -1914,6 +2015,7 @@ class APIServerAdapter(BasePlatformAdapter): tool_start_callback=_on_tool_start, tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, + gateway_session_key=gateway_session_key, )) response_id = f"resp_{uuid.uuid4().hex[:28]}" @@ -1934,6 +2036,7 @@ class APIServerAdapter(BasePlatformAdapter): conversation=conversation, store=store, session_id=session_id, + gateway_session_key=gateway_session_key, ) async def _compute_response(): @@ -1942,6 +2045,7 @@ class APIServerAdapter(BasePlatformAdapter): conversation_history=conversation_history, ephemeral_system_prompt=instructions, session_id=session_id, + gateway_session_key=gateway_session_key, ) idempotency_key = request.headers.get("Idempotency-Key") @@ -2016,7 +2120,10 @@ class APIServerAdapter(BasePlatformAdapter): if conversation: self._response_store.set_conversation(conversation, response_id) - return web.json_response(response_data) + response_headers = {"X-Hermes-Session-Id": session_id} + if gateway_session_key: + response_headers["X-Hermes-Session-Key"] = gateway_session_key + return web.json_response(response_data, headers=response_headers) # ------------------------------------------------------------------ # GET / DELETE response endpoints @@ -2338,6 +2445,7 @@ class APIServerAdapter(BasePlatformAdapter): tool_start_callback=None, tool_complete_callback=None, agent_ref: Optional[list] = None, + gateway_session_key: Optional[str] = None, ) -> tuple: """ Create an agent and run a conversation in a thread executor. @@ -2360,6 +2468,7 @@ class APIServerAdapter(BasePlatformAdapter): tool_progress_callback=tool_progress_callback, tool_start_callback=tool_start_callback, tool_complete_callback=tool_complete_callback, + gateway_session_key=gateway_session_key, ) if agent_ref is not None: agent_ref[0] = agent @@ -2453,6 +2562,11 @@ class APIServerAdapter(BasePlatformAdapter): if auth_err: return auth_err + # Long-term memory scope header (see chat_completions for details). + gateway_session_key, key_err = self._parse_session_key_header(request) + if key_err is not None: + return key_err + # Enforce concurrency limit if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS: return web.json_response( @@ -2561,6 +2675,7 @@ class APIServerAdapter(BasePlatformAdapter): session_id=session_id, stream_delta_callback=_text_cb, tool_progress_callback=event_cb, + gateway_session_key=gateway_session_key, ) self._active_run_agents[run_id] = agent def _run_sync(): @@ -2661,7 +2776,14 @@ class APIServerAdapter(BasePlatformAdapter): if hasattr(task, "add_done_callback"): task.add_done_callback(self._background_tasks.discard) - return web.json_response({"run_id": run_id, "status": "started"}, status=202) + response_headers = ( + {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} + ) + return web.json_response( + {"run_id": run_id, "status": "started"}, + status=202, + headers=response_headers, + ) async def _handle_get_run(self, request: "web.Request") -> "web.Response": """GET /v1/runs/{run_id} — return pollable run status for external UIs.""" diff --git a/scripts/release.py b/scripts/release.py index b8f416928f68..e2c9ef968df4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -63,6 +63,7 @@ AUTHOR_MAP = { "chengoak@users.noreply.github.com": "chengoak", "mrhanoi@outlook.com": "qxxaa", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", + "lazycat.manatee@gmail.com": "manateelazycat", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", "angelclaw@AngelMacBook.local": "angel12", diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index d519eee278c5..d97ef646b365 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -2563,3 +2563,185 @@ class TestSessionIdHeader: call_kwargs = mock_run.call_args.kwargs assert call_kwargs["conversation_history"] == [] assert call_kwargs["session_id"] == "some-session" + + +# --------------------------------------------------------------------------- +# X-Hermes-Session-Key header (long-term memory scoping) +# --------------------------------------------------------------------------- + + +class TestSessionKeyHeader: + """The session key is a stable per-channel identifier that scopes + long-term memory (e.g. Honcho) independently of the transcript-scoped + session_id. A third-party Web UI passes one stable key per assistant + channel and rotates session_id on /new, matching the native + gateway's session_key / session_id split. + """ + + @pytest.mark.asyncio + async def test_session_key_passed_to_agent_and_echoed(self, auth_adapter): + """X-Hermes-Session-Key reaches _run_agent as gateway_session_key and is echoed back.""" + mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/chat/completions", + headers={ + "X-Hermes-Session-Key": "webui:user-42", + "Authorization": "Bearer sk-secret", + }, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Session-Key") == "webui:user-42" + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["gateway_session_key"] == "webui:user-42" + + @pytest.mark.asyncio + async def test_session_key_independent_of_session_id(self, auth_adapter): + """Both headers coexist: key scopes memory, id scopes transcript.""" + mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} + mock_db = MagicMock() + mock_db.get_messages_as_conversation.return_value = [] + auth_adapter._session_db = mock_db + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/chat/completions", + headers={ + "X-Hermes-Session-Key": "channel-abc", + "X-Hermes-Session-Id": "transcript-xyz", + "Authorization": "Bearer sk-secret", + }, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Session-Key") == "channel-abc" + assert resp.headers.get("X-Hermes-Session-Id") == "transcript-xyz" + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["gateway_session_key"] == "channel-abc" + assert call_kwargs["session_id"] == "transcript-xyz" + + @pytest.mark.asyncio + async def test_session_key_absent_yields_none(self, auth_adapter): + """Omitting the header passes gateway_session_key=None and doesn't echo.""" + mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/chat/completions", + headers={"Authorization": "Bearer sk-secret"}, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + assert "X-Hermes-Session-Key" not in resp.headers + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["gateway_session_key"] is None + + @pytest.mark.asyncio + async def test_session_key_rejected_without_api_key(self, adapter): + """Without API_SERVER_KEY, accepting a caller-supplied memory scope is unsafe — reject with 403.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Hermes-Session-Key": "whatever"}, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 403 + + @pytest.mark.asyncio + async def test_session_key_rejects_control_chars(self, auth_adapter): + """Header injection via \\r\\n must be rejected by the server-side validator. + + Note: aiohttp client refuses to SEND a header containing CR/LF + (that check fires before the request leaves the client), so we + can't reach this code path through TestClient. Test the helper + directly instead with a raw request that bypasses client-side + validation. + """ + mock_request = MagicMock() + mock_request.headers = {"X-Hermes-Session-Key": "bad\rvalue"} + key, err = auth_adapter._parse_session_key_header(mock_request) + assert key is None + assert err is not None + assert err.status == 400 + + @pytest.mark.asyncio + async def test_session_key_rejects_oversized(self, auth_adapter): + """Session keys longer than the cap are rejected.""" + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Hermes-Session-Key": "x" * 1000, "Authorization": "Bearer sk-secret"}, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_session_key_threads_into_create_agent(self, auth_adapter): + """End-to-end: verify AIAgent(gateway_session_key=...) receives the key via _create_agent.""" + captured_kwargs = {} + + def _fake_create_agent(**kwargs): + captured_kwargs.update(kwargs) + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok", "messages": []} + mock_agent.session_prompt_tokens = 0 + mock_agent.session_completion_tokens = 0 + mock_agent.session_total_tokens = 0 + return mock_agent + + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(auth_adapter, "_create_agent", side_effect=_fake_create_agent): + resp = await cli.post( + "/v1/chat/completions", + headers={ + "X-Hermes-Session-Key": "agent:main:webui:dm:user-7", + "Authorization": "Bearer sk-secret", + }, + json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + # _create_agent must be called with gateway_session_key threaded through + assert captured_kwargs.get("gateway_session_key") == "agent:main:webui:dm:user-7" + + @pytest.mark.asyncio + async def test_responses_endpoint_accepts_session_key(self, auth_adapter): + """Responses API honors the same X-Hermes-Session-Key contract.""" + mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} + app = _create_app(auth_adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/responses", + headers={ + "X-Hermes-Session-Key": "webui:chan-1", + "Authorization": "Bearer sk-secret", + }, + json={"model": "hermes-agent", "input": "hello", "store": False}, + ) + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Session-Key") == "webui:chan-1" + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["gateway_session_key"] == "webui:chan-1" + + @pytest.mark.asyncio + async def test_capabilities_advertises_session_key_header(self, adapter): + """GET /v1/capabilities should advertise the new header so clients can feature-detect.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/v1/capabilities") + assert resp.status == 200 + data = await resp.json() + assert data["features"]["session_key_header"] == "X-Hermes-Session-Key" + From b10e38e392ba5b1ee0f98fd513d3f120f7277565 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:43:10 -0700 Subject: [PATCH 41/99] fix(skills): pin protects against deletion only, not edits (#20220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, pinning a skill blocked every skill_manage write action (edit, patch, delete, write_file, remove_file). The 'hard fence' design conflated two concerns: 1. Pin as deletion protection — don't let the curator archive or the agent delete a stable skill. 2. Pin as content freeze — don't let the agent rewrite it mid-conversation. In practice (1) is what users pin for: they want a skill to survive curator passes. (2) created friction — agents finding a new pitfall in a pinned skill had to ask the user to unpin, then the agent patches, then the user re-pins. The dance discouraged skill maintenance and pinned skills went stale. This narrows the _pinned_guard to skill_manage(action='delete') only. Patches, edits, and supporting-file writes go through on pinned skills so the agent can keep improving them. The curator's own pinned-skip behavior (agent/curator.py:271 for auto-archive, line 349 for the LLM review prompt) is unchanged — curator still never touches pinned skills. Changes: - tools/skill_manager_tool.py: remove _pinned_guard calls from _edit_skill, _patch_skill, _write_file, _remove_file; keep on _delete_skill. Updated _pinned_guard docstring and error message. - tools/skill_manager_tool.py: updated skill_manage model-facing tool description to reflect the new semantic. - website/docs/user-guide/features/curator.md: updated pinning section. - tests/tools/test_skill_manager_tool.py: flipped refuses-pinned tests for edit/patch/write_file/remove_file into allowed-when-pinned; kept test_delete_refuses_pinned (strengthened assertion to check the 'cannot be deleted' wording). Closes #18354 --- tests/tools/test_skill_manager_tool.py | 67 ++++++++++----------- tools/skill_manager_tool.py | 41 ++++--------- website/docs/user-guide/features/curator.md | 6 +- 3 files changed, 48 insertions(+), 66 deletions(-) diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index e24e19dea1ed..96c3a361f0c2 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -838,12 +838,13 @@ class TestExternalSkillMutations: # --------------------------------------------------------------------------- -# Pinned-skill guard — skill_manage refuses all writes to pinned skills. -# The user unpins via `hermes curator unpin `. +# Pinned-skill guard — skill_manage refuses only `delete` on pinned skills. +# Patches and edits go through so pinned skills can still evolve as pitfalls +# come up. The user unpins via `hermes curator unpin ` to delete. # --------------------------------------------------------------------------- class TestPinnedGuard: - """Every mutation action must refuse when the skill is pinned.""" + """Delete is refused on pinned skills; patch/edit/write_file/remove_file are allowed.""" @staticmethod def _pin(name: str): @@ -852,31 +853,28 @@ class TestPinnedGuard: return {"pinned": True} if skill_name == _name else {"pinned": False} return patch("tools.skill_usage.get_record", side_effect=_fake_get_record) - def test_edit_refuses_pinned(self, tmp_path): + def test_edit_allowed_when_pinned(self, tmp_path): + """Pin does NOT block edit — agent can still improve pinned skills.""" with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) with self._pin("my-skill"): result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2) - assert result["success"] is False - assert "pinned" in result["error"].lower() - assert "hermes curator unpin my-skill" in result["error"] - # Original content preserved + assert result["success"] is True, result + # Content updated content = (tmp_path / "my-skill" / "SKILL.md").read_text() - assert "A test skill" in content + assert "A test skill" not in content - def test_patch_refuses_pinned(self, tmp_path): + def test_patch_allowed_when_pinned(self, tmp_path): with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) with self._pin("my-skill"): result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.") - assert result["success"] is False - assert "pinned" in result["error"].lower() - assert "hermes curator unpin my-skill" in result["error"] + assert result["success"] is True, result content = (tmp_path / "my-skill" / "SKILL.md").read_text() - assert "Do the thing." in content # unchanged + assert "Do the new thing." in content - def test_patch_supporting_file_refuses_pinned(self, tmp_path): - """Pin covers supporting files too, not just SKILL.md.""" + def test_patch_supporting_file_allowed_when_pinned(self, tmp_path): + """Supporting-file patches also go through on pinned skills.""" with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) _write_file("my-skill", "references/api.md", "original") @@ -885,57 +883,56 @@ class TestPinnedGuard: "my-skill", "original", "modified", file_path="references/api.md", ) - assert result["success"] is False - assert "pinned" in result["error"].lower() - assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "original" + assert result["success"] is True, result + assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "modified" def test_delete_refuses_pinned(self, tmp_path): + """Delete is the one action pin still blocks — it's the irrecoverable one.""" with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) with self._pin("my-skill"): result = _delete_skill("my-skill") assert result["success"] is False assert "pinned" in result["error"].lower() + assert "cannot be deleted" in result["error"] + assert "hermes curator unpin my-skill" in result["error"] # Skill still exists assert (tmp_path / "my-skill" / "SKILL.md").exists() - def test_write_file_refuses_pinned(self, tmp_path): + def test_write_file_allowed_when_pinned(self, tmp_path): with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) with self._pin("my-skill"): result = _write_file("my-skill", "references/api.md", "content") - assert result["success"] is False - assert "pinned" in result["error"].lower() - assert not (tmp_path / "my-skill" / "references" / "api.md").exists() + assert result["success"] is True, result + assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "content" - def test_remove_file_refuses_pinned(self, tmp_path): + def test_remove_file_allowed_when_pinned(self, tmp_path): with _skill_dir(tmp_path): _create_skill("my-skill", VALID_SKILL_CONTENT) _write_file("my-skill", "references/api.md", "content") with self._pin("my-skill"): result = _remove_file("my-skill", "references/api.md") - assert result["success"] is False - assert "pinned" in result["error"].lower() - # File still there - assert (tmp_path / "my-skill" / "references" / "api.md").exists() + assert result["success"] is True, result + assert not (tmp_path / "my-skill" / "references" / "api.md").exists() def test_unpinned_skills_still_editable(self, tmp_path): - """Sanity check: the guard doesn't fire for unpinned skills. + """Sanity check: the guard doesn't fire for unpinned skills on delete. - Only the specifically-pinned skill is refused; a sibling skill must - still be freely editable. + Only the specifically-pinned skill is refused from delete; a sibling + skill must still be freely deletable. """ with _skill_dir(tmp_path): _create_skill("pinned-one", VALID_SKILL_CONTENT) _create_skill("free-one", VALID_SKILL_CONTENT) with self._pin("pinned-one"): - blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2) - allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2) + blocked = _delete_skill("pinned-one") + allowed = _delete_skill("free-one") assert blocked["success"] is False assert allowed["success"] is True def test_broken_sidecar_fails_open(self, tmp_path): - """If skill_usage.get_record raises, we allow the write through. + """If skill_usage.get_record raises, we allow delete through. Rationale: a corrupted telemetry file shouldn't lock the agent out of skills it would otherwise be allowed to touch. @@ -944,5 +941,5 @@ class TestPinnedGuard: _create_skill("my-skill", VALID_SKILL_CONTENT) with patch("tools.skill_usage.get_record", side_effect=RuntimeError("sidecar broken")): - result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2) + result = _delete_skill("my-skill") assert result["success"] is True diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 58c3fe3d2dcc..ed4cb3f1038f 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -137,14 +137,12 @@ def _containing_skills_root(skill_path: Path) -> Path: def _pinned_guard(name: str) -> Optional[str]: """Return a refusal message if *name* is pinned, else None. - Pinned skills are off-limits to the agent's skill_manage tool. The only - way to modify one is for the user to unpin it via - ``hermes curator unpin `` (or edit it directly by hand). This - mirrors the curator's own pinned-skip behavior but extends the guard - to tool-driven writes as well, giving users a hard fence against - accidental agent edits. + Pin protects a skill from **deletion** — both the curator's auto-archive + passes and the agent's ``skill_manage(action="delete")`` tool call. The + agent can still patch/edit pinned skills; pin only guards against + irrecoverable loss, not against content evolution. - Best-effort: if the sidecar is unreadable we let the write through + Best-effort: if the sidecar is unreadable we let the delete through rather than block on a broken telemetry file. """ try: @@ -152,9 +150,11 @@ def _pinned_guard(name: str) -> Optional[str]: rec = skill_usage.get_record(name) if rec.get("pinned"): return ( - f"Skill '{name}' is pinned and cannot be modified by " + f"Skill '{name}' is pinned and cannot be deleted by " f"skill_manage. Ask the user to run " - f"`hermes curator unpin {name}` if they want the change." + f"`hermes curator unpin {name}` if they want to delete it. " + f"Patches and edits are allowed on pinned skills; only " + f"deletion is blocked." ) except Exception: logger.debug("pinned-guard lookup failed for %s", name, exc_info=True) @@ -439,10 +439,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} - pinned_err = _pinned_guard(name) - if pinned_err: - return {"success": False, "error": pinned_err} - skill_md = existing["path"] / "SKILL.md" # Back up original content for rollback original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None @@ -483,10 +479,6 @@ def _patch_skill( if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - pinned_err = _pinned_guard(name) - if pinned_err: - return {"success": False, "error": pinned_err} - skill_dir = existing["path"] if file_path: @@ -645,10 +637,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} - pinned_err = _pinned_guard(name) - if pinned_err: - return {"success": False, "error": pinned_err} - target, err = _resolve_skill_target(existing["path"], file_path) if err: return {"success": False, "error": err} @@ -683,10 +671,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - pinned_err = _pinned_guard(name) - if pinned_err: - return {"success": False, "error": pinned_err} - skill_dir = existing["path"] target, err = _resolve_skill_target(skill_dir, file_path) @@ -835,9 +819,10 @@ SKILL_MANAGE_SCHEMA = { "Skip for simple one-offs. Confirm with user before creating/deleting.\n\n" "Good skills: trigger conditions, numbered steps with exact commands, " "pitfalls section, verification steps. Use skill_view() to see format examples.\n\n" - "Pinned skills are off-limits — all write actions refuse with a message " - "pointing the user to `hermes curator unpin `. Don't try to route " - "around this by renaming or recreating." + "Pinned skills are protected from deletion only — skill_manage(action='delete') " + "will refuse with a message pointing the user to `hermes curator unpin `. " + "Patches and edits go through on pinned skills so you can still improve them as " + "pitfalls come up; pin only guards against irrecoverable loss." ), "parameters": { "type": "object", diff --git a/website/docs/user-guide/features/curator.md b/website/docs/user-guide/features/curator.md index fccef941dc6a..e53076b45e70 100644 --- a/website/docs/user-guide/features/curator.md +++ b/website/docs/user-guide/features/curator.md @@ -157,10 +157,10 @@ If you want to protect a specific skill from ever being touched — for example ## Pinning a skill -Pinning is a hard fence against both automated and agent-driven changes. Once a skill is pinned: +Pinning protects a skill from deletion — both the curator's automated archive passes and the agent's `skill_manage(action="delete")` tool call. Once a skill is pinned: - The **curator** skips it during auto-transitions (`active → stale → archived`), and its LLM review pass is instructed to leave it alone. -- The **agent's `skill_manage` tool** refuses every write action on it. Calls to `edit`, `patch`, `delete`, `write_file`, and `remove_file` return a refusal that tells the model to ask the user to run `hermes curator unpin `. This prevents the agent from silently rewriting a skill mid-conversation. +- The **agent's `skill_manage` tool** refuses `delete` on it, pointing the user at `hermes curator unpin `. Patches and edits still go through, so the agent can improve a pinned skill's content as pitfalls come up without a pin/unpin/re-pin dance. Pin and unpin with: @@ -173,7 +173,7 @@ The flag is stored as `"pinned": true` on the skill's entry in `~/.hermes/skills Only **agent-created** skills can be pinned — bundled and hub-installed skills are never subject to curator mutation in the first place, and `hermes curator pin` will refuse with an explanatory message if you try. -If you need to update a pinned skill yourself, edit `~/.hermes/skills//SKILL.md` directly with your editor. The pin only guards the agent's tool path, not your own filesystem access. +If you want a stronger guarantee than "no deletion" — for instance, freezing a skill's content entirely while the agent still reads it — edit `~/.hermes/skills//SKILL.md` directly with your editor. The pin guards tool-driven deletion, not your own filesystem access. ## Usage telemetry From f15b0fbb4f1738055cf6f16f9b349dbca69b879a Mon Sep 17 00:00:00 2001 From: beardthelion Date: Tue, 28 Apr 2026 14:53:56 -0500 Subject: [PATCH 42/99] fix: add PLATFORM_HINTS entry for api_server platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API server is a documented, first-class messaging platform with its own gateway adapter, docs pages, and toolset. But it's the only messaging platform missing from PLATFORM_HINTS in agent/prompt_builder.py. Without a platform hint, the agent has no context about the API server's rendering environment and defaults to markdown-heavy document-style outputs (code fences, bold, bullet points) — which break on the plain-text frontends most API server consumers wrap (Open WebUI, custom agents, third-party bridges). Adds a generic api_server entry that describes the medium (unknown rendering, assume plain text) without encoding any specific use case. Individual consumers can layer additional style guidance via ephemeral system prompts. Before (DeepSeek V4 Pro via API server, no hint): **Sendblue bridge** at /opt/sendblue-bridge - **68MB** on disk After (same prompt, with hint): Sendblue bridge at /opt/sendblue-bridge, 68MB on disk No breaking changes — new dict entry only. Existing API server consumers see no behavioral change except for models that previously defaulted to markdown formatting, which now produce cleaner plain-text output. --- agent/prompt_builder.py | 6 ++++++ tests/agent/test_prompt_builder.py | 1 + 2 files changed, 7 insertions(+) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 8494a70eef2a..2f00020cc1ce 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -513,6 +513,12 @@ PLATFORM_HINTS = { "image and is the WRONG path. Bare Unicode emoji in text is also not a substitute " "— when a sticker is the right response, use yb_send_sticker." ), + "api_server": ( + "You're responding through an API server. The rendering layer is unknown — " + "assume plain text. No markdown formatting (no asterisks, bullets, headers, " + "code fences). Treat this like a conversation, not a document. Keep responses " + "brief and natural." + ), } # --------------------------------------------------------------------------- diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 88de5186b838..d99e6944ff50 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -788,6 +788,7 @@ class TestPromptBuilderConstants: assert "discord" in PLATFORM_HINTS assert "cron" in PLATFORM_HINTS assert "cli" in PLATFORM_HINTS + assert "api_server" in PLATFORM_HINTS def test_cli_hint_does_not_suggest_media_tags(self): # Regression: MEDIA:/path tags are intercepted only by messaging From 005b2f4c5dfdced4acb4c80ff0b3520f9c2da1c7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:21:04 -0700 Subject: [PATCH 43/99] chore: AUTHOR_MAP entry for beardthelion --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index e2c9ef968df4..7cea454075c4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,6 +55,7 @@ AUTHOR_MAP = { "14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel", "657290301@qq.com": "IMHaoyan", "revar@users.noreply.github.com": "revaraver", + "beardthelion@users.noreply.github.com": "beardthelion", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From 44cf33449d4f78f9a13eb37c7d99d2c4b021f696 Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Tue, 5 May 2026 05:26:49 -0700 Subject: [PATCH 44/99] fix(mcp): add periodic keepalive to _wait_for_lifecycle_event Sends a lightweight list_tools() probe every 3 minutes during idle periods to prevent TCP connections from going stale behind LB / NAT idle timeouts (commonly 300-600s). When the keepalive fails, the reconnect event fires so the transport rebuilds the session cleanly. Salvages the keepalive portion of @vominh1919's PR #17016. The circuit-breaker half-open recovery from the same PR was independently landed on main via #benbarclay's commit 8cc3cebca ("fix(mcp): add half-open state to circuit breaker", Apr 21); only the keepalive is salvaged here. Fixes #17003. --- tools/mcp_tool.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 21e935a12fff..9ed8ac75d0f6 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1038,14 +1038,43 @@ class MCPServerTask: with a fresh signal. Shutdown takes precedence if both events are set simultaneously. + + Periodically sends a lightweight keepalive (``list_tools``) to + prevent TCP connections from going stale during long idle + periods (#17003). If the keepalive fails, triggers a reconnect. """ + # Keepalive interval in seconds. Must be shorter than typical + # LB / NAT idle-timeout (commonly 300-600s). + _KEEPALIVE_INTERVAL = 180 # 3 minutes + shutdown_task = asyncio.create_task(self._shutdown_event.wait()) reconnect_task = asyncio.create_task(self._reconnect_event.wait()) try: - await asyncio.wait( - {shutdown_task, reconnect_task}, - return_when=asyncio.FIRST_COMPLETED, - ) + while True: + done, _pending = await asyncio.wait( + {shutdown_task, reconnect_task}, + timeout=_KEEPALIVE_INTERVAL, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + break + + # Timeout — no lifecycle event fired. Send a keepalive + # to exercise the connection and detect stale sockets. + if self.session: + try: + await asyncio.wait_for( + self.session.list_tools(), + timeout=30.0, + ) + except Exception as exc: + logger.warning( + "MCP server '%s' keepalive failed, " + "triggering reconnect: %s", + self.name, exc, + ) + self._reconnect_event.set() + break finally: for t in (shutdown_task, reconnect_task): if not t.done(): From 9e893d16d1f4bdb3c1623a547450f8b8bdcc5bee Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:18:49 -0700 Subject: [PATCH 45/99] fix(aux): default Codex reasoning effort to medium when extra_body.reasoning.effort is falsy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auxiliary..extra_body.reasoning, but the new translation path in _CodexCompletionsAdapter.create() reads the effort with ``reasoning_cfg.get("effort", "medium")``. That returns the configured value verbatim when the key is present, so ``effort: null`` / ``effort: ""`` (both common YAML shapes) flow through as ``{"effort": null, "summary": "auto"}`` and Codex rejects the request with "Invalid value for parameter ``reasoning.effort``". agent/transports/codex.py::build_kwargs() — which the new adapter is documented to mirror — uses a truthy check (``elif reasoning_config.get("effort"):``) so the same falsy values keep the "medium" default. Switch the auxiliary adapter to the same ``or "medium"`` truthy form so identical config produces identical requests on both paths. - [x] Two new regression tests cover ``effort: None`` and ``effort: ""`` and assert the request goes out as ``{"effort": "medium", "summary": "auto"}``. - [x] Old behaviour fails the new tests (``{'effort': None} != {'effort': 'medium'}``); fixed behaviour passes all 11 tests in the ``TestCodexAdapterReasoningTranslation`` class. - [x] Adjacent suites green: ``tests/agent/test_auxiliary_client.py`` (108 passed) and ``tests/agent/transports/test_codex_transport.py + test_chat_completions.py`` (73 passed). Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/auxiliary_client.py | 7 ++++++- tests/agent/test_auxiliary_client.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4c706748a0b0..62695af83aa5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -567,7 +567,12 @@ class _CodexCompletionsAdapter: # API allows it. pass else: - effort = reasoning_cfg.get("effort", "medium") + # Truthy-only check mirrors agent/transports/codex.py + # build_kwargs(): falsy values (None, "", 0) fall back + # to the default rather than being forwarded to the + # Codex backend, which rejects e.g. {"effort": null} + # with a 400. + effort = reasoning_cfg.get("effort") or "medium" # Codex backend rejects "minimal"; clamp to "low" to # match the main-agent Codex transport behavior. if effort == "minimal": diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 43125554dfa4..a42b6fc8779a 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1650,6 +1650,30 @@ class TestCodexAdapterReasoningTranslation: ) assert "reasoning" not in captured + def test_reasoning_effort_null_falls_back_to_medium(self): + """Parity with agent/transports/codex.py::build_kwargs() — falsy + ``effort`` (None / empty / 0) keeps the default ``medium`` instead + of being forwarded to Codex. Codex rejects ``{"effort": null}`` + with HTTP 400 (Invalid value for parameter `reasoning.effort`).""" + adapter, captured = self._build_adapter() + adapter.create( + messages=[{"role": "user", "content": "hi"}], + extra_body={"reasoning": {"effort": None}}, + ) + assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"} + assert captured.get("include") == ["reasoning.encrypted_content"] + + def test_reasoning_effort_empty_string_falls_back_to_medium(self): + """Empty-string effort (e.g. ``effort: ""`` in YAML) is falsy in + the main-agent path's truthy check; mirror that here so the same + config produces the same result.""" + adapter, captured = self._build_adapter() + adapter.create( + messages=[{"role": "user", "content": "hi"}], + extra_body={"reasoning": {"effort": ""}}, + ) + assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"} + assert captured.get("include") == ["reasoning.encrypted_content"] class TestVisionAutoSkipsKimiCoding: From c1a2710a322592e6bdceb3c874050eeb09b4fbf8 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:26:19 -0700 Subject: [PATCH 46/99] test(aux): cover effort: 0 fallback in Codex reasoning translation Copilot review on PR #17012 noted the docstring/comment lists `0` among the falsy effort values that fall back to `medium`, but the existing regression tests only cover `None` and `""`. Add the third case to lock in the full contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/agent/test_auxiliary_client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a42b6fc8779a..cf75d0b1f8ae 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1675,6 +1675,18 @@ class TestCodexAdapterReasoningTranslation: assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"} assert captured.get("include") == ["reasoning.encrypted_content"] + def test_reasoning_effort_zero_falls_back_to_medium(self): + """Numeric ``0`` is also falsy — the docstring lists it explicitly, + so cover the contract. Codex would reject ``{"effort": 0}`` the + same way it rejects ``null``.""" + adapter, captured = self._build_adapter() + adapter.create( + messages=[{"role": "user", "content": "hi"}], + extra_body={"reasoning": {"effort": 0}}, + ) + assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"} + assert captured.get("include") == ["reasoning.encrypted_content"] + class TestVisionAutoSkipsKimiCoding: """_resolve_auto vision branch skips providers that have no vision on From 34c6f93496244c4e4e0f7898f938b64d10a40537 Mon Sep 17 00:00:00 2001 From: Hafiy Zakaria Date: Tue, 28 Apr 2026 20:02:18 +0800 Subject: [PATCH 47/99] fix: resolve model.aliases from config.yaml in /model alias resolution hermes config set model.aliases.xxx commands write to the model.aliases nested key, but _load_direct_aliases() only read from the top-level model_aliases key. This meant aliases set via hermes config set were invisible to the /model command, and unrecognised inputs fell through to the DeepSeek normaliser which mapped everything to deepseek-chat. Add a second pass in _load_direct_aliases() that reads model.aliases and converts string-value entries (provider/model format) into DirectAlias objects. The provider is parsed from the slash prefix; if no slash, the current default provider from config is used. Also prevent simple aliases from overriding explicit model_aliases dict entries when both exist. --- hermes_cli/model_switch.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index c7edca0a07d2..8c2dc2eaed84 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -190,11 +190,18 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: model: "minimax-m2.7" provider: custom base_url: "https://ollama.com/v1" + + Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``) + and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``) + into DirectAlias objects. The provider is parsed from the ``provider/`` + prefix in the value; if no slash, the current provider is used. """ merged = dict(_BUILTIN_DIRECT_ALIASES) try: from hermes_cli.config import load_config cfg = load_config() + + # --- model_aliases (dict-based format) --- user_aliases = cfg.get("model_aliases") if isinstance(user_aliases, dict): for name, entry in user_aliases.items(): @@ -207,6 +214,30 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: merged[name.strip().lower()] = DirectAlias( model=model, provider=provider, base_url=base_url, ) + + # --- model.aliases (string-based format, from config set) --- + model_section = cfg.get("model", {}) + if isinstance(model_section, dict): + simple_aliases = model_section.get("aliases") + if isinstance(simple_aliases, dict): + current_provider = model_section.get("provider", "") + for name, value in simple_aliases.items(): + if not isinstance(value, str) or not value.strip(): + continue + key = name.strip().lower() + if key in merged: + continue # don't override explicit model_aliases entries + val = value.strip() + if "/" in val: + provider, model = val.split("/", 1) + else: + provider = current_provider + model = val + merged[key] = DirectAlias( + model=model.strip(), + provider=provider.strip() or current_provider, + base_url="", + ) except Exception: pass return merged From 7f735b4db2967849d935ef4b03bd6933107e48ff Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Tue, 28 Apr 2026 20:31:02 +0700 Subject: [PATCH 48/99] fix: return effective session_id after context compression (#16938) When context compression rotates the agent's session_id to a new child session, the API server was still returning the stale parent session_id in the X-Hermes-Session-Id response header. This caused external clients to keep sending the old session_id, loading uncompressed parent history instead of the compressed continuation. Fix: _run_agent() now includes the effective session_id in its result dict, and the response header uses it instead of the original provided session_id. --- gateway/platforms/api_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index d3beb1d1d2f8..200006b745e3 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1209,7 +1209,9 @@ class APIServerAdapter(BasePlatformAdapter): }, } - response_headers = {"X-Hermes-Session-Id": session_id} + response_headers = { + "X-Hermes-Session-Id": result.get("session_id", session_id), + } if gateway_session_key: response_headers["X-Hermes-Session-Key"] = gateway_session_key return web.json_response(response_data, headers=response_headers) @@ -2483,6 +2485,10 @@ class APIServerAdapter(BasePlatformAdapter): "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, "total_tokens": getattr(agent, "session_total_tokens", 0) or 0, } + # Include the effective session ID in the result so callers + # (e.g. X-Hermes-Session-Id header) can track compression- + # triggered session rotations. (#16938) + result["session_id"] = getattr(agent, "session_id", session_id) return result, usage return await loop.run_in_executor(None, _run) From 314361733f16e11ed6f42a2da772c68d1236b071 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:23:51 -0700 Subject: [PATCH 49/99] test(api_server): _run_agent result now carries session_id for #16938 --- gateway/platforms/api_server.py | 4 +++- tests/gateway/test_api_server.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 200006b745e3..b46075433161 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2488,7 +2488,9 @@ class APIServerAdapter(BasePlatformAdapter): # Include the effective session ID in the result so callers # (e.g. X-Hermes-Session-Id header) can track compression- # triggered session rotations. (#16938) - result["session_id"] = getattr(agent, "session_id", session_id) + _eff_sid = getattr(agent, "session_id", session_id) + if isinstance(_eff_sid, str) and _eff_sid: + result["session_id"] = _eff_sid return result, usage return await loop.run_in_executor(None, _run) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index d97ef646b365..2bf539041e94 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -395,7 +395,12 @@ class TestAgentExecution: session_id="session-123", ) - assert result == {"final_response": "ok"} + # _run_agent annotates result with the effective agent.session_id + # when it's a real string, so the response-header writer can track + # compression-triggered session rotations (#16938). The mock agent + # here doesn't set an explicit session_id string so the guard skips + # the annotation — header will fall back to the provided session_id. + assert result["final_response"] == "ok" assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3} mock_agent.run_conversation.assert_called_once_with( user_message="hello", From 80b386a472fdb37113e137360da3cc60e796d782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JC=E7=9A=84AI=E5=88=86=E8=BA=AB?= Date: Tue, 28 Apr 2026 20:22:33 +0800 Subject: [PATCH 50/99] fix(feishu): refresh bot identity during hydration --- gateway/platforms/feishu.py | 71 +++++++++++++++++++----------------- tests/gateway/test_feishu.py | 46 +++++++++++++++++++---- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index ac920bab69df..0c362a400cdc 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -3862,47 +3862,50 @@ class FeishuAdapter(BasePlatformAdapter): and self-sent bot event filtering. Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info - (no extra scopes required beyond the tenant access token). Falls back to - the application info endpoint for ``_bot_name`` only when the first probe - doesn't return it. Each field is hydrated independently — a value already - supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID / - FEISHU_BOT_NAME) is preserved and skips its probe. + (no extra scopes required beyond the tenant access token). The probe + always runs when a client is available so stale env vars from app/bot + migrations do not break group @mention gating. Falls back to the + application info endpoint for ``_bot_name`` only when the first probe + doesn't return it. If the probe fails, env-provided values are preserved. """ if not self._client: return - if self._bot_open_id and self._bot_name: - # Everything the self-send filter and precise mention gate need is - # already in place; nothing to probe. - return # Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no # extra scopes required. This is the same endpoint the onboarding wizard # uses via probe_bot(). - if not self._bot_open_id or not self._bot_name: - try: - req = ( - BaseRequest.builder() - .http_method(HttpMethod.GET) - .uri("/open-apis/bot/v3/info") - .token_types({AccessTokenType.TENANT}) - .build() - ) - resp = await asyncio.to_thread(self._client.request, req) - content = getattr(getattr(resp, "raw", None), "content", None) - if content: - payload = json.loads(content) - parsed = _parse_bot_response(payload) or {} - open_id = (parsed.get("bot_open_id") or "").strip() - bot_name = (parsed.get("bot_name") or "").strip() - if open_id and not self._bot_open_id: - self._bot_open_id = open_id - if bot_name and not self._bot_name: - self._bot_name = bot_name - except Exception: - logger.debug( - "[Feishu] /bot/v3/info probe failed during hydration", - exc_info=True, - ) + try: + req = ( + BaseRequest.builder() + .http_method(HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({AccessTokenType.TENANT}) + .build() + ) + resp = await asyncio.to_thread(self._client.request, req) + content = getattr(getattr(resp, "raw", None), "content", None) + if content: + payload = json.loads(content) + parsed = _parse_bot_response(payload) or {} + open_id = (parsed.get("bot_open_id") or "").strip() + bot_name = (parsed.get("bot_name") or "").strip() + if open_id: + if self._bot_open_id and self._bot_open_id != open_id: + logger.warning( + "[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating." + ) + self._bot_open_id = open_id + if bot_name: + if self._bot_name and self._bot_name != bot_name: + logger.info( + "[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating." + ) + self._bot_name = bot_name + except Exception: + logger.debug( + "[Feishu] /bot/v3/info probe failed during hydration", + exc_info=True, + ) # Fallback probe for _bot_name only: application info endpoint. Needs # admin:app.info:readonly or application:application:self_manage scope, diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 8042d38e3f37..0444261b183e 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2817,20 +2817,32 @@ class TestHydrateBotIdentity(unittest.TestCase): }, clear=True, ) - def test_hydration_skipped_when_env_vars_supply_both_fields(self): + def test_hydration_refreshes_env_values_when_bot_info_available(self): adapter = self._make_adapter() adapter._client = Mock() - adapter._client.request = Mock() + payload = json.dumps( + { + "code": 0, + "bot": { + "bot_name": "Hydrated Hermes", + "open_id": "ou_hydrated", + }, + } + ).encode("utf-8") + adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload))) asyncio.run(adapter._hydrate_bot_identity()) - adapter._client.request.assert_not_called() - self.assertEqual(adapter._bot_open_id, "ou_env") - self.assertEqual(adapter._bot_name, "Env Hermes") + # PR #16993 semantics: /bot/v3/info probe runs unconditionally + # and hydrated values win over env vars so a stale FEISHU_BOT_* + # from an old app registration doesn't break @mention gating. + adapter._client.request.assert_called_once() + self.assertEqual(adapter._bot_open_id, "ou_hydrated") + self.assertEqual(adapter._bot_name, "Hydrated Hermes") @patch.dict(os.environ, {"FEISHU_BOT_OPEN_ID": "ou_env"}, clear=True) - def test_hydration_fills_only_missing_fields(self): - """Env-var open_id must NOT be overwritten by a different probe value.""" + def test_hydration_overwrites_stale_env_open_id(self): + """A stale env open_id should not break group mention gating after app migration.""" adapter = self._make_adapter() adapter._client = Mock() payload = json.dumps( @@ -2846,9 +2858,27 @@ class TestHydrateBotIdentity(unittest.TestCase): asyncio.run(adapter._hydrate_bot_identity()) - self.assertEqual(adapter._bot_open_id, "ou_env") # preserved + self.assertEqual(adapter._bot_open_id, "ou_probe_DIFFERENT") self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in + @patch.dict( + os.environ, + { + "FEISHU_BOT_OPEN_ID": "ou_env", + "FEISHU_BOT_NAME": "Env Hermes", + }, + clear=True, + ) + def test_hydration_preserves_env_values_when_bot_info_probe_fails(self): + adapter = self._make_adapter() + adapter._client = Mock() + adapter._client.request = Mock(side_effect=RuntimeError("network down")) + + asyncio.run(adapter._hydrate_bot_identity()) + + self.assertEqual(adapter._bot_open_id, "ou_env") + self.assertEqual(adapter._bot_name, "Env Hermes") + @patch.dict(os.environ, {}, clear=True) def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self): adapter = self._make_adapter() From c7fc5af1228812434d45e7a8417a7567442f15a4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:29:49 -0700 Subject: [PATCH 51/99] chore: AUTHOR_MAP entry for tangyuanjc --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 7cea454075c4..d5f241ca3e27 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -56,6 +56,7 @@ AUTHOR_MAP = { "657290301@qq.com": "IMHaoyan", "revar@users.noreply.github.com": "revaraver", "beardthelion@users.noreply.github.com": "beardthelion", + "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From 96514de472d0019c10e5fa5928738094cb7d6a74 Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Tue, 28 Apr 2026 16:46:09 +0700 Subject: [PATCH 52/99] fix(auxiliary): avoid locking into custom path when api_key is empty When auxiliary. config has base_url set but api_key is empty (common when user expects env var fallback), _resolve_task_provider_model() returned provider="custom" with api_key=None. This caused downstream client construction to make API calls without an Authorization header, resulting in HTTP 401 errors. Fix: only return "custom" when BOTH cfg_base_url AND cfg_api_key are non-empty. When base_url is set without api_key but with a known provider (e.g. "openrouter"), pass through to that provider so it can resolve credentials from environment variables. Fixes #16829 --- agent/auxiliary_client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 62695af83aa5..3017e7703da3 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3132,8 +3132,14 @@ def _resolve_task_provider_model( if task: # Config.yaml is the primary source for per-task overrides. - if cfg_base_url: + if cfg_base_url and cfg_api_key: + # Both base_url and api_key explicitly set → custom endpoint. return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode + if cfg_base_url and cfg_provider and cfg_provider != "auto": + # base_url set without api_key but with a known provider — use + # the provider so it can resolve credentials from env vars + # (e.g. OPENROUTER_API_KEY) instead of locking into "custom". + return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode if cfg_provider and cfg_provider != "auto": return cfg_provider, resolved_model, None, None, resolved_api_mode From 19eebf6e0de733ffa2f28133801f79e07fbdec4e Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Apr 2026 19:59:45 +0800 Subject: [PATCH 53/99] fix(openrouter): treat xiaomi models as reasoning-capable --- run_agent.py | 1 + tests/run_agent/test_run_agent.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/run_agent.py b/run_agent.py index 46197eee76f2..c5662c70ecbe 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8582,6 +8582,7 @@ class AIAgent: "google/gemini-2", "qwen/qwen3", "tencent/hy3-preview", + "xiaomi/", ) return any(model.startswith(prefix) for prefix in reasoning_model_prefixes) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d663805f8f07..eba186cf2c27 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4990,6 +4990,28 @@ class TestDeadRetryCode: ) +class TestSupportsReasoningExtraBody: + def _make_agent(self): + agent = object.__new__(AIAgent) + agent.provider = "openrouter" + agent.base_url = "https://openrouter.ai/api/v1" + agent._base_url_lower = agent.base_url.lower() + agent.model = "" + return agent + + def test_xiaomi_models_are_treated_as_reasoning_capable(self): + agent = self._make_agent() + for model in ( + "xiaomi/mimo-v2.5-pro", + "xiaomi/mimo-v2.5", + "xiaomi/mimo-v2-omni", + "xiaomi/mimo-v2-pro", + "xiaomi/mimo-v2-flash", + ): + agent.model = model + assert agent._supports_reasoning_extra_body() is True, model + + class TestMemoryContextSanitization: """sanitize_context() helper correctness — used at provider boundaries.""" From f844e516d8ca49581d7e61dd03e98c9daa204943 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:32:18 -0700 Subject: [PATCH 54/99] chore: AUTHOR_MAP entry for agentlinker --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index d5f241ca3e27..33b52c306cc9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -57,6 +57,7 @@ AUTHOR_MAP = { "revar@users.noreply.github.com": "revaraver", "beardthelion@users.noreply.github.com": "beardthelion", "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", + "leon@agentlinker.ai": "agentlinker", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From f6677748a0376a386bb339a7ac4a7264d2b5675c Mon Sep 17 00:00:00 2001 From: Santosh Date: Tue, 28 Apr 2026 05:35:22 -0500 Subject: [PATCH 55/99] fix(claw): handle missing dir in _scan_workspace_state --- hermes_cli/claw.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index f6e2521eb01f..5f9d728252dd 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -235,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: """ findings: list[tuple[Path, str]] = [] + if not source_dir.exists(): + return findings + # Direct state files in the root for name in ("todo.json", "sessions", "logs"): candidate = source_dir / name @@ -243,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: findings.append((candidate, f"Root {kind}: {name}")) # State files inside workspace directories - for child in sorted(source_dir.iterdir()): + try: + children = sorted(source_dir.iterdir()) + except OSError: + return findings + + for child in children: if not child.is_dir() or child.name.startswith("."): continue # Check for workspace-like subdirectories From 37b5731694c5acaf90cdb0d514b0a6c4170cb7b0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:33:23 -0700 Subject: [PATCH 56/99] chore: AUTHOR_MAP entry for npmisantosh --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 33b52c306cc9..a1405aff3f75 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -58,6 +58,7 @@ AUTHOR_MAP = { "beardthelion@users.noreply.github.com": "beardthelion", "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", "leon@agentlinker.ai": "agentlinker", + "santoshhumagain1887@gmail.com": "npmisantosh", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From 4e6f51167dd12d2812bfccbf6226618dacfff3fd Mon Sep 17 00:00:00 2001 From: novax635 Date: Tue, 28 Apr 2026 12:35:43 +0300 Subject: [PATCH 57/99] fix(cli): fall back on invalid HERMES_MAX_ITERATIONS --- cli.py | 5 ++++- tests/cli/test_cli_init.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 76e10e29f43d..966c63c0bf1c 100644 --- a/cli.py +++ b/cli.py @@ -2145,7 +2145,10 @@ class HermesCLI: elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns self.max_turns = CLI_CONFIG["max_turns"] elif os.getenv("HERMES_MAX_ITERATIONS"): - self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS")) + try: + self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS", "")) + except (TypeError, ValueError): + self.max_turns = 90 else: self.max_turns = 90 diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index d2d6398b969b..bf1f347e500f 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -75,6 +75,11 @@ class TestMaxTurnsResolution: cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"}) assert cli_obj.max_turns == 42 + def test_invalid_env_var_max_turns_falls_back_to_default(self): + """Invalid env values should not crash CLI init.""" + cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"}) + assert cli_obj.max_turns == 90 + def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self): cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77}) assert cli_obj.max_turns == 77 From 349d0da07ec93d5929295a2fc180aa4d3d030f53 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:35:18 -0700 Subject: [PATCH 58/99] chore: AUTHOR_MAP entry for novax635 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a1405aff3f75..d41ab75bce0d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", "leon@agentlinker.ai": "agentlinker", "santoshhumagain1887@gmail.com": "npmisantosh", + "novax635@gmail.com": "novax635", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From 3b16c590e03f8208989ea2eab588c6d84b75eca7 Mon Sep 17 00:00:00 2001 From: Krionex Date: Tue, 28 Apr 2026 12:04:27 +0300 Subject: [PATCH 59/99] fix(gateway): ignore malformed custom delay env vars in natural mode --- gateway/platforms/base.py | 5 +++-- tests/gateway/test_platform_base.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 4d611fdaa536..8b34f461aa35 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2675,10 +2675,11 @@ class BasePlatformAdapter(ABC): mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower() if mode == "off": return 0.0 - min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800")) - max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500")) if mode == "natural": min_ms, max_ms = 800, 2500 + return random.uniform(min_ms / 1000.0, max_ms / 1000.0) + min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800")) + max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500")) return random.uniform(min_ms / 1000.0, max_ms / 1000.0) async def _process_message_background(self, event: MessageEvent, session_key: str) -> None: diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index a6e0d51d60ee..dd6ffaf95114 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -492,6 +492,16 @@ class TestGetHumanDelay: delay = BasePlatformAdapter._get_human_delay() assert 0.8 <= delay <= 2.5 + def test_natural_mode_ignores_malformed_custom_env_vars(self): + env = { + "HERMES_HUMAN_DELAY_MODE": "natural", + "HERMES_HUMAN_DELAY_MIN_MS": "oops", + "HERMES_HUMAN_DELAY_MAX_MS": "still-bad", + } + with patch.dict(os.environ, env): + delay = BasePlatformAdapter._get_human_delay() + assert 0.8 <= delay <= 2.5 + def test_custom_mode_uses_env_vars(self): env = { "HERMES_HUMAN_DELAY_MODE": "custom", From 285c208cf7b5bd67d055a952cd7e748b32ecaf73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:37:28 -0700 Subject: [PATCH 60/99] fix(gateway): also tolerate malformed env vars in custom human-delay mode Widens @Krionex's PR #16933 fix to cover the second bug class at the sibling site. natural mode used to pass env values through int() before the PR caught mis-typed values crashing the gateway; custom mode had the exact same bug one branch away (HERMES_HUMAN_DELAY_MIN_MS=oops in custom mode still crashed). Same try/except/fallback pattern, scoped to the two int() calls that feed random.uniform(). --- gateway/platforms/base.py | 11 +++++++++-- tests/gateway/test_platform_base.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8b34f461aa35..5c2bbf96aa88 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2678,8 +2678,15 @@ class BasePlatformAdapter(ABC): if mode == "natural": min_ms, max_ms = 800, 2500 return random.uniform(min_ms / 1000.0, max_ms / 1000.0) - min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800")) - max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500")) + # custom mode — tolerate malformed env vars instead of crashing. + try: + min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800")) + except (TypeError, ValueError): + min_ms = 800 + try: + max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500")) + except (TypeError, ValueError): + max_ms = 2500 return random.uniform(min_ms / 1000.0, max_ms / 1000.0) async def _process_message_background(self, event: MessageEvent, session_key: str) -> None: diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index dd6ffaf95114..84f3b7239fb8 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -512,6 +512,17 @@ class TestGetHumanDelay: delay = BasePlatformAdapter._get_human_delay() assert 0.1 <= delay <= 0.2 + def test_custom_mode_tolerates_malformed_env_vars(self): + env = { + "HERMES_HUMAN_DELAY_MODE": "custom", + "HERMES_HUMAN_DELAY_MIN_MS": "oops", + "HERMES_HUMAN_DELAY_MAX_MS": "still-bad", + } + with patch.dict(os.environ, env): + # falls back to the custom-mode defaults instead of crashing + delay = BasePlatformAdapter._get_human_delay() + assert 0.8 <= delay <= 2.5 + # --------------------------------------------------------------------------- # utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn From fb311952d7708ac04f7534dedd22737782e411ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:37:42 -0700 Subject: [PATCH 61/99] chore: AUTHOR_MAP entry for Krionex --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index d41ab75bce0d..f7e3f82dc6a4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -60,6 +60,7 @@ AUTHOR_MAP = { "leon@agentlinker.ai": "agentlinker", "santoshhumagain1887@gmail.com": "npmisantosh", "novax635@gmail.com": "novax635", + "krionex1@gmail.com": "Krionex", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From c46bc9294991929a3dc8f6c28111c3e7780406a2 Mon Sep 17 00:00:00 2001 From: rxdxxxx Date: Tue, 5 May 2026 10:07:58 +0800 Subject: [PATCH 62/99] fix(run_agent): use aux provider for compression context length lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each auxiliary model must be resolved with its own provider so that provider-specific paths (e.g. Bedrock static table, OpenRouter API) are invoked for the correct client, not inherited from the main model. When the main model is Bedrock, passing self.provider unconditionally to get_model_context_length() for the aux model caused the Bedrock static table hard-intercept (step 1b) to fire for non-Bedrock models, returning BEDROCK_DEFAULT_CONTEXT_LENGTH=128K instead of the model's real context window — triggering a false compression warning every session. Fix: pass _aux_cfg_provider when explicitly set, falling back to self.provider only when the aux provider is unset or "auto". Closes #12977 Related: #13807, #17460 --- run_agent.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index c5662c70ecbe..bf1e2258bb66 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2667,7 +2667,10 @@ class AIAgent: base_url=aux_base_url, api_key=aux_api_key, config_context_length=getattr(self, "_aux_compression_context_length_config", None), - provider=getattr(self, "provider", ""), + # Each model must be resolved with its own provider so that + # provider-specific paths (e.g. Bedrock static table, OpenRouter API) + # are invoked for the correct client, not inherited from the main model. + provider=(_aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(self, "provider", "")), ) # Hard floor: the auxiliary compression model must have at least From 8ebb81fd769abadbd954808059216411b73cd2b1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:39:13 -0700 Subject: [PATCH 63/99] chore: AUTHOR_MAP entry for rxdxxxx --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index f7e3f82dc6a4..6120fd563302 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -61,6 +61,7 @@ AUTHOR_MAP = { "santoshhumagain1887@gmail.com": "npmisantosh", "novax635@gmail.com": "novax635", "krionex1@gmail.com": "Krionex", + "rxdxxxx@users.noreply.github.com": "rxdxxxx", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From 02147cc850069294d215d72b55c2a2cc2390eff4 Mon Sep 17 00:00:00 2001 From: MaHaoHao-ch Date: Tue, 28 Apr 2026 10:43:53 +0800 Subject: [PATCH 64/99] =?UTF-8?q?=EF=BB=BFfix(cli):=20sanitize=20bracketed?= =?UTF-8?q?=20paste=20markers=20during=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip bracketed-paste control sequences from setup prompt input so pasted API keys work on Linux and WSL terminals, and add regression tests for normal/password prompts. Closes #16491 --- hermes_cli/setup.py | 14 ++++++++++++- tests/hermes_cli/test_setup_prompt_menus.py | 22 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 63f5267ddf2f..19e9366a202d 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -15,6 +15,7 @@ import importlib.util import json import logging import os +import re import shutil import sys import copy @@ -208,12 +209,23 @@ def prompt(question: str, default: str = None, password: bool = False) -> str: else: value = input(color(display, Colors.YELLOW)) - return value.strip() or default or "" + cleaned = _sanitize_pasted_input(value) + return cleaned.strip() or default or "" except (KeyboardInterrupt, EOFError): print() sys.exit(1) +_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~") + + +def _sanitize_pasted_input(value: str) -> str: + """Strip terminal bracketed-paste control markers from pasted text.""" + if not isinstance(value, str) or not value: + return value + return _BRACKETED_PASTE_PATTERN.sub("", value) + + def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int: """Single-select menu using curses. Delegates to curses_radiolist.""" from hermes_cli.curses_ui import curses_radiolist diff --git a/tests/hermes_cli/test_setup_prompt_menus.py b/tests/hermes_cli/test_setup_prompt_menus.py index fd017d87dfe9..e776ba1fc55e 100644 --- a/tests/hermes_cli/test_setup_prompt_menus.py +++ b/tests/hermes_cli/test_setup_prompt_menus.py @@ -1,6 +1,28 @@ from hermes_cli import setup as setup_mod +def test_prompt_strips_bracketed_paste_markers(monkeypatch): + monkeypatch.setattr( + "builtins.input", + lambda _prompt="": "\x1b[200~sk-ant-api-key\x1b[201~", + ) + + value = setup_mod.prompt("API key") + + assert value == "sk-ant-api-key" + + +def test_password_prompt_strips_bracketed_paste_markers(monkeypatch): + monkeypatch.setattr( + "getpass.getpass", + lambda _prompt="": "\x1b[200~secret-token\x1b[201~", + ) + + value = setup_mod.prompt("API key", password=True) + + assert value == "secret-token" + + def test_prompt_choice_uses_curses_helper(monkeypatch): monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: 1) From 7530ce04e09d438923409b8eab738dd3dcbb3110 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:40:04 -0700 Subject: [PATCH 65/99] chore: AUTHOR_MAP entry for MaHaoHao-ch --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6120fd563302..09ca21f24d94 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -62,6 +62,7 @@ AUTHOR_MAP = { "novax635@gmail.com": "novax635", "krionex1@gmail.com": "Krionex", "rxdxxxx@users.noreply.github.com": "rxdxxxx", + "ma.haohao2@xydigit.com": "MaHaoHao-ch", "29756950+revaraver@users.noreply.github.com": "revaraver", "nexus@eptic.me": "TheEpTic", "74554762+wmagev@users.noreply.github.com": "wmagev", From b7bd177105986a0af5ec13c427e0e3837f8d05c8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 07:56:29 -0700 Subject: [PATCH 66/99] docs(AGENTS.md): add curator/cron/delegation/toolsets, fix plugin tree (#20226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(AGENTS.md): add curator/cron/delegation/toolsets, fix plugin tree, frontmatter, auto-discovery caveat Closes #19101 and #19107 (@pty819). Verified 16 claims from those two issues against current main. 12 were real gaps; 2 were generated/hallucinated (#10 unverified --now flag is actually real and already cited in AGENTS.md; #11 stale PR refs #5587 and #4950 do not appear in AGENTS.md at all); 2 were low-prio nits (memory provider hierarchy, --now scope enumeration) deferred. Changes: - Project tree: add yuanbao to platforms comment; expand plugins/ subtree with real directory names (kanban, hermes-achievements, observability, image_gen) instead of vague ''. - Test-count blurb: 15k/700 Apr → 17k/900 May (verified: 17,375 test defs, 915 files). - Adding New Tools: clarify that auto-discovery wires up schemas but the tool only reaches an agent if its name is added to a toolset in toolsets.py. _HERMES_CORE_TOOLS is not dead code. - Adding Configuration: enumerate top-level config.yaml sections including auxiliary and curator; note auxiliary is per-task overrides for side-LLM work. - SKILL.md frontmatter: add author, license, related_skills. Note top-level tags/category are mirrored from metadata.hermes.*. - New section 'Toolsets' — enumerates the 30 current TOOLSETS keys (including yuanbao, kanban, moa, spotify, safe, debugging). - New section 'Delegation (delegate_task)' — sync semantics, batch mode, leaf vs orchestrator roles, config knobs, durability caveat. - New section 'Curator (skill lifecycle)' — core files, 11 CLI verbs, telemetry sidecar, invariants (pin/delete split after PR #20220, bundled/hub off-limits), curator.* config section. - New section 'Cron (scheduled jobs)' — 4 schedule formats, 7 CLI verbs, per-job fields, 3-min hard interrupt, catchup/grace windows, tick.lock, cron→session isolation. Skipped (invalid claims): - #19107 item 10: --now is real (hermes_cli/skills_hub.py:624/966/1013/1470) - #19107 item 11: no '#5587' or '#4950' or 'async_delegation' in AGENTS.md * docs(AGENTS.md): add Kanban section Adds a Kanban entry alongside Curator / Cron / Delegation so the major durable background systems are all represented. Covers the CLI verbs, the HERMES_KANBAN_TASK-gated worker toolset, the in-gateway dispatcher, plugin assets, and the board/tenant isolation model. Points at the full 742-line user docs for detail. --- AGENTS.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 195 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f09258061fdf..b77a1d269990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,12 +37,17 @@ hermes-agent/ │ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp, │ │ # homeassistant, signal, matrix, mattermost, email, sms, │ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles, -│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md. +│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md. │ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped) ├── plugins/ # Plugin system (see "Plugins" section below) │ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...) │ ├── context_engine/ # Context-engine plugins -│ └── / # Dashboard, image-gen, disk-cleanup, examples, ... +│ ├── kanban/ # Multi-agent board dispatcher + worker plugin +│ ├── hermes-achievements/ # Gamified achievement tracking +│ ├── observability/ # Metrics / traces / logs plugin +│ ├── image_gen/ # Image-generation providers +│ └── / # disk-cleanup, example-dashboard, google_meet, platforms, +│ # spotify, strike-freedom-cockpit, ... ├── optional-skills/ # Heavier/niche skills shipped but NOT active by default ├── skills/ # Built-in skills bundled with the repo ├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` @@ -53,7 +58,7 @@ hermes-agent/ ├── environments/ # RL training environments (Atropos) ├── scripts/ # run_tests.sh, release.py, auxiliary scripts ├── website/ # Docusaurus docs site -└── tests/ # Pytest suite (~15k tests across ~700 files as of Apr 2026) +└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026) ``` **User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only). @@ -289,9 +294,9 @@ registry.register( ) ``` -**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. +**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from. -Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. +Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step. The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string. @@ -313,6 +318,22 @@ The registry handles schema collection, dispatch, availability checking, and err section is handled automatically by the deep-merge and does NOT require a version bump. +### Top-level `config.yaml` sections (non-exhaustive): + +`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`, +`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`, +`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`, +`plugins`, `honcho`. + +`auxiliary` holds per-task overrides for side-LLM work (curator, vision, +embedding, title generation, session_search, etc.) — each task can pin +its own provider/model/base_url/max_tokens/reasoning_effort. See +`agent/auxiliary_client.py::_resolve_auto` for resolution order. + +`curator` holds the background skill-maintenance config — +`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`, +`archive_after_days`, `backup` (nested). + ### .env variables (SECRETS ONLY — API keys, tokens, passwords): 1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata: ```python @@ -519,11 +540,176 @@ niche skills belong in `optional-skills/`. ### SKILL.md frontmatter -Standard fields: `name`, `description`, `version`, `platforms` -(OS-gating list: `[macos]`, `[linux, macos]`, ...), +Standard fields: `name`, `description`, `version`, `author`, `license`, +`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...), `metadata.hermes.tags`, `metadata.hermes.category`, -`metadata.hermes.config` (config.yaml settings the skill needs — stored -under `skills.config.`, prompted during setup, injected at load time). +`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml +settings the skill needs — stored under `skills.config.`, prompted +during setup, injected at load time). + +Top-level `tags:` and `category:` are also accepted and mirrored from +`metadata.hermes.*` by the loader. + +--- + +## Toolsets + +All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict. +Each platform's adapter picks a base toolset (e.g. Telegram uses +`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most +platforms inherit from. + +Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`, +`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`, +`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`, +`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`, +`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`. + +Enable/disable per platform via `hermes tools` (the curses UI) or the +`tools..enabled` / `tools..disabled` lists in +`config.yaml`. + +--- + +## Delegation (`delegate_task`) + +`tools/delegate_tool.py` spawns a subagent with an isolated +context + terminal session. Synchronous: the parent waits for the +child's summary before continuing its own loop — if the parent is +interrupted, the child is cancelled. + +Two shapes: + +- **Single:** pass `goal` (+ optional `context`, `toolsets`). +- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent + running concurrently. Concurrency is capped by + `delegation.max_concurrent_children` (default 3). + +Roles: + +- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`, + `clarify`, `memory`, `send_message`, `execute_code`. +- `role="orchestrator"` — retains `delegate_task` so it can spawn its + own workers. Gated by `delegation.orchestrator_enabled` (default true) + and bounded by `delegation.max_spawn_depth` (default 2). + +Key config knobs (under `delegation:` in `config.yaml`): +`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`, +`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`, +`max_iterations`. + +Synchronicity rule: delegate_task is **not** durable. For long-running +work that must outlive the current turn, use `cronjob` or +`terminal(background=True, notify_on_complete=True)` instead. + +--- + +## Curator (skill lifecycle) + +Background skill-maintenance system that tracks usage on agent-created +skills and auto-archives stale ones. Users never lose skills; archives +go to `~/.hermes/skills/.archive/` and are restorable. + +- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review + prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots). +- **CLI:** `hermes_cli/curator.py` wires `hermes curator ` where + verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`, + `archive`, `restore`, `prune`, `backup`, `rollback`. +- **Telemetry:** `tools/skill_usage.py` owns the sidecar + `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`, + `patch_count`, `last_activity_at`, `state` (active / stale / + archived), `pinned`. + +Invariants: +- Curator only touches skills with `created_by: "agent"` provenance — + bundled + hub-installed skills are off-limits. +- Never deletes; max destructive action is archive. +- Pinned skills are exempt from every auto-transition and from the + LLM review pass. +- `skill_manage(action="delete")` refuses pinned skills; patch/edit/ + write_file/remove_file go through so the agent can keep improving + pinned skills. + +Config section (`curator:` in `config.yaml`): +`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`, +`archive_after_days`, `backup.*`. + +Full user-facing docs: `website/docs/user-guide/features/curator.md`. + +--- + +## Cron (scheduled jobs) + +`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents +schedule jobs via the `cronjob` tool; users via `hermes cron ` +(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the +`/cron` slash command. + +Supported schedule formats: +- Duration: `"30m"`, `"2h"`, `"1d"` +- "every" phrase: `"every 2h"`, `"every monday 9am"` +- 5-field cron expression: `"0 9 * * *"` +- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"` + +Per-job fields include `skills` (load specific skills), `model` / +`provider` overrides, `script` (pre-run data-collection script whose +stdout is injected into the prompt; `no_agent=True` turns the script +into the entire job), `context_from` (chain job A's last output into +job B's prompt), `workdir` (run in a specific directory with its +`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery. + +Hardening invariants: +- **3-minute hard interrupt** on cron sessions — runaway agent loops + cannot monopolize the scheduler. +- Catchup window: half the job's period, clamped to 120s–2h. +- Grace window: 120s for one-shot jobs whose fire time was missed. +- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks + across processes. +- Cron sessions pass `skip_memory=True` by default; memory providers + intentionally do not run during cron. + +Cron deliveries are **not** mirrored into the target gateway session — +they land in their own cron session with a header/footer frame so the +main conversation's message-role alternation stays intact. + +--- + +## Kanban (multi-agent work queue) + +Durable SQLite-backed board that lets multiple profiles / workers +collaborate on shared tasks. Users drive it via `hermes kanban `; +workers spawned by the dispatcher drive it via a dedicated `kanban_*` +toolset so their schema footprint is zero when they're not inside a +kanban task. + +- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs + `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`, + `unlink`, `comment`, `complete`, `block`, `unblock`, `archive`, + `tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`, + `assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`. +- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`, + `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, + `kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so + the schema only appears for processes actually running as a worker. +- **Dispatcher:** long-lived loop that (default every 60s) reclaims + stale claims, promotes ready tasks, atomically claims, and spawns + assigned profiles. Runs **inside the gateway** by default via + `kanban.dispatch_in_gateway: true`. +- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) + + `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for + standalone dispatcher deployment). + +Isolation model: +- **Board** is the hard boundary — workers are spawned with + `HERMES_KANBAN_BOARD` pinned in their env so they can't see other + boards. +- **Tenant** is a soft namespace *within* a board — one specialist + fleet can serve multiple businesses with workspace-path + memory-key + isolation. +- After ~5 consecutive spawn failures on the same task the dispatcher + auto-blocks it to prevent spin loops. + +Full user-facing docs: `website/docs/user-guide/features/kanban.md`. --- From 7de3c86c5a793485d7b686ac80448336ae996689 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 08:03:07 -0700 Subject: [PATCH 67/99] feat(i18n): add display.language for static message translation (zh/ja/de/es) (#20231) * revert(gateway): remove stale-code self-check and auto-restart Removes the _detect_stale_code / _trigger_stale_code_restart mechanism introduced in #17648 and iterated in #19740. On every incoming message the gateway compared the boot-time git HEAD SHA to the current SHA on disk, and if they differed it would reply with Gateway code was updated in the background -- restarting this gateway so your next message runs on the new code. Please retry in a moment. and then kick off a graceful restart. This is unwanted behaviour: users who run a long-lived gateway and do their own ad-hoc git operations on the checkout end up with their chat interrupted and the current message dropped every time HEAD moves, with no way to opt out. If an operator really needs the old protection against stale sys.modules after "hermes update", the SIGKILL-survivor sweep in hermes update (hermes_cli/main.py, also tagged #17648) already handles the supervisor-respawn case on its own. Removed: gateway/run.py: - _STALE_CODE_SENTINELS, _GIT_SHA_CACHE_TTL_SECS - _read_git_head_sha(), _compute_repo_mtime() module helpers - class-level _boot_wall_time / _boot_repo_mtime / _boot_git_sha / _stale_code_restart_triggered defaults - __init__ boot-snapshot block (_boot_*, _cached_current_sha*, _repo_root_for_staleness, _stale_code_notified) - _current_git_sha_cached(), _detect_stale_code(), _trigger_stale_code_restart() methods - stale-code check + user-facing restart notice at the top of _handle_message() tests/gateway/test_stale_code_self_check.py (deleted, 412 lines) No new logic added. Zero remaining references to any removed symbol. Gateway test suite passes the same 4589 tests it passed before; the 3 pre-existing unrelated failures (discord free-channel, feishu bot admission, teams typing) are unchanged by this commit. * feat(i18n): add display.language for static message translation (zh/ja/de/es) Adds a thin-slice i18n layer covering the highest-impact static user-facing messages: the CLI dangerous-command approval prompt and a handful of gateway slash-command replies (restart-drain, goal cleared, approval expired, config read/save errors). Out of scope (stays English): agent responses, log lines, tool outputs, slash-command descriptions, error tracebacks. Infrastructure: - agent/i18n.py: catalog loader, t() helper, language resolution (HERMES_LANGUAGE env var > display.language config > en) - locales/{en,zh,ja,de,es}.yaml: ~19 translated strings per language - display.language in DEFAULT_CONFIG (hermes_cli/config.py) Tests: - tests/agent/test_i18n.py: 21 tests covering catalog parity, placeholder parity across locales, fallback behavior, env-var override, alias normalization, missing-key graceful degradation. Docs: - website/docs/user-guide/configuration.md: display.language entry plus a short section explaining scope so users don't expect agent responses to translate via this knob. --- agent/i18n.py | 230 +++++++++++++++++++++++ gateway/run.py | 13 +- hermes_cli/config.py | 5 + locales/de.yaml | 24 +++ locales/en.yaml | 35 ++++ locales/es.yaml | 24 +++ locales/ja.yaml | 24 +++ locales/zh.yaml | 24 +++ tests/agent/test_i18n.py | 156 +++++++++++++++ tools/approval.py | 25 +-- website/docs/user-guide/configuration.md | 14 ++ 11 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 agent/i18n.py create mode 100644 locales/de.yaml create mode 100644 locales/en.yaml create mode 100644 locales/es.yaml create mode 100644 locales/ja.yaml create mode 100644 locales/zh.yaml create mode 100644 tests/agent/test_i18n.py diff --git a/agent/i18n.py b/agent/i18n.py new file mode 100644 index 000000000000..98d7ebce9a58 --- /dev/null +++ b/agent/i18n.py @@ -0,0 +1,230 @@ +"""Lightweight internationalization (i18n) for Hermes static user-facing messages. + +Scope (thin slice, by design): only the highest-impact static strings shown +to the user by Hermes itself -- approval prompts, a handful of gateway slash +command replies, restart-drain notices. Agent-generated output, log lines, +error tracebacks, tool outputs, and slash-command descriptions all stay in +English. + +Catalog files live under ``locales/.yaml`` at the repo root. Each +catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or +``gateway.approval_expired``). Missing keys fall back to English; if English +is missing too, the key path itself is returned so a broken catalog never +crashes the agent. + +Usage:: + + from agent.i18n import t + print(t("approval.choose_long")) # current lang + print(t("gateway.draining", count=3)) # {count} formatted + print(t("approval.choose_long", lang="zh")) # explicit override + +Language resolution order: + 1. Explicit ``lang=`` argument passed to :func:`t` + 2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override) + 3. ``display.language`` from config.yaml + 4. ``"en"`` (baseline) + +Supported languages: en, zh, ja, de, es. Unknown values fall back to en. +""" + +from __future__ import annotations + +import logging +import os +import threading +from functools import lru_cache +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es") +DEFAULT_LANGUAGE = "en" + +# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp" +# get the right catalog instead of silently falling back to English. +_LANGUAGE_ALIASES: dict[str, str] = { + "english": "en", "en-us": "en", "en-gb": "en", + "chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh", + "japanese": "ja", "jp": "ja", "ja-jp": "ja", + "german": "de", "deutsch": "de", "de-de": "de", + "spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es", +} + +_catalog_cache: dict[str, dict[str, str]] = {} +_catalog_lock = threading.Lock() + + +def _locales_dir() -> Path: + """Return the directory containing locale YAML files. + + Lives next to the repo root so both the bundled install and editable + checkouts find it without PYTHONPATH gymnastics. + """ + # agent/i18n.py -> agent/ -> repo root + return Path(__file__).resolve().parent.parent / "locales" + + +def _normalize_lang(value: Any) -> str: + """Normalize a user-supplied language value to a supported code. + + Accepts supported codes directly, common aliases (``chinese`` -> ``zh``), + and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the + default language for unknown values. + """ + if not isinstance(value, str): + return DEFAULT_LANGUAGE + key = value.strip().lower() + if not key: + return DEFAULT_LANGUAGE + if key in SUPPORTED_LANGUAGES: + return key + if key in _LANGUAGE_ALIASES: + return _LANGUAGE_ALIASES[key] + # Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported, + # but "zh-CN" -> "zh" will). + base = key.split("-", 1)[0] + if base in SUPPORTED_LANGUAGES: + return base + return DEFAULT_LANGUAGE + + +def _load_catalog(lang: str) -> dict[str, str]: + """Load and flatten one locale YAML file into a dotted-key dict. + + YAML files can be nested for human readability; this produces the flat + key space :func:`t` expects. Cached per-language for the process. + """ + with _catalog_lock: + cached = _catalog_cache.get(lang) + if cached is not None: + return cached + + path = _locales_dir() / f"{lang}.yaml" + if not path.is_file(): + logger.debug("i18n catalog missing for %s at %s", lang, path) + with _catalog_lock: + _catalog_cache[lang] = {} + return {} + + try: + import yaml # PyYAML is already a hermes dependency + with path.open("r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + except Exception as exc: + logger.warning("Failed to load i18n catalog %s: %s", path, exc) + with _catalog_lock: + _catalog_cache[lang] = {} + return {} + + flat: dict[str, str] = {} + _flatten_into(raw, "", flat) + with _catalog_lock: + _catalog_cache[lang] = flat + return flat + + +def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None: + if isinstance(node, dict): + for key, value in node.items(): + child_key = f"{prefix}.{key}" if prefix else str(key) + _flatten_into(value, child_key, out) + elif isinstance(node, str): + out[prefix] = node + # Non-string, non-dict leaves are ignored -- catalogs are text-only. + + +@lru_cache(maxsize=1) +def _config_language_cached() -> str | None: + """Read ``display.language`` from config.yaml once per process. + + Cached because ``t()`` is called in hot paths (every approval prompt, + every gateway reply) and re-reading YAML each call would be wasteful. + ``reset_language_cache()`` clears this when config changes at runtime + (e.g. after the setup wizard). + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + lang = (cfg.get("display") or {}).get("language") + if lang: + return _normalize_lang(lang) + except Exception as exc: + logger.debug("Could not read display.language from config: %s", exc) + return None + + +def reset_language_cache() -> None: + """Invalidate cached language resolution and catalogs. + + Call after :func:`hermes_cli.config.save_config` if a running process + needs to pick up a changed ``display.language`` without restart. + """ + _config_language_cached.cache_clear() + with _catalog_lock: + _catalog_cache.clear() + + +def get_language() -> str: + """Resolve the active language using env > config > default order.""" + env_lang = os.environ.get("HERMES_LANGUAGE") + if env_lang: + return _normalize_lang(env_lang) + cfg_lang = _config_language_cached() + if cfg_lang: + return cfg_lang + return DEFAULT_LANGUAGE + + +def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str: + """Translate a dotted key to the active language. + + Parameters + ---------- + key + Dotted path into the catalog, e.g. ``"approval.choose_long"``. + lang + Explicit language override. Takes precedence over env + config. + **format_kwargs + ``str.format`` substitution arguments (``t("gateway.drain", count=3)`` + expects a catalog entry with a ``{count}`` placeholder). + + Returns + ------- + The translated string, or the English fallback if the key is missing in + the target language, or the bare key if English is also missing. + """ + target = _normalize_lang(lang) if lang else get_language() + catalog = _load_catalog(target) + value = catalog.get(key) + + if value is None and target != DEFAULT_LANGUAGE: + # Fall through to English rather than showing a key path to the user. + value = _load_catalog(DEFAULT_LANGUAGE).get(key) + + if value is None: + # Last-ditch: return the key itself. A broken catalog should not + # crash anything; it just looks ugly until someone fixes it. + logger.debug("i18n miss: key=%r lang=%r", key, target) + value = key + + if format_kwargs: + try: + return value.format(**format_kwargs) + except (KeyError, IndexError, ValueError) as exc: + logger.warning( + "i18n format failed for key=%r lang=%r kwargs=%r: %s", + key, target, format_kwargs, exc, + ) + return value + return value + + +__all__ = [ + "SUPPORTED_LANGUAGES", + "DEFAULT_LANGUAGE", + "t", + "get_language", + "reset_language_cache", +] diff --git a/gateway/run.py b/gateway/run.py index 433b41387fd4..c0a6ae0bb10d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -39,6 +39,7 @@ from typing import Dict, Optional, Any, List, Union # gateway is a long-running daemon, so its boot cost matters less than # preserving the established test-patch surface. from agent.account_usage import fetch_account_usage, render_account_usage_lines +from agent.i18n import t from hermes_cli.config import cfg_get # --- Agent cache tuning --------------------------------------------------- @@ -7377,7 +7378,7 @@ class GatewayRunner: if self._restart_requested or self._draining: count = self._running_agent_count() if count: - return f"⏳ Draining {count} active agent(s) before restart..." + return t("gateway.draining", count=count) return EphemeralReply("⏳ Gateway restart already in progress...") # Save the requester's routing info so the new gateway process can @@ -7429,7 +7430,7 @@ class GatewayRunner: else: self.request_restart(detached=True, via_service=False) if active_agents: - return f"⏳ Draining {active_agents} active agent(s) before restart..." + return t("gateway.draining", count=active_agents) return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.") def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool: @@ -8099,7 +8100,7 @@ class GatewayRunner: if lower in ("clear", "stop", "done"): had = mgr.has_goal() mgr.clear() - return "✓ Goal cleared." if had else "No active goal." + return t("gateway.goal_cleared") if had else t("gateway.no_active_goal") # Otherwise — treat the remaining text as the new goal. try: @@ -9317,7 +9318,7 @@ class GatewayRunner: try: user_config: dict = _load_gateway_config() except Exception as e: - return f"⚠️ Could not read config.yaml: {e}" + return t("gateway.config_read_failed", error=e) effective = resolve_footer_config(user_config, platform_key) @@ -9350,7 +9351,7 @@ class GatewayRunner: atomic_yaml_write(config_path, user_config) except Exception as e: logger.warning("Failed to save runtime_footer.enabled: %s", e) - return f"⚠️ Could not save config: {e}" + return t("gateway.config_save_failed", error=e) state = "ON" if new_state else "OFF" example = "" @@ -10788,7 +10789,7 @@ class GatewayRunner: if not has_blocking_approval(session_key): if session_key in self._pending_approvals: self._pending_approvals.pop(session_key) - return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again." + return t("gateway.approval_expired") return "No pending command to approve." # Parse args: support "all", "all session", "all always", "session", "always" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8cf33b90fe3e..6ca56422e2fc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -781,6 +781,11 @@ DEFAULT_CONFIG = { "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", + # UI language for static user-facing messages (approval prompts, a + # handful of gateway slash-command replies). Does NOT affect agent + # responses, log lines, tool outputs, or slash-command descriptions. + # Supported: en, zh, ja, de, es. Unknown values fall back to en. + "language": "en", # TUI busy indicator style: kaomoji (default), emoji, unicode (braille # spinner), or ascii. Live-swappable via `/indicator