Merge remote-tracking branch 'origin/main' into feat/keyless-tavily-firecrawl-failover

This commit is contained in:
Teknium
2026-08-19 23:04:36 -07:00
41 changed files with 1821 additions and 149 deletions
+3
View File
@@ -1885,6 +1885,7 @@ class ContextCompressor(ContextEngine):
self._cooldown_persist_failed = False
self._last_summary_error = None
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
@@ -2167,6 +2168,7 @@ class ContextCompressor(ContextEngine):
self._summary_failure_cooldown_until = 0.0
self._cooldown_persist_failed = False
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self._context_probed = False
self._context_probe_persistable = False
self.last_real_prompt_tokens = 0
@@ -6927,6 +6929,7 @@ This compaction should PRIORITISE preserving all information related to the focu
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self._last_compression_made_progress = False
# NOTE: do NOT reset _last_summary_auth_failure or
# _last_summary_network_failure here. These flags are set by
+11
View File
@@ -3395,6 +3395,17 @@ def compress_context(
f"{_rough_in:,}",
f"{_rough_out:,}",
)
# Flag the refusal on the compressor state so manual
# /compress feedback can report it honestly. Without this,
# the CLI compared the returned list against its pre-call
# snapshot, saw a difference (durable-snapshot adoption can
# legitimately change the count), and printed
# "✅ Compressed: 8 → 14 messages" directly under the
# refusal warning (Aug 2026 full-surface CLI QA sweep).
try:
agent.context_compressor._last_compress_refused_would_grow = True
except Exception:
pass
try:
agent._emit_warning(
"⚠️ Compression refused: the generated summary "
+20 -2
View File
@@ -53,6 +53,11 @@ def summarize_manual_compression(
compression_state is not None
and getattr(compression_state, "_last_compress_aborted", False) is True
)
refused_would_grow = (
compression_state is not None
and getattr(compression_state, "_last_compress_refused_would_grow", False)
is True
)
fallback_used = (
compression_state is not None
and getattr(compression_state, "_last_summary_fallback_used", False) is True
@@ -65,7 +70,12 @@ def summarize_manual_compression(
if not isinstance(failure_reason, str) or not failure_reason.strip():
failure_reason = None
if aborted:
if refused_would_grow:
headline = (
f"Compression refused (summary would grow the conversation): "
f"{before_count} messages preserved"
)
elif aborted:
headline = f"Compression aborted: {before_count} messages preserved"
elif fallback_used:
headline = (
@@ -78,6 +88,8 @@ def summarize_manual_compression(
if noop and after_tokens == before_tokens:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
elif refused_would_grow:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
else:
token_line = (
f"Approx request size: ~{before_tokens:,} → "
@@ -85,7 +97,12 @@ def summarize_manual_compression(
)
note = None
if aborted:
if refused_would_grow:
note = (
"The generated summary was larger than what it would replace; "
"no messages were removed."
)
elif aborted:
note = "Summary generation failed; no messages were removed."
elif fallback_used:
dropped_count = getattr(
@@ -113,6 +130,7 @@ def summarize_manual_compression(
return {
"noop": noop,
"aborted": aborted,
"refused_would_grow": refused_would_grow,
"fallback_used": fallback_used,
"headline": headline,
"token_line": token_line,
+15
View File
@@ -191,6 +191,21 @@ MEMORY_GUIDANCE = (
"workflows belong in skills, not memory."
)
USER_PROFILE_GUIDANCE = (
"You have a persistent user profile across sessions. Save durable facts about "
"the user with the memory tool (target='user'): name, role, preferences, "
"corrections, and communication style. The profile is injected into every turn, "
"so keep it compact and focused on facts that will still matter later.\n"
"The built-in memory notes store is disabled — write only to the user profile "
"(target='user'), never target='memory'.\n"
"Prioritize what reduces future user steering — the most valuable entry is one "
"that prevents the user from having to correct or remind you again.\n"
"Write entries as declarative facts, not instructions to yourself. "
"'User prefers concise responses' ✓ — 'Always respond concisely' ✗. "
"Imperative phrasing gets re-read as a directive in later sessions and can "
"cause repeated work or override the user's current request."
)
SESSION_SEARCH_GUIDANCE = (
"When the user references something from a past conversation or you suspect "
"relevant cross-session context exists, use session_search to recall it before "
+12 -3
View File
@@ -36,8 +36,14 @@ Rules for call sites:
from __future__ import annotations
import re
from typing import Optional, Sequence
#: K3 slug detector — matches ``k3`` as a delimited token (``k3``,
#: ``k3-256k``, ``kimi-k3``, ``kimi-k3-cot``) without matching K2-era names
#: (``kimi-k2.6``). From #76427 by @ruizanthony.
_KIMI_K3_SLUG_RE = re.compile(r"(?:^|[^a-z0-9])k3(?:[^a-z0-9]|$)")
# Canonical low→high ordering used for nearest-level clamping. Superset of
# hermes_constants.VALID_REASONING_EFFORTS ("none" included so an explicit
# disable can be clamped too when a provider publishes it as a level).
@@ -124,11 +130,14 @@ SOLAR_EFFORTS: tuple[str, ...] = ("low", "medium", "high")
def kimi_supported_efforts(model: Optional[str]) -> tuple[str, ...]:
"""Supported effort set for a Moonshot/Kimi model slug.
K3 is served as the bare slug ``k3`` and the ``kimi-k3*`` aliases; its
documented set is low/high/max. Everything earlier speaks low/medium/high.
K3 is served as the bare slug ``k3``, plan variants like ``k3-256k``,
and the ``kimi-k3*`` aliases; its documented set is low/high/max.
Everything earlier speaks low/medium/high. Boundary-matched so K2-era
names (``kimi-k2.6``) never match (detection regex from #76427 by
@ruizanthony).
"""
m = (model or "").strip().lower().split("/")[-1]
if m == "k3" or m.startswith("kimi-k3"):
if _KIMI_K3_SLUG_RE.search(m):
return KIMI_K3_EFFORTS
return KIMI_K2_EFFORTS
+15 -1
View File
@@ -38,6 +38,7 @@ from agent.prompt_builder import (
HERMES_AGENT_HELP_GUIDANCE,
KANBAN_GUIDANCE,
MEMORY_GUIDANCE,
USER_PROFILE_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS,
@@ -415,8 +416,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
# MEMORY_GUIDANCE instructs the model to save facts to the built-in
# MEMORY.md/USER.md stores. With both disabled in config no store is built,
# so the guidance would steer the model at a tool whose every call returns
# "Memory is not available". Defaults to True for the rare code paths that
# build an agent view without going through agent_init.
# When only the user profile store is enabled, the narrower
# USER_PROFILE_GUIDANCE is injected instead — the full block instructs the
# model to write notes to a MEMORY.md store that does not exist.
_mem_enabled = getattr(agent, "_memory_enabled", True)
_profile_enabled = getattr(agent, "_user_profile_enabled", True)
if "memory" in agent.valid_tool_names:
tool_guidance.append(MEMORY_GUIDANCE)
if _mem_enabled:
tool_guidance.append(MEMORY_GUIDANCE)
elif _profile_enabled:
tool_guidance.append(USER_PROFILE_GUIDANCE)
if "session_search" in agent.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE)
if "skill_manage" in agent.valid_tool_names:
+6
View File
@@ -151,6 +151,12 @@ Notes:
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
- Polymorph with `asChild` when the button must render as a link/Slot.
## Badges — one component
`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`,
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
## Form controls
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
+1 -3
View File
@@ -4886,9 +4886,7 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) {
parsed,
{
method: 'GET',
headers: options.bearer
? { Authorization: `Bearer ${options.bearer}` }
: { 'X-Hermes-Session-Token': token }
headers: options.bearer ? { Authorization: `Bearer ${options.bearer}` } : { 'X-Hermes-Session-Token': token }
},
res => {
// Headers arrived — the connection phase is done. Drop the idle timeout
@@ -5,9 +5,11 @@ import { useLocation, useNavigate } from 'react-router'
import { hudTargetSessionId } from '@/app/hud/handoff'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { triggerHaptic } from '@/lib/haptics'
import { formatModifierToken } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
@@ -15,11 +17,13 @@ import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleHud } from '@/store/hud'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import { $unreadSessionCount } from '@/store/session-dot-state'
import { appViewForPath, isOverlayView } from '../routes'
@@ -43,6 +47,8 @@ export interface TitlebarTool {
onSelect?: (event?: MouseEvent) => void
/** Keybind action id — when set, the tooltip shows the label + keybind hint. */
actionId?: string
/** Overlay count on the glyph (unread sessions). Hidden when 0/undefined. */
badge?: number
title?: string
to?: string
}
@@ -79,6 +85,24 @@ function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
)
}
/** Overlay count on a titlebar glyph. Hidden when count is 0/undefined. */
function withCountBadge(icon: ReactNode, count: number | undefined): ReactNode {
if (!count) {
return icon
}
return (
<span className="relative inline-flex">
{icon}
<span className="pointer-events-none absolute -top-2.5 -right-1.5 z-1">
<Badge aria-hidden size="overlay" variant="solid">
{compactNumber(count)}
</Badge>
</span>
</span>
)
}
/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the
* layout button morphs into its reset form while the modifier is down). */
function useModifierHeld(): boolean {
@@ -109,7 +133,11 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const sidebarOpen = useStore($sidebarOpen)
const unreadCount = useStore($unreadSessionCount)
const unreadBadge = unreadCount > 0 ? unreadCount : undefined
const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : ''
const toggleHaptics = () => {
if (!hapticsMuted) {
@@ -130,13 +158,16 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
// show/hide affordances.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar
const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar
const leftToolbarTools: TitlebarTool[] = [
{
actionId: 'view.toggleSidebar',
badge: panesFlipped ? undefined : unreadBadge,
icon: <TitlebarIcon name="layout-sidebar-left" />,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
label: `${leftLabel}${panesFlipped ? '' : unreadHint}`,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
@@ -157,9 +188,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = {
actionId: 'view.toggleRightSidebar',
badge: panesFlipped ? unreadBadge : undefined,
icon: <TitlebarIcon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
label: `${rightLabel}${panesFlipped ? unreadHint : ''}`,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
@@ -306,7 +338,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
rel="noreferrer"
target="_blank"
>
{tool.icon}
{withCountBadge(tool.icon, tool.badge)}
</a>
</Button>
</Tip>
@@ -332,7 +364,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
type="button"
variant="ghost"
>
{tool.icon}
{withCountBadge(tool.icon, tool.badge)}
</Button>
</Tip>
)
+5 -2
View File
@@ -15,11 +15,14 @@ const badgeVariants = cva(
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
destructive: 'bg-destructive/10 text-destructive',
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground'
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground',
// Solid fill — icon-corner counts (titlebar unread, etc.).
solid: 'bg-primary text-primary-foreground'
},
size: {
default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3',
xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5'
xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5',
overlay: 'h-2 min-w-2 justify-center rounded-[2px] px-px text-[7px] font-semibold tabular-nums'
}
},
defaultVariants: { variant: 'default', size: 'default' }
+1
View File
@@ -181,6 +181,7 @@ export const ar = defineLocale({
swapSidebarSides: 'تبديل جانبي الأشرطة',
hideRightSidebar: 'إخفاء الشريط الأيمن',
showRightSidebar: 'إظهار الشريط الأيمن',
unreadSessions: count => (count === 1 ? 'جلسة واحدة غير مقروءة' : `${count} جلسات غير مقروءة`),
muteHaptics: 'كتم الاهتزازات',
unmuteHaptics: 'تفعيل الاهتزازات',
openSettings: 'فتح الإعدادات',
+1
View File
@@ -211,6 +211,7 @@ export const en: Translations = {
swapSidebarSides: 'Swap sidebar sides',
hideRightSidebar: 'Hide right sidebar',
showRightSidebar: 'Show right sidebar',
unreadSessions: count => (count === 1 ? '1 unread session' : `${count} unread sessions`),
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings',
+1
View File
@@ -212,6 +212,7 @@ export const ja = defineLocale({
swapSidebarSides: 'サイドバーの向きを切り替え',
hideRightSidebar: '右サイドバーを非表示',
showRightSidebar: '右サイドバーを表示',
unreadSessions: count => (count === 1 ? '未読セッション 1 件' : `未読セッション ${count} 件`),
muteHaptics: '触覚フィードバックをオフ',
unmuteHaptics: '触覚フィードバックをオン',
openSettings: '設定を開く',
+1
View File
@@ -253,6 +253,7 @@ export interface Translations {
swapSidebarSides: string
hideRightSidebar: string
showRightSidebar: string
unreadSessions: (count: number) => string
muteHaptics: string
unmuteHaptics: string
openSettings: string
+1
View File
@@ -206,6 +206,7 @@ export const zhHant = defineLocale({
swapSidebarSides: '交換側邊欄位置',
hideRightSidebar: '隱藏右側邊欄',
showRightSidebar: '顯示右側邊欄',
unreadSessions: count => (count === 1 ? '1 個未讀工作階段' : `${count} 個未讀工作階段`),
muteHaptics: '靜音觸感回饋',
unmuteHaptics: '開啟觸感回饋',
openSettings: '開啟設定',
+1
View File
@@ -206,6 +206,7 @@ export const zh: Translations = {
swapSidebarSides: '交换侧边栏位置',
hideRightSidebar: '隐藏右侧栏',
showRightSidebar: '显示右侧栏',
unreadSessions: count => (count === 1 ? '1 个未读会话' : `${count} 个未读会话`),
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置',
+2 -2
View File
@@ -43,8 +43,8 @@ describe('normalizeSvgSize', () => {
})
it('leaves an explicit pixel height when only width is a percentage', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="70" viewBox="0 0 500 200"><rect/></svg>'
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="70" viewBox="0 0 500 200"><rect/></svg>'
const out = normalizeSvgSize(svg)
expect(out).toContain('width="500"')
+268 -3
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NO_PROJECT_ID, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { $sidebarAgentsGrouped, setSidebarAgentsGrouped } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { $activeGatewayProfile, setShowAllProfiles } from '@/store/profile'
import { $currentCwd, $selectedStoredSessionId, $sessions, applyConfiguredDefaultProjectDir } from '@/store/session'
import {
@@ -21,6 +21,7 @@ import {
endSessionMutation,
enterProject,
exitProjectScope,
fetchProjectSessions,
openProjectCreate,
pickProjectFolder,
projectIdForCwd,
@@ -63,6 +64,7 @@ vi.mock('@/lib/desktop-git', async importOriginal => ({
vi.mock('@/hermes', () => ({
getHermesConfig: vi.fn(),
getProfiles: vi.fn(),
hermesApi: vi.fn(),
setApiRequestProfile: vi.fn(),
STARTUP_REQUEST_TIMEOUT_MS: 1000
}))
@@ -84,6 +86,16 @@ const getHermesConfig = vi.mocked(hermes.getHermesConfig)
const notifications = await import('@/store/notifications')
const notify = vi.mocked(notifications.notify)
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
describe('project scope', () => {
beforeEach(() => {
window.localStorage.clear()
@@ -118,6 +130,50 @@ describe('project scope', () => {
})
})
describe('projects RPC profile forwarding', () => {
beforeEach(() => {
vi.clearAllMocks()
$activeGatewayProfile.set('default')
$activeProjectId.set(null)
$projectTree.set([])
setShowAllProfiles(false)
})
it('forwards the normalized active profile to project read RPCs', async () => {
const request = vi.fn(async () => ({ active_id: null, projects: [], scoped_session_ids: [] }))
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
$activeGatewayProfile.set(' coder ')
await refreshProjects()
await refreshProjectTree()
await fetchProjectSessions('p_123')
expect(request).toHaveBeenNthCalledWith(1, 'projects.list', { profile: 'coder' })
expect(request).toHaveBeenNthCalledWith(2, 'projects.tree', { preview_limit: 3, profile: 'coder' })
expect(request).toHaveBeenNthCalledWith(3, 'projects.project_sessions', {
profile: 'coder',
project_id: 'p_123'
})
})
it('skips project reads in the all-profiles view rather than forwarding its sentinel', async () => {
const request = vi.fn()
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
setShowAllProfiles(true)
await refreshProjects()
await refreshProjectTree()
await fetchProjectSessions('p_123')
expect(request).not.toHaveBeenCalled()
setShowAllProfiles(false)
})
})
describe('resolveNewSessionCwd', () => {
beforeEach(() => {
$projectScope.set(ALL_PROJECTS)
@@ -478,6 +534,7 @@ describe('repository discovery policy', () => {
expect(scanRepos).not.toHaveBeenCalled()
expect(request).toHaveBeenCalledWith('projects.record_repos', {
discovery_policy: { enabled: false, exclude_paths: [], roots: [] },
profile: 'default',
repos: []
})
})
@@ -513,24 +570,154 @@ describe('repository discovery policy', () => {
exclude_paths: ['/work/vendor'],
roots: ['/work']
},
profile: 'default',
repos: [{ label: 'repo', root: '/work/repo' }]
})
})
it('does not scan the local filesystem for remote connections', async () => {
it('does not scan the local filesystem for remote connections but still refreshes the project tree', async () => {
isDesktopFsRemoteMode.mockReturnValue(true)
const scanRepos = vi.fn()
desktopGit.mockReturnValue({ scanRepos } as never)
const request = vi.fn(async (method: string) =>
method === 'projects.tree'
? { active_id: null, projects: [], scoped_session_ids: [] }
: { accepted: false, repos: [] }
)
gatewayWith(request)
$projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree])
await scanAndRecordRepos(true)
expect(scanRepos).not.toHaveBeenCalled()
expect(getHermesConfig).not.toHaveBeenCalled()
// The desktop can't crawl the remote host's filesystem, so it asks the
// host to scan its own discovery roots (`projects.discover_repos` with
// `scan: true`) — repos with zero Hermes sessions must still surface —
// then refreshes the tree to pick up the merged list. Regression for
// #81723: the sidebar used to go silent in remote mode and never
// refresh again.
expect(request).toHaveBeenCalledWith('projects.discover_repos', { profile: 'default', scan: true })
expect(request).toHaveBeenCalledWith(
'projects.tree',
expect.objectContaining({ preview_limit: expect.any(Number), profile: 'default' })
)
// A successful scan refreshes the tree (here to the empty list the mock
// tree returns), so a later discover-repos call replaces it instead of
// keeping the stale seed.
expect($projectTree.get()).toEqual([])
})
it('surfaces a reject from remote discover_repos without clearing the sidebar', async () => {
// Backend error (RPC `error` frame) rejects the request — the sidebar must
// keep its last known list and flag the failure, not go silently blank.
isDesktopFsRemoteMode.mockReturnValue(true)
desktopGit.mockReturnValue({ scanRepos: vi.fn() } as never)
const request = vi.fn(async (method: string) => {
if (method === 'projects.discover_repos') {
throw new Error('discover_repos failed')
}
if (method === 'projects.tree') {
return { active_id: null, projects: [], scoped_session_ids: [] }
}
return { accepted: false, repos: [] }
})
gatewayWith(request)
$projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree])
await scanAndRecordRepos(true)
// The tree refresh must NOT run against a failed remote scan ...
expect(request).not.toHaveBeenCalledWith(
'projects.tree',
expect.objectContaining({ preview_limit: expect.any(Number) })
)
// ... the cached tree is preserved ...
expect($projectTree.get()).toEqual([
{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 }
])
})
it('does not treat an error-shaped discover_repos response as a successful refresh', async () => {
// A resolved-but-error-shaped body (`{accepted:false}` / no `repos`) must
// be treated as a failure: keep the old list rather than refreshing into
// the silent, empty sidebar of #81723.
isDesktopFsRemoteMode.mockReturnValue(true)
desktopGit.mockReturnValue({ scanRepos: vi.fn() } as never)
const request = vi.fn(async (method: string) =>
method === 'projects.tree'
? { active_id: null, projects: [], scoped_session_ids: [] }
: { accepted: false }
)
gatewayWith(request)
$projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree])
await scanAndRecordRepos(true)
expect(request).not.toHaveBeenCalledWith(
'projects.tree',
expect.objectContaining({ preview_limit: expect.any(Number) })
)
expect($projectTree.get()).toEqual([
{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 }
])
})
it('records repos under the profile the scan started with, not one focused mid-scan', async () => {
const { promise: scanResult, resolve: resolveScan } = deferred<Array<{ label: string; root: string }>>()
const { promise: scanStarted, resolve: markScanStarted } = deferred<void>()
const request = vi.fn(async (method: string) =>
method === 'projects.tree'
? {
active_id: null,
projects: [{ id: 'p_lured', label: 'Lured', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
}
: { accepted: true, repos: [] }
)
gatewayWith(request)
const scanRepos = vi.fn(() => {
markScanStarted()
return scanResult
})
desktopGit.mockReturnValue({ scanRepos } as never)
getHermesConfig.mockResolvedValue({
desktop: {
repo_scan_enabled: true,
repo_scan_exclude_paths: [],
repo_scan_roots: ['/work']
}
})
$activeGatewayProfile.set('launch')
$projectTree.set([])
const pending = scanAndRecordRepos()
await scanStarted
$activeGatewayProfile.set('coder')
resolveScan([{ label: 'repo', root: '/work/repo' }])
await pending
expect(request).toHaveBeenCalledWith('projects.record_repos', {
discovery_policy: { enabled: true, exclude_paths: [], roots: ['/work'] },
profile: 'launch',
repos: [{ label: 'repo', root: '/work/repo' }]
})
expect(request).not.toHaveBeenCalledWith('projects.record_repos', expect.objectContaining({ profile: 'coder' }))
expect($projectTree.get()).toEqual([])
})
})
describe('project tree profile isolation', () => {
it('does not publish a late response from the previous profile', async () => {
beforeEach(() => {
setShowAllProfiles(false)
$activeGatewayProfile.set('default')
$projects.set([])
$projectTree.set([])
})
it('does not publish a late response from the previous gateway', async () => {
let resolveA: ((value: unknown) => void) | undefined
const responseA = new Promise(resolve => {
@@ -566,6 +753,84 @@ describe('project tree profile isolation', () => {
expect($projectTree.get().map(project => project.id)).toEqual(['profile-b'])
})
it('does not publish a late projects.list response from the previous profile', async () => {
const { promise: defaultResponse, resolve: resolveDefault } = deferred<unknown>()
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
params.profile === 'default'
? defaultResponse
: Promise.resolve({
active_id: null,
projects: [{ id: 'profile-b', label: 'Profile B' }]
})
)
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
const pendingDefault = refreshProjects()
$activeGatewayProfile.set('profile-b')
await refreshProjects()
resolveDefault({
active_id: null,
projects: [{ id: 'profile-a', label: 'Profile A' }]
})
await pendingDefault
expect($projects.get().map(project => project.id)).toEqual(['profile-b'])
})
it('does not publish a late projects.tree response from the previous profile', async () => {
const { promise: defaultResponse, resolve: resolveDefault } = deferred<unknown>()
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
params.profile === 'default'
? defaultResponse
: Promise.resolve({
active_id: null,
projects: [{ id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
})
)
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
const pendingDefault = refreshProjectTree()
$activeGatewayProfile.set('profile-b')
await refreshProjectTree()
resolveDefault({
active_id: null,
projects: [{ id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
})
await pendingDefault
expect($projectTree.get().map(project => project.id)).toEqual(['profile-b'])
})
it('drops a late hydrated-project response from the previous profile', async () => {
const { promise: defaultResponse, resolve: resolveDefault } = deferred<unknown>()
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
params.profile === 'default'
? defaultResponse
: Promise.resolve({
project: { id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 }
})
)
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
const pendingDefault = fetchProjectSessions('p_123')
$activeGatewayProfile.set('profile-b')
const profileB = await fetchProjectSessions('p_123')
resolveDefault({
project: { id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 }
})
expect(profileB?.id).toBe('profile-b')
await expect(pendingDefault).resolves.toBeNull()
})
})
describe('tombstone pruning', () => {
+149 -51
View File
@@ -16,7 +16,13 @@ import { persistentAtom } from '@/lib/persisted'
import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
import { setSidebarAgentsGrouped } from '@/store/layout'
import { notify } from '@/store/notifications'
import { $activeGatewayProfile, $profileScope, ALL_PROFILES, requestFreshSession } from '@/store/profile'
import {
$activeGatewayProfile,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
requestFreshSession
} from '@/store/profile'
import {
$selectedStoredSessionId,
$sessions,
@@ -341,6 +347,23 @@ async function gatewayRequest<T>(method: string, params: Record<string, unknown>
return gateway.request<T>(method, params)
}
function projectProfile(): null | string {
const profile = normalizeProfileKey($activeGatewayProfile.get())
return $profileScope.get() === ALL_PROFILES || profile === ALL_PROFILES ? null : profile
}
function projectParams(
params: Record<string, unknown> = {},
profile: null | string = projectProfile()
): Record<string, unknown> {
if (!profile) {
throw new Error('Projects are unavailable while viewing all profiles')
}
return { ...params, profile }
}
async function gatewayRequestOn<T>(
gateway: HermesGateway,
method: string,
@@ -354,15 +377,24 @@ interface ActiveProjectsContext {
profile: string
}
function stillOnProjectsContext(context: ActiveProjectsContext): boolean {
return activeGateway() === context.gateway && projectProfile() === context.profile
}
async function activeProjectsContext(): Promise<ActiveProjectsContext> {
const profile = $activeGatewayProfile.get() || 'default'
const profile = projectProfile()
if (!profile) {
throw new Error('Projects are unavailable while viewing all profiles')
}
let gateway = activeGateway()
if (!gateway || gateway.connectionState !== 'open') {
gateway = await ensureActiveGatewayOpen()
}
if (!gateway || gateway !== activeGateway() || profile !== ($activeGatewayProfile.get() || 'default')) {
if (!gateway || gateway !== activeGateway() || profile !== projectProfile()) {
throw new Error('Active Hermes profile changed while connecting')
}
@@ -380,20 +412,24 @@ let projectsRefreshGeneration = 0
// not up yet) leaves the cached atoms intact so the sidebar doesn't flicker.
export async function refreshProjects(): Promise<void> {
const generation = ++projectsRefreshGeneration
let gateway: HermesGateway | null = null
let context: ActiveProjectsContext | null = null
try {
gateway = (await activeProjectsContext()).gateway
const payload = await gatewayRequestOn<ProjectsPayload>(gateway, 'projects.list')
context = await activeProjectsContext()
const payload = await gatewayRequestOn<ProjectsPayload>(
context.gateway,
'projects.list',
projectParams({}, context.profile)
)
if (generation !== projectsRefreshGeneration || activeGateway() !== gateway) {
if (generation !== projectsRefreshGeneration || !stillOnProjectsContext(context)) {
return
}
applyPayload(payload)
markProjectsRpcSuccess()
} catch (err) {
if (generation === projectsRefreshGeneration && (!gateway || activeGateway() === gateway)) {
if (context && generation === projectsRefreshGeneration && stillOnProjectsContext(context)) {
markProjectsRpcFailure(err)
}
// Backend may not be ready; keep the last known list.
@@ -433,26 +469,29 @@ function applyProjectTreePayload(res: ProjectTreePayload): void {
}
}
async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<void> {
const generation = ++projectTreeRefreshGeneration
const { gateway, profile } = context
if (activeGateway() === gateway) {
$projectTreeLoading.set(true)
}
try {
const res = await gatewayRequestOn<ProjectTreePayload>(gateway, 'projects.tree', {
preview_limit: PROJECT_TREE_PREVIEW_LIMIT
})
const res = await gatewayRequestOn<ProjectTreePayload>(
gateway,
'projects.tree',
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
)
if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) {
if (generation !== projectTreeRefreshGeneration || !stillOnProjectsContext(context)) {
return
}
applyProjectTreePayload(res)
markProjectsRpcSuccess()
} catch (err) {
if (activeGateway() === gateway) {
if (generation === projectTreeRefreshGeneration && stillOnProjectsContext(context)) {
markProjectsRpcFailure(err)
}
} finally {
@@ -473,8 +512,7 @@ export async function refreshProjectTree(): Promise<void> {
}
try {
const { gateway } = await activeProjectsContext()
await refreshProjectTreeOn(gateway)
await refreshProjectTreeOn(await activeProjectsContext())
} catch {
// Backend may not be ready; keep the last known tree.
}
@@ -514,11 +552,22 @@ async function refreshProjectTreeAcrossProfiles(): Promise<void> {
// Fully hydrated lanes (repo -> lane -> session rows) for one project, fetched
// when the user enters it. Same backend grouping as `projects.tree`, so ids and
// membership match exactly.
let projectSessionsRefreshGeneration = 0
export async function fetchProjectSessions(projectId: string): Promise<SidebarProjectTree | null> {
const generation = ++projectSessionsRefreshGeneration
try {
const res = await gatewayRequest<{ project: SidebarProjectTree | null }>('projects.project_sessions', {
project_id: projectId
})
const context = await activeProjectsContext()
const res = await gatewayRequestOn<{ project: SidebarProjectTree | null }>(
context.gateway,
'projects.project_sessions',
projectParams({ project_id: projectId }, context.profile)
)
if (generation !== projectSessionsRefreshGeneration || !stillOnProjectsContext(context)) {
return null
}
return res.project ?? null
} catch {
@@ -616,6 +665,41 @@ $gateway.subscribe(syncReposScanning)
export async function scanAndRecordRepos(force = false): Promise<void> {
if (isDesktopFsRemoteMode()) {
// On a remote backend the desktop can't crawl the host filesystem.
// Ask the host to scan its own discovery roots (`projects.discover_repos`
// with `scan: true` — added in #81723) so repos with zero Hermes
// sessions still surface, then refresh the tree so the sidebar picks up
// the merged session-derived + scanned list.
try {
const context = await activeProjectsContext()
const discovered = await gatewayRequestOn<{
repos?: unknown
discovery_policy?: unknown
}>(context.gateway, 'projects.discover_repos', projectParams({ scan: true }, context.profile))
// A resolved response must be the discovery shape. Anything else (an
// error/`accepted:false` body, or a backend that ignored `scan` and
// returned no repo list) means the scan didn't happen — bail out without
// touching the tree so the sidebar keeps its last known list instead of
// being blanked back to the silent, unpopulated state of #81723.
if (discovered?.repos === undefined) {
markProjectsRpcFailure(new Error('projects.discover_repos returned no repo list'))
return
}
// Remote scan succeeded: refresh the tree so the merged session-derived +
// scanned list surfaces. Skip if the user moved on — a stale scan must
// not publish into the newly focused profile.
if (stillOnProjectsContext(context)) {
await refreshProjectTreeOn(context)
}
} catch (err) {
// Surface the failure (stale backend, RPC error, gateway drop) instead
// of swallowing it: a silent return is exactly the "sidebar goes quiet"
// symptom `scan:true` was meant to fix (#81723). Keep the old list and
// let the sidebar show the error/absent state.
markProjectsRpcFailure(err)
}
return
}
@@ -649,10 +733,11 @@ export async function scanAndRecordRepos(force = false): Promise<void> {
state.runningSignature = signature
if (!policy.enabled) {
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
discovery_policy: policy,
repos: []
})
await gatewayRequestOn(
context.gateway,
'projects.record_repos',
projectParams({ discovery_policy: policy, repos: [] }, context.profile)
)
} else {
scanningGatewayGenerations.set(context.gateway, generation)
syncReposScanning()
@@ -666,10 +751,11 @@ export async function scanAndRecordRepos(force = false): Promise<void> {
return
}
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
discovery_policy: policy,
repos
})
await gatewayRequestOn(
context.gateway,
'projects.record_repos',
projectParams({ discovery_policy: policy, repos }, context.profile)
)
}
if (state.generation !== generation) {
@@ -677,10 +763,13 @@ export async function scanAndRecordRepos(force = false): Promise<void> {
}
state.completedSignature = signature
// Scope-aware on purpose: the scan records into one profile, but folding
// its result back in through the active scope keeps an all-profiles tree
// from being overwritten by the scanned profile's own.
await refreshProjectTree()
// Completion refresh only when the focused profile still matches the one
// the scan was captured under. refreshProjectTree() re-derives the current
// context, so skipping on mismatch keeps a stale scan from publishing into
// the newly focused profile.
if (stillOnProjectsContext(context)) {
await refreshProjectTree()
}
} catch {
state.completedSignature = undefined
} finally {
@@ -808,17 +897,20 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectI
let res: { project: ProjectInfo | null }
try {
res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', {
name: input.name,
folders: input.folders ?? [],
primary_path: input.primaryPath,
slug: input.slug,
description: input.description,
icon: input.icon,
color: input.color,
board_slug: input.boardSlug,
use: input.use ?? false
})
res = await gatewayRequest<{ project: ProjectInfo | null }>(
'projects.create',
projectParams({
name: input.name,
folders: input.folders ?? [],
primary_path: input.primaryPath,
slug: input.slug,
description: input.description,
icon: input.icon,
color: input.color,
board_slug: input.boardSlug,
use: input.use ?? false
})
)
} catch (err) {
if (isMissingRpcMethod(err)) {
$projectsRpcAvailable.set(false)
@@ -891,12 +983,15 @@ export async function updateProject(
// Backend treats null/undefined as "leave unchanged"; "" clears (stores NULL).
// Map explicit null → "" so "no color"/"no icon" actually clear.
await persistOrRollback(snap, () =>
gatewayRequest('projects.update', {
id,
...patch,
...(patch.color === null && { color: '' }),
...(patch.icon === null && { icon: '' })
})
gatewayRequest(
'projects.update',
projectParams({
id,
...patch,
...(patch.color === null && { color: '' }),
...(patch.icon === null && { icon: '' })
})
)
)
}
@@ -967,7 +1062,10 @@ export async function addProjectFolder(
}
await persistOrRollback(snap, () =>
gatewayRequest('projects.add_folder', { id, path, label: opts.label, is_primary: opts.isPrimary ?? false })
gatewayRequest(
'projects.add_folder',
projectParams({ id, path, label: opts.label, is_primary: opts.isPrimary ?? false })
)
)
reconcileProjects()
}
@@ -1010,13 +1108,13 @@ export async function deleteProject(id: string): Promise<void> {
}
await persistOrRollback(snap, async () => {
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', { id }))
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', projectParams({ id })))
})
void refreshProjectTree()
}
export async function setActiveProject(id: null | string): Promise<void> {
const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', { id })
const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', projectParams({ id }))
$activeProjectId.set(res.active_id ?? null)
}
@@ -4,7 +4,13 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import type { SessionInfo } from '@/types/hermes'
import { $sessions, $unreadFinishedSessionIds, setSessions } from './session'
import { $delegatingSessionIds, $sessionDotStateById, hasLiveTurn, showsRunningArc } from './session-dot-state'
import {
$delegatingSessionIds,
$sessionDotStateById,
hasLiveTurn,
showsRunningArc,
unreadSessionCount
} from './session-dot-state'
import { clearAllSessionStates, publishSessionState } from './session-states'
import { $unreadWriteGuard } from './session-unread-remote'
import { $subagentsBySession, type SubagentProgress } from './subagents'
@@ -148,3 +154,20 @@ describe('persisted unread (backend watermark)', () => {
expect($sessionDotStateById.get()['s1'] ?? 'idle').not.toBe('unread')
})
})
describe('unreadSessionCount', () => {
it('counts listed unread rows and skips archived', () => {
expect(
unreadSessionCount({ a: 'unread', b: 'working', c: 'unread' }, [
{ id: 'a' },
{ id: 'b' },
{ archived: true, id: 'c' },
{ id: 'missing' }
])
).toBe(1)
})
it('does not count alias keys that are not listed rows', () => {
expect(unreadSessionCount({ tip: 'unread', root: 'unread' }, [{ id: 'tip' }])).toBe(1)
})
})
+25 -1
View File
@@ -28,7 +28,7 @@ import { computed } from 'nanostores'
import { stableArray, stableRecord } from '@/lib/stable-array'
import { $backgroundRunningSessionIds } from './composer-status'
import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session'
import { $cronSessions, $messagingSessions, $sessions, $unreadFinishedSessionIds, lineageAliases } from './session'
import {
$attentionSessionIds,
$draftSessionIds,
@@ -182,3 +182,27 @@ export const $sessionDotStateById = computed(
return (dotStates = stableRecord(dotStates, next))
}
)
/** Listed, non-archived rows whose resolved status is unread. Alias keys in
* `$sessionDotStateById` are ignored unless they are themselves a listed row. */
export function unreadSessionCount(
byId: Readonly<Record<string, SessionDotState>>,
...lists: Array<readonly { archived?: boolean; id: string }[]>
): number {
let n = 0
for (const rows of lists) {
for (const row of rows) {
if (!row.archived && byId[row.id] === 'unread') {
n++
}
}
}
return n
}
export const $unreadSessionCount = computed(
[$sessionDotStateById, $sessions, $cronSessions, $messagingSessions],
(byId, sessions, cron, messaging) => unreadSessionCount(byId, sessions, cron, messaging)
)
+5 -1
View File
@@ -12970,7 +12970,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self.agent, "context_compressor", None
),
)
if summary.get("aborted") or summary.get("fallback_used"):
if (
summary.get("aborted")
or summary.get("fallback_used")
or summary.get("refused_would_grow")
):
icon = "⚠️"
else:
icon = "🗜️" if summary["noop"] else "✅"
+9 -1
View File
@@ -245,7 +245,15 @@ def _coerce_request_bool(value: Any, default: bool = False) -> bool:
_REQUEST_OPTION_MISSING = object()
_REASONING_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
# Full internal ladder + "none": the API server accepts what /reasoning and
# config.yaml accept (hermes_constants.VALID_REASONING_EFFORTS); wire-level
# clamping to each provider's vocabulary happens downstream in the
# transports/profiles via agent.reasoning_effort. Rejecting "max"/"ultra"
# here made API/browser clients second-class citizens of the ladder
# (#78216's api_server observation).
_REASONING_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
)
_RUNTIME_AGENT_OVERRIDE_KEYS = (
"api_key",
"base_url",
+12 -1
View File
@@ -88,8 +88,19 @@ class CLICommandsMixin:
args = filtered
if not args:
# List checkpoints
# List checkpoints — fall back to the cross-project view when the
# current directory has none (#10505, reapply of PR #10633 by
# @nightq). The Aug 2026 QA sweep hit this live: writes landed
# checkpoints under the session cwd (/tmp/qa-repo) while bare
# /rollback searched only TERMINAL_CWD's project and reported
# "No checkpoints found" despite fresh checkpoints existing.
checkpoints = mgr.list_checkpoints(cwd)
if not checkpoints:
all_checkpoints = mgr.list_all_checkpoints()
if all_checkpoints:
print(f" No checkpoints for {cwd} — showing all directories.")
print(format_checkpoint_list(all_checkpoints, "all directories"))
return
print(format_checkpoint_list(checkpoints, cwd))
return
+13
View File
@@ -2517,6 +2517,19 @@ class TelegramAdapter(BasePlatformAdapter):
return
if generation != self._polling_generation:
return
if not self._polling_progress_event.is_set():
# The first confirmed getUpdates round-trip of this generation
# resolves the "health pending getUpdates progress" line both
# reconnect paths end on. Without it the log stream for
# "reconnected and healthy" is byte-identical to "reconnected
# and hung" — a wedged long-poll is invisible until a user
# notices silence (#90504).
logger.info(
"[%s] Telegram polling confirmed healthy: getUpdates progressing "
"(generation %d)",
self.name,
generation,
)
self._polling_progress_event.set()
self._polling_network_error_count = 0
if generation == self._polling_conflict_recovery_generation:
@@ -0,0 +1,161 @@
"""Built-in memory disabled in config must leave no dead surface behind.
Setting ``memory.memory_enabled: false`` and ``memory.user_profile_enabled:
false`` stops ``agent_init`` from building a ``MemoryStore``, so the ``memory``
tool dispatches against ``store=None`` and every call comes back "Memory is not
available". Before the fix the tool stayed in the schema and MEMORY_GUIDANCE
stayed in the system prompt, so users running a third-party provider (Hindsight,
Mem0, …) paid for both on every API call with no way to drop them — listing
``memory`` under ``disabled_toolsets`` takes the provider's tools down too.
These tests exercise the real resolution chain (config on disk → check_fn →
``get_tool_definitions``) against a temp ``HERMES_HOME``, not mocks.
"""
import pytest
import yaml
from model_tools import get_tool_definitions
@pytest.fixture(autouse=True)
def _clear_caches():
"""check_fn results and tool definitions are both cached; config written by
a test only takes effect once those are dropped."""
from model_tools import _clear_tool_defs_cache
from tools.registry import invalidate_check_fn_cache
invalidate_check_fn_cache()
_clear_tool_defs_cache()
yield
invalidate_check_fn_cache()
_clear_tool_defs_cache()
def _write_memory_config(home, **memory_section):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
yaml.safe_dump({"memory": memory_section}), encoding="utf-8"
)
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _memory_tool_names():
tools = get_tool_definitions(enabled_toolsets=["memory"], quiet_mode=True)
return {tool["function"]["name"] for tool in tools}
class TestBuiltinMemoryToolAvailability:
def test_tool_hidden_when_both_stores_disabled(self, hermes_home):
_write_memory_config(
hermes_home, memory_enabled=False, user_profile_enabled=False
)
assert "memory" not in _memory_tool_names()
def test_tool_present_when_only_user_profile_enabled(self, hermes_home):
_write_memory_config(
hermes_home, memory_enabled=False, user_profile_enabled=True
)
assert "memory" in _memory_tool_names()
def test_tool_present_when_only_memory_enabled(self, hermes_home):
_write_memory_config(
hermes_home, memory_enabled=True, user_profile_enabled=False
)
assert "memory" in _memory_tool_names()
def test_tool_present_by_default(self, hermes_home):
"""No config file at all must not strip a working tool."""
assert "memory" in _memory_tool_names()
def test_unreadable_config_fails_open(self, hermes_home, monkeypatch):
"""A config read error must not silently remove the tool."""
from tools import memory_tool as memory_tool_module
def _boom():
raise RuntimeError("config unreadable")
monkeypatch.setattr(
"hermes_cli.config.load_config_readonly", _boom, raising=False
)
assert memory_tool_module.check_memory_requirements() is True
class TestExternalProviderSurvivesBuiltinDisable:
"""Dropping the built-in tool must not drop the external provider's tools.
``memory_provider_tools_enabled`` short-circuits on the built-in tool being
present, so hiding that tool moves the decision onto the toolset gate. The
provider must still be reachable for every way a caller can ask for memory.
"""
def test_provider_tools_enabled_when_memory_toolset_requested(self):
from agent.memory_manager import memory_provider_tools_enabled
assert memory_provider_tools_enabled(
["memory", "file"], None, memory_tool_present=False
)
def test_provider_tools_enabled_for_unrestricted_toolsets(self):
from agent.memory_manager import memory_provider_tools_enabled
assert memory_provider_tools_enabled(None, None, memory_tool_present=False)
def test_disabled_toolsets_still_takes_everything_down(self):
"""The heavy switch keeps its documented meaning."""
from agent.memory_manager import memory_provider_tools_enabled
assert not memory_provider_tools_enabled(
None, ["memory"], memory_tool_present=False
)
class TestInjectionEndToEnd:
"""The real ``inject_memory_provider_tools`` with no built-in memory tool."""
def test_provider_tools_injected_without_builtin_memory_tool(self):
from types import SimpleNamespace
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
from agent.memory_provider import MemoryProvider
class _Provider(MemoryProvider):
@property
def name(self):
return "fake_hindsight"
def is_available(self):
return True
def initialize(self, session_id, **kwargs):
pass
def get_tool_schemas(self):
return [
{
"name": "hindsight_retain",
"description": "retain",
"parameters": {"type": "object", "properties": {}},
}
]
manager = MemoryManager()
manager.add_provider(_Provider())
agent = SimpleNamespace(
_memory_manager=manager,
enabled_toolsets=["memory"],
disabled_toolsets=None,
tools=[],
valid_tool_names=set(),
)
added = inject_memory_provider_tools(agent)
assert added == 1
assert "hindsight_retain" in agent.valid_tool_names
@@ -0,0 +1,58 @@
"""Regression tests: manual /compress feedback for a would-grow refusal.
The anti-growth guard (#83339 / PR #86700) can refuse the commit AFTER the
compressor produced a candidate. The CLI's feedback previously compared the
returned list against its pre-call snapshot; durable-snapshot adoption can
legitimately change that list, so a REFUSED compression printed
"✅ Compressed: 8 → 14 messages" directly under the refusal warning
(Aug 2026 full-surface CLI QA sweep). The refusal must surface as its own
honest headline.
"""
from types import SimpleNamespace
from agent.manual_compression_feedback import summarize_manual_compression
def _msgs(n):
return [{"role": "user", "content": f"m{n_i}"} for n_i in range(n)]
def _state(**flags):
base = {
"_last_compress_aborted": False,
"_last_compress_refused_would_grow": False,
"_last_summary_fallback_used": False,
"_last_summary_error": None,
"_last_summary_dropped_count": 0,
}
base.update(flags)
return SimpleNamespace(**base)
class TestWouldGrowRefusalFeedback:
def test_refusal_reports_preserved_not_compressed(self):
summary = summarize_manual_compression(
_msgs(8), _msgs(14), 22025, 22453,
compression_state=_state(_last_compress_refused_would_grow=True),
)
assert summary["refused_would_grow"] is True
assert "refused" in summary["headline"].lower()
assert "→" not in summary["headline"] # never claims a message rewrite
assert "unchanged" in summary["token_line"]
assert "no messages were removed" in summary["note"]
def test_normal_compression_unchanged(self):
summary = summarize_manual_compression(
_msgs(10), _msgs(4), 30000, 12000, compression_state=_state(),
)
assert summary["refused_would_grow"] is False
assert summary["headline"].startswith("Compressed: 10 → 4")
def test_abort_still_wins_its_own_headline(self):
summary = summarize_manual_compression(
_msgs(6), _msgs(6), 9000, 9000,
compression_state=_state(_last_compress_aborted=True),
)
assert summary["aborted"] is True
assert summary["headline"].startswith("Compression aborted")
+4 -2
View File
@@ -112,13 +112,15 @@ class TestClampEffort:
class TestKimiVocabulary:
@pytest.mark.parametrize(
"model", ["k3", "kimi-k3", "kimi-k3-cot", "moonshotai/kimi-k3"]
"model",
["k3", "kimi-k3", "kimi-k3-cot", "moonshotai/kimi-k3", "k3-256k"],
)
def test_k3_slugs(self, model):
assert kimi_supported_efforts(model) is KIMI_K3_EFFORTS
@pytest.mark.parametrize(
"model", ["kimi-k2.6", "moonshotai/kimi-k2-0905", "kimi-latest", None]
"model",
["kimi-k2.6", "moonshotai/kimi-k2-0905", "kimi-latest", "mk3000", None],
)
def test_k2_era_slugs(self, model):
assert kimi_supported_efforts(model) is KIMI_K2_EFFORTS
@@ -0,0 +1,33 @@
"""API-server reasoning-effort request parsing accepts the full ladder.
_request_reasoning_config() previously whitelisted only none..xhigh, so an
API/browser client sending ``max`` or ``ultra`` (both valid /reasoning and
config.yaml levels) was silently ignored and the session ran at the default
effort. The server now accepts the full internal ladder; per-provider wire
clamping happens downstream in the transports/profiles via
agent.reasoning_effort (#78216's api_server observation).
"""
from gateway.platforms.api_server import _request_reasoning_config
from hermes_constants import VALID_REASONING_EFFORTS
class TestRequestReasoningFullLadder:
def test_every_configurable_level_is_accepted(self):
for level in VALID_REASONING_EFFORTS:
out = _request_reasoning_config({"reasoning_effort": level})
assert out == {"enabled": True, "effort": level}, level
def test_none_disables(self):
assert _request_reasoning_config({"reasoning_effort": "none"}) == {
"enabled": False
}
def test_structured_object_shape(self):
out = _request_reasoning_config(
{"reasoning": {"enabled": True, "effort": "ultra"}}
)
assert out == {"enabled": True, "effort": "ultra"}
def test_unknown_level_still_ignored(self):
assert _request_reasoning_config({"reasoning_effort": "turbo"}) is None
@@ -0,0 +1,107 @@
"""Regression tests for the Telegram polling health-confirmation log (#90504).
Both reconnect paths end on ``health pending getUpdates progress`` and
``_record_polling_progress`` used to complete silently, so the log stream for
"reconnected and healthy" was byte-identical to "reconnected and hung". The
first confirmed getUpdates round-trip of each generation now emits an INFO
line, turning the pending line into a resolvable pair whose *absence* after a
reconnect is a reliable hung-poll signature.
"""
import asyncio
import logging
import sys
from unittest.mock import MagicMock
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
telegram_mod = MagicMock()
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.constants.ChatType.GROUP = "group"
telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
telegram_mod.constants.ChatType.CHANNEL = "channel"
telegram_mod.constants.ChatType.PRIVATE = "private"
telegram_mod.error.NetworkError = type("NetworkError", (OSError,), {})
telegram_mod.error.TimedOut = type("TimedOut", (OSError,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
sys.modules.setdefault("telegram.error", telegram_mod.error)
_ensure_telegram_mock()
from gateway.config import Platform # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def _bare_adapter():
a = TelegramAdapter.__new__(TelegramAdapter)
a.platform = Platform.TELEGRAM
a._fatal_error_code = None
a._fatal_error_message = None
a._fatal_error_retryable = True
a._polling_teardown_started = False
a._polling_progress_accepting = True
a._polling_generation = 1
a._polling_progress_event = asyncio.Event()
a._polling_network_error_count = 2
a._polling_conflict_count = 3
a._polling_conflict_recovery_generation = None
a._send_path_degraded = True
return a
class TestPollingHealthConfirmation:
def test_first_progress_emits_confirmed_healthy(self, caplog):
a = _bare_adapter()
with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"):
a._record_polling_progress(1)
rendered = " | ".join(rec.getMessage() for rec in caplog.records)
assert "confirmed healthy" in rendered
assert "generation 1" in rendered
assert a._polling_progress_event.is_set()
def test_subsequent_progress_is_silent(self, caplog):
"""Only the FIRST round-trip of a generation logs — a quiet evening
must not spam one INFO per getUpdates poll."""
a = _bare_adapter()
a._record_polling_progress(1) # first — logs
with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"):
a._record_polling_progress(1) # second — silent
a._record_polling_progress(1) # third — silent
assert not [
rec for rec in caplog.records if "confirmed healthy" in rec.getMessage()
]
def test_new_generation_logs_again(self, caplog):
"""A reconnect starts a new generation with a fresh event; its first
progress must re-emit the confirmation so the pending line of THAT
reconnect also resolves."""
a = _bare_adapter()
a._record_polling_progress(1)
# reconnect: new generation, event reset, counters possibly nonzero
a._polling_generation = 2
a._polling_progress_event = asyncio.Event()
a._polling_network_error_count = 1
a._send_path_degraded = True
with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"):
a._record_polling_progress(2)
rendered = " | ".join(rec.getMessage() for rec in caplog.records)
assert "confirmed healthy" in rendered
assert "generation 2" in rendered
def test_stale_generation_progress_stays_silent(self, caplog):
"""Progress from an abandoned generation must neither log nor set the
current event (pre-existing guard, pinned here because the log line
must inherit the same generation-scoping)."""
a = _bare_adapter()
a._polling_generation = 2
a._polling_progress_event = asyncio.Event()
with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"):
a._record_polling_progress(1)
assert not [
rec for rec in caplog.records if "confirmed healthy" in rec.getMessage()
]
assert not a._polling_progress_event.is_set()
+36
View File
@@ -910,9 +910,45 @@ class TestBuildSystemPrompt:
def test_memory_guidance_when_memory_tool_loaded(self, agent_with_memory_tool):
from agent.prompt_builder import MEMORY_GUIDANCE
agent_with_memory_tool._memory_enabled = True
prompt = agent_with_memory_tool._build_system_prompt()
assert MEMORY_GUIDANCE in prompt
def test_no_memory_guidance_when_both_builtin_stores_disabled(
self, agent_with_memory_tool
):
"""Guidance must follow the stores, not just the tool's presence.
With both built-in stores off, ``agent_init`` never builds a
``MemoryStore``, so every memory call returns "Memory is not
available" — telling the model to save facts there is a dead
instruction paid for on every API call.
"""
from agent.prompt_builder import MEMORY_GUIDANCE, USER_PROFILE_GUIDANCE
agent_with_memory_tool._memory_enabled = False
agent_with_memory_tool._user_profile_enabled = False
prompt = agent_with_memory_tool._build_system_prompt()
assert MEMORY_GUIDANCE not in prompt
assert USER_PROFILE_GUIDANCE not in prompt
def test_profile_guidance_when_only_user_profile_enabled(
self, agent_with_memory_tool
):
"""USER.md alone gets the narrower profile-only guidance.
The full MEMORY_GUIDANCE block instructs the model to save notes to a
MEMORY.md store that does not exist in this configuration, so the
profile-specific block is injected instead.
"""
from agent.prompt_builder import MEMORY_GUIDANCE, USER_PROFILE_GUIDANCE
agent_with_memory_tool._memory_enabled = False
agent_with_memory_tool._user_profile_enabled = True
prompt = agent_with_memory_tool._build_system_prompt()
assert MEMORY_GUIDANCE not in prompt
assert USER_PROFILE_GUIDANCE in prompt
def test_datetime_is_date_only_not_minute_precision(self, agent):
+51 -1
View File
@@ -6,6 +6,7 @@ import base64
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, cast
from unittest.mock import MagicMock, patch
@@ -79,7 +80,7 @@ class TestRegistration:
from tools.computer_use import cua_backend
driver = tmp_path / "custom-cua-driver"
driver.write_text("#!/bin/sh\nexit 0\n")
driver.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
driver.chmod(0o755)
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver))
@@ -2553,6 +2554,55 @@ class TestElementSpillFile:
assert "elements_file" not in out
class TestCaptureScreenshotPersistence:
"""Image captures expose a bounded file for explicit user delivery."""
_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76L"
"AAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
)
def _capture(self):
from tools.computer_use.backend import CaptureResult
return CaptureResult(
mode="vision",
width=8,
height=8,
png_b64=self._PNG_B64,
image_mime_type="image/png",
png_bytes_len=len(base64.b64decode(self._PNG_B64)),
)
def test_multimodal_capture_exposes_shareable_screenshot(
self, tmp_path, monkeypatch,
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools.computer_use import tool as cu_tool
monkeypatch.setattr(
cu_tool, "_should_route_through_aux_vision", lambda: False,
)
out = cu_tool._capture_response(self._capture())
screenshot_path = out["meta"]["screenshot_path"]
assert screenshot_path in out["text_summary"]
assert "MEDIA:" not in out["text_summary"]
assert screenshot_path.startswith(str(tmp_path / "cache" / "images"))
assert Path(screenshot_path).read_bytes() == base64.b64decode(self._PNG_B64)
def test_capture_cache_is_bounded(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools.computer_use import tool as cu_tool
monkeypatch.setattr(cu_tool, "_MAX_CAPTURE_FILES", 2)
for _ in range(3):
assert cu_tool._persist_capture_image(self._capture()) is not None
captures = list((tmp_path / "cache" / "images").glob("computer_use_*.*"))
assert len(captures) == 2
class TestBoundsScaleField:
def test_scale_reported_when_spaces_diverge(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -0,0 +1,70 @@
"""Regression tests for the /rollback all-directories fallback (#10505).
Bare /rollback searched only TERMINAL_CWD's project; checkpoints created
under a different session cwd were invisible ("No checkpoints found for
/home/user" while checkpoints existed seconds earlier). Reapply of PR
#10633 by @nightq onto the v2 single-store layout.
"""
import json
import tools.checkpoint_manager as cm
from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
def _make_store_with_project(tmp_path, workdir: str):
store = tmp_path / "store"
(store / cm._PROJECTS_DIRNAME).mkdir(parents=True)
(store / "HEAD").write_text("ref: refs/heads/main\n")
dir_hash = cm._project_hash(workdir)
meta = {"workdir": workdir, "created_at": 1, "last_touch": 2}
(store / cm._PROJECTS_DIRNAME / f"{dir_hash}.json").write_text(json.dumps(meta))
return store
class TestListAllCheckpoints:
def test_aggregates_projects_and_labels_workdir(self, tmp_path, monkeypatch):
workdir = str(tmp_path / "proj")
store = _make_store_with_project(tmp_path, workdir)
monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path)
monkeypatch.setattr(cm, "_store_path", lambda base=None: store)
mgr = CheckpointManager(enabled=True)
fake_entries = [
{"hash": "a" * 40, "short_hash": "aaaaaaa", "timestamp": "2026-08-19T10:00:00",
"reason": "before write_file", "files_changed": 1, "insertions": 2, "deletions": 0},
]
monkeypatch.setattr(
CheckpointManager, "list_checkpoints", lambda self, wd: list(fake_entries)
)
results = mgr.list_all_checkpoints()
assert len(results) == 1
assert results[0]["workdir"] == workdir
def test_empty_store_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path)
monkeypatch.setattr(cm, "_store_path", lambda base=None: tmp_path / "missing")
assert CheckpointManager(enabled=True).list_all_checkpoints() == []
class TestFormatAllDirectories:
def test_workdir_label_shown_in_all_directories_view(self):
cps = [{
"hash": "b" * 40, "short_hash": "bbbbbbb",
"timestamp": "2026-08-19T10:00:00", "reason": "before patch",
"files_changed": 0, "insertions": 0, "deletions": 0,
"workdir": "/tmp/qa-repo",
}]
out = format_checkpoint_list(cps, "all directories")
assert "[qa-repo]" in out
def test_single_directory_view_unchanged(self):
cps = [{
"hash": "c" * 40, "short_hash": "ccccccc",
"timestamp": "2026-08-19T10:00:00", "reason": "before patch",
"files_changed": 0, "insertions": 0, "deletions": 0,
}]
out = format_checkpoint_list(cps, "/tmp/qa-repo")
assert "[qa-repo]" not in out
assert "ccccccc" in out
+362
View File
@@ -2,11 +2,14 @@
from __future__ import annotations
import contextlib
import os
import subprocess
from pathlib import Path
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
import tui_gateway.server as server
@@ -336,6 +339,139 @@ def test_scan_time_is_not_treated_as_session_activity(tmp_path):
assert active["last_active"] > idle["last_active"]
def test_remote_scan_failure_merges_instead_of_replacing_cache(tmp_path, monkeypatch):
"""A backend scan that can't fully walk its roots must NOT wipe the cache.
`projects.discover_repos` with `scan:true` asks the remote host to scan its
own discovery roots. When one root fails to walk, the scan result is not the
authoritative full universe — the previously cached repos must survive so a
failed remote refresh can't blank the sidebar back to the silent, empty
state of #81723 (regression for MEDIUM: `replace=True` was wiping on every
call regardless of success).
"""
from hermes_cli import projects_db as pdb
import tui_gateway.server as server
def _git_repo(path):
repo = path
repo.mkdir(parents=True)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
return str(repo)
# A cached repo the partial scan never visits, parked OUTSIDE the scan roots.
seed = _git_repo(tmp_path / "elsewhere" / "seed-repo")
with pdb.connect_closing() as conn:
pdb.record_discovered_repos(conn, [(seed, "seed-repo")])
seeded = [r["root"] for r in pdb.list_discovered_repos(conn)]
assert seed in seeded
good = _git_repo(tmp_path / "good-repo")
# Force one root to fail to walk: the scan becomes non-authoritative, so it
# must merge into the cache, never wipe it.
real_walk = os.walk
bad_root = str(tmp_path / "unwalkable")
def _flaky_walk(top, *a, **k):
if top == bad_root:
raise OSError("boom")
yield from real_walk(top, *a, **k)
monkeypatch.setattr(server.os, "walk", _flaky_walk)
policy = {"enabled": True, "roots": [good, bad_root], "exclude_paths": []}
with pdb.connect_closing() as conn:
authoritative = server._scan_discovered_repos_remote(conn, policy)
joined = [r["root"] for r in pdb.list_discovered_repos(conn)]
# The scan found the good repo and merged it, but the failed root means the
# result is not authoritative, so it must NOT have replaced the cache.
assert not authoritative
assert good in joined
# The seeded repo that the partial scan never saw is still cached.
assert seed in joined
def test_remote_scan_missing_root_does_not_wipe_cache(tmp_path):
"""A configured discovery root missing on disk must NOT wipe the cache.
``os.walk`` on a non-existent root silently yields nothing instead of
raising, so a temporarily unavailable root (unmounted volume, moved path)
would otherwise make the scan look like a genuinely empty authoritative
set and DELETE-replace every cached repo that lived under it. The missing
root must contribute nothing, and the scan must merge — never wipe.
"""
from hermes_cli import projects_db as pdb
import tui_gateway.server as server
def _git_repo(path):
repo = path
repo.mkdir(parents=True)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
return str(repo)
# A cached repo the scan never visits, parked OUTSIDE the scan roots.
seed = _git_repo(tmp_path / "elsewhere" / "seed-repo")
with pdb.connect_closing() as conn:
pdb.record_discovered_repos(conn, [(seed, "seed-repo")])
seeded = [r["root"] for r in pdb.list_discovered_repos(conn)]
assert seed in seeded
good = _git_repo(tmp_path / "good-repo")
# This root is configured but does NOT exist on disk. os.walk on it yields
# nothing silently — without the guard the scan would stay authoritative
# and wipe the cache.
missing_root = str(tmp_path / "missing-root")
policy = {"enabled": True, "roots": [good, missing_root], "exclude_paths": []}
with pdb.connect_closing() as conn:
authoritative = server._scan_discovered_repos_remote(conn, policy)
joined = [r["root"] for r in pdb.list_discovered_repos(conn)]
# The missing root is not authoritative, so the scan must merge, not wipe.
assert not authoritative
assert good in joined
# The seeded repo the missing root would have wiped is still cached.
assert seed in joined
def test_remote_scan_full_authoritative_replaces_cache(tmp_path):
"""Only a fully-walked scan may replace the stale cache."""
from hermes_cli import projects_db as pdb
import tui_gateway.server as server
def _git_repo(path):
repo = path
repo.mkdir(parents=True)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
return str(repo)
# Park the stale repo OUTSIDE the scan root so the authoritative scan no
# longer sees it, and the fresh repo inside the root it walks.
stale = _git_repo(tmp_path / "outside" / "stale-repo")
scandir = tmp_path / "scandir"
scandir.mkdir()
fresh = _git_repo(scandir / "fresh-repo")
with pdb.connect_closing() as conn:
pdb.record_discovered_repos(conn, [(stale, "stale-repo")])
policy = {"enabled": True, "roots": [str(scandir)], "exclude_paths": []}
with pdb.connect_closing() as conn:
authoritative = server._scan_discovered_repos_remote(conn, policy)
joined = [r["root"] for r in pdb.list_discovered_repos(conn)]
assert authoritative
assert fresh in joined
# A full, authoritative scan replaced the stale cache: the old repo the
# scan no longer saw is gone from the authoritative set.
assert stale not in joined
def test_terminal_session_persists_its_launch_cwd():
"""A terminal session's cwd IS its workspace, so the row must record it.
@@ -460,3 +596,229 @@ def test_nondefault_policy_rejects_stale_or_legacy_results(monkeypatch, tmp_path
assert any(item["root"] == str(root) for item in accepted["repos"])
def _profile_dir(tmp_path: Path, name: str) -> Path:
home = tmp_path / "homes" / name
home.mkdir(parents=True, exist_ok=True)
return home
def _bind_profiles(monkeypatch, tmp_path: Path, homes: dict[str, Path]) -> None:
"""Resolve profile names to this test's throwaway homes.
Unmapped names resolve to a path that does not exist, which is how the
gateway detects "not a real profile on this host" and stays on launch.
"""
monkeypatch.setattr(
"hermes_cli.profiles.get_profile_dir",
lambda name: homes.get(name, tmp_path / "homes" / "missing" / name),
)
def _create_project(home: Path, name: str, folder: Path, *, use: bool = False) -> dict:
"""Create a project in ``home``'s projects.db via the real RPC."""
token = set_hermes_home_override(home)
try:
return _call(
"projects.create", {"name": name, "folders": [str(folder)], "use": use}
)["project"]
finally:
reset_hermes_home_override(token)
def _create_session(home: Path, session_id: str, cwd: Path) -> None:
"""Seed one message-bearing session in ``home``'s state.db."""
from hermes_state import SessionDB
db = SessionDB(db_path=home / "state.db")
try:
db.create_session(session_id, "cli", cwd=str(cwd))
db.append_message(session_id, "user", f"hello from {session_id}")
finally:
db.close()
@contextlib.contextmanager
def _serving_launch_profile(launch_home: Path):
"""Run the handlers as a backend launched under ``launch_home``."""
from hermes_state import SessionDB
token = set_hermes_home_override(launch_home)
prev_db, prev_error = server._db, server._db_error
server._db = SessionDB(db_path=launch_home / "state.db")
server._db_error = None
try:
yield
finally:
server._db.close()
server._db, server._db_error = prev_db, prev_error
reset_hermes_home_override(token)
def _cached_repo_labels(home: Path) -> list[str]:
"""Labels in ``home``'s discovered-repo cache, read straight off disk."""
from hermes_cli import projects_db as pdb
with pdb.connect_closing(home / "projects.db") as conn:
return sorted(str(entry.get("label") or "") for entry in pdb.list_discovered_repos(conn))
def test_projects_reads_are_scoped_to_the_requested_profile(monkeypatch, tmp_path):
"""A ``profile`` param reads that profile's projects.db AND its state.db."""
launch_home = _profile_dir(tmp_path, "launch")
coder_home = _profile_dir(tmp_path, "coder")
launch_repo = tmp_path / "repos" / "launch-repo"
coder_repo = tmp_path / "repos" / "coder-repo"
launch_repo.mkdir(parents=True)
coder_repo.mkdir(parents=True)
_bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home})
launch_project = _create_project(launch_home, "Launch", launch_repo, use=True)
coder_project = _create_project(coder_home, "Coder", coder_repo, use=True)
_create_session(launch_home, "launch-session", launch_repo)
_create_session(coder_home, "coder-session", coder_repo)
with _serving_launch_profile(launch_home):
launch_listing = _call("projects.list")
coder_listing = _call("projects.list", {"profile": "coder"})
launch_tree = _call("projects.tree")
coder_tree = _call("projects.tree", {"profile": "coder"})
coder_sessions = _call(
"projects.project_sessions",
{"profile": "coder", "project_id": coder_project["id"]},
)
# The override must not leak: the very next unscoped read is launch again.
launch_again = _call("projects.list")
assert [p["name"] for p in launch_listing["projects"]] == ["Launch"]
assert [p["name"] for p in coder_listing["projects"]] == ["Coder"]
assert launch_listing["active_id"] == launch_project["id"]
assert coder_listing["active_id"] == coder_project["id"]
assert launch_again == launch_listing
assert [p["label"] for p in launch_tree["projects"]] == ["Launch"]
assert [p["label"] for p in coder_tree["projects"]] == ["Coder"]
# Session counts prove the SESSION db was swapped too, not just projects.db.
assert launch_tree["projects"][0]["sessionCount"] == 1
assert coder_tree["projects"][0]["sessionCount"] == 1
assert launch_tree["scoped_session_ids"] == ["launch-session"]
assert coder_tree["scoped_session_ids"] == ["coder-session"]
assert coder_sessions["project"]["id"] == coder_project["id"]
assert coder_sessions["project"]["sessionCount"] == 1
lane = coder_sessions["project"]["repos"][0]["groups"][0]
assert [s["id"] for s in lane["sessions"]] == ["coder-session"]
def test_projects_tree_is_scoped_to_the_requested_profile(monkeypatch, tmp_path):
"""``projects.tree`` on its own reads the requested profile's stores."""
launch_home = _profile_dir(tmp_path, "launch")
coder_home = _profile_dir(tmp_path, "coder")
launch_repo = tmp_path / "repos" / "tree-launch"
coder_repo = tmp_path / "repos" / "tree-coder"
launch_repo.mkdir(parents=True)
coder_repo.mkdir(parents=True)
_bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home})
_create_project(launch_home, "Launch", launch_repo, use=True)
_create_project(coder_home, "Coder", coder_repo, use=True)
_create_session(launch_home, "tree-launch-session", launch_repo)
_create_session(coder_home, "tree-coder-session", coder_repo)
with _serving_launch_profile(launch_home):
coder_tree = _call("projects.tree", {"profile": "coder"})
launch_tree = _call("projects.tree")
assert [p["label"] for p in coder_tree["projects"]] == ["Coder"]
assert coder_tree["scoped_session_ids"] == ["tree-coder-session"]
assert [p["label"] for p in launch_tree["projects"]] == ["Launch"]
assert launch_tree["scoped_session_ids"] == ["tree-launch-session"]
def test_project_sessions_is_scoped_to_the_requested_profile(monkeypatch, tmp_path):
"""``projects.project_sessions`` on its own hydrates from the requested profile."""
launch_home = _profile_dir(tmp_path, "launch")
coder_home = _profile_dir(tmp_path, "coder")
launch_repo = tmp_path / "repos" / "drill-launch"
coder_repo = tmp_path / "repos" / "drill-coder"
launch_repo.mkdir(parents=True)
coder_repo.mkdir(parents=True)
_bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home})
launch_project = _create_project(launch_home, "Launch", launch_repo, use=True)
coder_project = _create_project(coder_home, "Coder", coder_repo, use=True)
_create_session(launch_home, "drill-launch-session", launch_repo)
_create_session(coder_home, "drill-coder-session", coder_repo)
with _serving_launch_profile(launch_home):
coder_drill = _call(
"projects.project_sessions",
{"profile": "coder", "project_id": coder_project["id"]},
)
launch_drill = _call(
"projects.project_sessions", {"project_id": launch_project["id"]}
)
assert coder_drill["project"] is not None
assert coder_drill["project"]["id"] == coder_project["id"]
coder_lane = coder_drill["project"]["repos"][0]["groups"][0]
assert [s["id"] for s in coder_lane["sessions"]] == ["drill-coder-session"]
assert launch_drill["project"]["id"] == launch_project["id"]
launch_lane = launch_drill["project"]["repos"][0]["groups"][0]
assert [s["id"] for s in launch_lane["sessions"]] == ["drill-launch-session"]
def test_record_repos_writes_to_the_requested_profiles_projects_db(monkeypatch, tmp_path):
"""The scan cache is per-profile: a scoped write must not land on launch."""
launch_home = _profile_dir(tmp_path, "launch")
coder_home = _profile_dir(tmp_path, "coder")
launch_repo = tmp_path / "repos" / "launch-scan"
coder_repo = tmp_path / "repos" / "coder-scan"
launch_repo.mkdir(parents=True)
coder_repo.mkdir(parents=True)
_bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home})
with _serving_launch_profile(launch_home):
_call("projects.record_repos", {"repos": [{"root": str(launch_repo), "label": "launch"}]})
_call(
"projects.record_repos",
{"profile": "coder", "repos": [{"root": str(coder_repo), "label": "coder"}]},
)
launch_repos = _call("projects.discover_repos")["repos"]
coder_repos = _call("projects.discover_repos", {"profile": "coder"})["repos"]
assert [repo["label"] for repo in launch_repos] == ["launch"]
assert [repo["label"] for repo in coder_repos] == ["coder"]
assert _cached_repo_labels(launch_home) == ["launch"]
assert _cached_repo_labels(coder_home) == ["coder"]
def test_projects_without_a_profile_stay_on_the_launch_home(monkeypatch, tmp_path):
"""Omitted/blank/unknown profile is a no-op — the pre-scoping behavior."""
launch_home = _profile_dir(tmp_path, "launch")
coder_home = _profile_dir(tmp_path, "coder")
repo = tmp_path / "repos" / "launch-only"
repo.mkdir(parents=True)
_bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home})
with _serving_launch_profile(launch_home):
created = _call(
"projects.create", {"name": "Launch only", "folders": [str(repo)], "use": True}
)["project"]
_call("projects.record_repos", {"repos": [{"root": str(repo), "label": "only"}]})
omitted = _call("projects.list")
blank = _call("projects.list", {"profile": ""})
unknown = _call("projects.list", {"profile": "not-a-profile"})
assert [p["name"] for p in omitted["projects"]] == ["Launch only"]
assert blank == omitted
assert unknown == omitted
assert omitted["active_id"] == created["id"]
assert _cached_repo_labels(launch_home) == ["only"]
assert not (coder_home / "projects.db").exists()
assert not (Path(os.environ["HERMES_HOME"]) / "projects.db").exists()
+33 -1
View File
@@ -968,6 +968,29 @@ class CheckpointManager:
results.append(entry)
return results
def list_all_checkpoints(self) -> List[Dict]:
"""List checkpoints across every registered project (most recent first).
Surgical reapply of PR #10633 by @nightq (#10505) onto the v2
single-store layout: iterate ``projects/<hash>.json`` metadata via
``_list_projects`` instead of the pre-v2 per-shadow-dir scan. Each
entry carries the extra ``workdir`` key so callers can label which
project a checkpoint belongs to.
"""
store = _store_path(CHECKPOINT_BASE)
if not (store / "HEAD").exists():
return []
results: List[Dict] = []
for meta in _list_projects(store):
workdir = meta.get("workdir") or ""
if not workdir:
continue
for entry in self.list_checkpoints(workdir):
entry["workdir"] = workdir
results.append(entry)
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
return results
@staticmethod
def _parse_shortstat(stat_line: str, entry: Dict) -> None:
"""Parse git --shortstat output into entry dict."""
@@ -1563,7 +1586,16 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str:
else:
stat = ""
lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}")
# Label per-project entries when showing the cross-project view
# (workdir key only present on list_all_checkpoints results).
workdir = cp.get("workdir", "")
if workdir and directory == "all directories":
workdir_short = Path(workdir).name or workdir
lines.append(
f" {i}. {cp['short_hash']} {ts} [{workdir_short}] {cp['reason']}{stat}"
)
else:
lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}")
lines.append("\n /rollback <N> restore to checkpoint N")
lines.append(" /rollback diff <N> preview changes since checkpoint N")
+5 -1
View File
@@ -22,7 +22,11 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = {
"Preferred workflow: call with "
"action='capture' (mode='som' gives numbered element overlays), "
"then click by `element` index for reliability. Pixel coordinates "
"are supported for models trained on them. Works on any window — "
"are supported for models trained on them. Image captures include a "
"shareable `screenshot_path`; when the user asks to receive the image "
"and the current surface supports attachments, deliver that file using "
"the platform's native MEDIA attachment syntax. Do not automatically "
"send screenshots used only for computer control. Works on any window — "
"hidden, minimized, or behind another app. Requires cua-driver to "
"be installed."
),
+66 -3
View File
@@ -1195,6 +1195,11 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
or image_dimensions[1] < _MIN_PROVIDER_IMAGE_DIMENSION
)
)
screenshot_path = (
_persist_capture_image(cap)
if cap.png_b64 and cap.mode != "ax" and not image_too_small
else None
)
# Index only what's actually surfaced in the response — otherwise the
# human-readable summary references element indices the model cannot
@@ -1209,6 +1214,10 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
]
if bounds_note:
summary_lines.append(f" ({bounds_note})")
if screenshot_path:
summary_lines.append(
f" (shareable screenshot saved to {screenshot_path})"
)
if elements_file:
summary_lines.append(
f" (full element tree with untruncated labels saved to "
@@ -1243,6 +1252,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
visible_elements=visible_elements,
truncated_elements=truncated_elements,
elements_file=elements_file,
screenshot_path=screenshot_path,
)
if routed is not None:
return routed
@@ -1278,6 +1288,8 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
payload["truncated_elements"] = truncated_elements
if elements_file:
payload["elements_file"] = elements_file
if screenshot_path:
payload["screenshot_path"] = screenshot_path
if bounds_scale:
payload["bounds_scale"] = bounds_scale
return json.dumps(payload)
@@ -1303,9 +1315,10 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
],
"text_summary": summary,
"meta": {"mode": cap.mode, "width": response_width, "height": response_height,
"elements": total_elements, "png_bytes": cap.png_bytes_len,
**({"elements_file": elements_file} if elements_file else {}),
**({"bounds_scale": bounds_scale} if bounds_scale else {})},
"elements": total_elements, "png_bytes": cap.png_bytes_len,
**({"screenshot_path": screenshot_path} if screenshot_path else {}),
**({"elements_file": elements_file} if elements_file else {}),
**({"bounds_scale": bounds_scale} if bounds_scale else {})},
}
# AX-only (or image-missing fallback): text path actually carries the
# `elements` array, so the truncation note applies here.
@@ -1446,6 +1459,7 @@ def _route_capture_through_aux_vision(
visible_elements: Optional[List[UIElement]] = None,
truncated_elements: int = 0,
elements_file: Optional[str] = None,
screenshot_path: Optional[str] = None,
) -> Optional[str]:
"""Pre-analyse the captured PNG via ``vision_analyze`` and return a text result.
@@ -1559,6 +1573,8 @@ def _route_capture_through_aux_vision(
payload["truncated_elements"] = truncated_elements
if elements_file:
payload["elements_file"] = elements_file
if screenshot_path:
payload["screenshot_path"] = screenshot_path
return json.dumps(payload)
@@ -1648,6 +1664,53 @@ _MAX_ELEMENT_LABEL_CHARS = 120
# capture of a dense UI can spill; without pruning the cache grows unbounded.
_MAX_SPILL_FILES = 20
# Keep user-shareable capture files bounded independently from the gateway's
# periodic media-cache cleanup. CLI-only sessions may never start the gateway,
# and capture_after can otherwise leave an unbounded screenshot trail.
_MAX_CAPTURE_FILES = 20
def _persist_capture_image(cap: CaptureResult) -> Optional[str]:
"""Save a capture in Hermes' media cache and return its absolute path.
Captures are normally embedded only in the model's tool context. Persisting
a bounded copy gives attachment-capable surfaces a real file to deliver
when the user explicitly asks for the screenshot. This is best-effort: an
unwritable cache must never break computer control.
"""
if not cap.png_b64:
return None
try:
import uuid as _uuid
from hermes_constants import get_hermes_dir
raw = base64.b64decode(cap.png_b64, validate=False)
mime = str(cap.image_mime_type or "").lower()
ext = ".jpg" if mime == "image/jpeg" or (
not mime and cap.png_b64[:8].startswith("/9j/")
) else ".png"
cache_dir = get_hermes_dir("cache/images", "image_cache")
cache_dir.mkdir(parents=True, exist_ok=True)
try:
captures = sorted(
cache_dir.glob("computer_use_*.*"),
key=lambda path: path.stat().st_mtime,
)
keep_before_write = max(0, _MAX_CAPTURE_FILES - 1)
for stale in captures[: max(0, len(captures) - keep_before_write)]:
stale.unlink(missing_ok=True)
except Exception:
pass
path = cache_dir / f"computer_use_{_uuid.uuid4().hex}{ext}"
path.write_bytes(raw)
return str(path)
except Exception as exc: # pragma: no cover - defensive
logger.debug("computer_use: screenshot persistence failed: %s", exc)
return None
def _spill_elements_to_file(cap: CaptureResult) -> Optional[str]:
"""Write the FULL element tree (untruncated labels) to a cache file.
+27 -2
View File
@@ -1143,9 +1143,34 @@ def memory_tool(
return json.dumps(result, ensure_ascii=False)
def builtin_memory_stores_enabled() -> bool:
"""Return whether either built-in store (MEMORY.md / USER.md) is enabled.
``agent_init`` only builds a ``MemoryStore`` when at least one of
``memory.memory_enabled`` / ``memory.user_profile_enabled`` is true, so with
both off the tool dispatches against ``store=None`` and every call fails
with "Memory is not available".
Fails open when config can't be read: an unreadable config must not strip a
tool that would otherwise work.
"""
try:
from hermes_cli.config import load_config_readonly
section = (load_config_readonly() or {}).get("memory")
if not isinstance(section, dict):
return True
return bool(section.get("memory_enabled", True)) or bool(
section.get("user_profile_enabled", True)
)
except Exception:
logger.debug("Could not read memory config for availability", exc_info=True)
return True
def check_memory_requirements() -> bool:
"""Memory tool has no external requirements -- always available."""
return True
"""Available unless both built-in memory stores are disabled in config."""
return builtin_memory_stores_enabled()
def apply_memory_pending(payload: Dict[str, Any], store: "MemoryStore") -> Dict[str, Any]:
+65 -54
View File
@@ -17,31 +17,40 @@ _profile_scoped = _registry.profile_scoped
@method("projects.discover_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived."""
try:
db = _get_db()
if db is None:
return _ok(rid, {"repos": []})
from hermes_cli import projects_db as pdb
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"repos": []})
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
with pdb.connect_closing() as conn:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
repos = _discover_repos_payload(
db, conn=conn, include_cached=policy["enabled"]
)
return _ok(rid, {"repos": repos, "discovery_policy": policy})
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
with pdb.connect_closing() as conn:
pdb.reconcile_discovered_repos_policy(
conn,
policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy),
)
# `scan=true` (set by the desktop in remote-gateway mode): run a
# backend-side filesystem scan of the policy roots so repos with
# zero Hermes sessions still surface. The desktop's native scan
# only runs on the local filesystem; on a remote connection it
# must ask the host to scan itself (#81723).
if params.get("scan") and policy["enabled"]:
_scan_discovered_repos_remote(conn, policy)
repos = _discover_repos_payload(
db, conn=conn, include_cached=policy["enabled"]
)
return _ok(rid, {"repos": repos, "discovery_policy": policy})
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.record_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Persist git repo roots found by the client's filesystem scan, then return
the merged repo list. The native crawl runs on the desktop (local fs); this
@@ -88,24 +97,25 @@ def _(rid, params: dict) -> dict:
elif not policy["enabled"]:
pdb.clear_discovered_repos(conn, policy_key=policy_key)
db = _get_db()
return _ok(
rid,
{
"repos": _discover_repos_payload(
db, include_cached=policy["enabled"]
)
if db is not None
else [],
"accepted": accepted,
"discovery_policy": policy,
},
)
with _profile_db(params) as db:
return _ok(
rid,
{
"repos": _discover_repos_payload(
db, include_cached=policy["enabled"]
)
if db is not None
else [],
"accepted": accepted,
"discovery_policy": policy,
},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.tree")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Authoritative project overview: project -> repo -> lane structure with
counts + a few preview sessions per project, plus the flat set of session
@@ -113,26 +123,27 @@ def _(rid, params: dict) -> dict:
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
"""
try:
db = _get_db()
if db is None:
return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []})
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []})
tree, active_id = _build_project_tree(
db,
preview_limit=int(params.get("preview_limit") or 3),
hydrate=False,
session_limit=int(params.get("session_limit") or 2000),
include_discovered=True,
)
return _ok(
rid,
{"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]},
)
tree, active_id = _build_project_tree(
db,
preview_limit=int(params.get("preview_limit") or 3),
hydrate=False,
session_limit=int(params.get("session_limit") or 2000),
include_discovered=True,
)
return _ok(
rid,
{"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.project_sessions")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
built from the same authoritative grouping as ``projects.tree`` so ids and
@@ -142,18 +153,18 @@ def _(rid, params: dict) -> dict:
if not project_id:
return _err(rid, 5063, "project_id required")
db = _get_db()
if db is None:
return _ok(rid, {"project": None})
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"project": None})
# Drill-in only needs the entered project (which has sessions), so skip
# the zero-session discovery tier entirely.
tree, _active = _build_project_tree(
db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000),
include_discovered=False,
)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
return _ok(rid, {"project": proj})
# Drill-in only needs the entered project (which has sessions), so skip
# the zero-session discovery tier entirely.
tree, _active = _build_project_tree(
db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000),
include_discovered=False,
)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
return _ok(rid, {"project": proj})
except Exception as e:
return _err(rid, 5061, str(e))
+92 -9
View File
@@ -1534,13 +1534,12 @@ def _profile_home(profile: str | None) -> Path | None:
def _profile_scoped(handler):
"""Bind ``params['profile']``'s HERMES_HOME around a pet RPC handler.
"""Bind ``params['profile']``'s HERMES_HOME around a handler.
Pets are per-profile: ``display.pet.*`` lives in the profile's config.yaml and
sprites install under its ``pets/`` dir (both resolve via ``get_hermes_home``).
The desktop sends ``profile`` on pet calls so config + pets dir resolve to the
focused profile even in app-global remote mode, where one backend serves every
profile. No-op for the launch profile (own-profile backends already resolve it).
Pets (config + sprites) and projects (projects.db, discovery policy) both
resolve via ``get_hermes_home``. The desktop sends ``profile`` so a single
backend serving every profile in app-global remote mode still hits the
focused profile's home. No-op for the launch profile.
"""
def wrapper(rid, params):
@@ -12634,13 +12633,14 @@ def _projects_payload(conn) -> dict:
def _projects_method(name: str):
"""Register a projects RPC, injecting (pdb, conn) and unifying error mapping.
Every project CRUD handler opened the per-profile DB, mapped a missing id to
5062, bad args to 5063, and everything else to 5061. This collapses that
boilerplate so each handler is just its one meaningful operation.
Binds ``params['profile']`` (via ``@_profile_scoped``) so app-global remote
mode reads that profile's ``projects.db``. Missing id maps to 5062, bad args
to 5063, everything else to 5061.
"""
def decorator(fn):
@method(name)
@_profile_scoped
def handler(rid, params: dict) -> dict:
try:
from hermes_cli import projects_db as pdb
@@ -12879,6 +12879,89 @@ def _repo_discovery_policy_is_default(policy: dict) -> bool:
)
def _scan_discovered_repos_remote(conn, policy: dict) -> bool:
"""Backend-side disk scan of the discovery policy roots.
The desktop's native repo scan only runs on the local filesystem. On a
remote gateway connection the host must scan its own disk so repos with
zero Hermes sessions still appear in the sidebar (#81723). Mirrors the
desktop's behavior: walk each root (bounded depth), find `.git`
directories, record (root, label) pairs into the discovery cache.
Best-effort: any failure logs and leaves the cache untouched — the
session-derived repos from `_discover_repos_payload` still surface.
Returns True when the scan is authoritative (every root was walked to
completion without error and the per-scan cap was not hit). Only then may
the caller treat the result as a full replacement and pass ``replace=True``
to the cache write — a partial or errored scan must merge, never wipe, so
a failed remote refresh can't blank the previously cached repos into the
silent, unpopulated sidebar of #81723.
"""
from hermes_cli import projects_db as pdb
roots = policy.get("roots") or []
excludes = policy.get("exclude_paths") or []
pairs: list[tuple[str, str | None]] = []
seen: set[str] = set()
authoritative = True
def _is_excluded(path: str) -> bool:
return any(path == ex or path.startswith(ex.rstrip("/\\") + os.sep) for ex in excludes if ex)
for root in roots:
if not os.path.isdir(root):
# `os.walk` on a missing root silently yields nothing instead of
# raising, so a temporarily unavailable root (unmounted volume,
# moved path) would otherwise look like a genuinely empty scan and
# let `authoritative` stay True — letting the replace wipe every
# cached repo that lived under the missing root. A missing root
# contributes nothing and must not be treated as authoritative.
authoritative = False
logger.debug("discover_repos scan root missing, skipping: %s", root)
continue
try:
for dirpath, dirnames, _filenames in os.walk(root):
if _is_excluded(dirpath):
dirnames[:] = []
continue
# A `.git` directory marks this directory as a repo root. Check
# BEFORE pruning hidden dirs — `.git` is itself hidden, so a
# prune-first order would drop it and never detect any repo.
if ".git" in dirnames:
repo_root = dirpath
if repo_root not in seen:
seen.add(repo_root)
pairs.append((repo_root, os.path.basename(repo_root)))
# Don't descend into the repo's own .git to hunt nested repos.
dirnames[:] = []
else:
# Not a repo: skip hidden dirs (e.g. .hermes) and node_modules.
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in ("node_modules",)]
if len(pairs) >= 500:
break
except Exception:
# A root that can't be walked yields no authoritative set — fall back
# to merging, never replacing, so the prior cache survives.
authoritative = False
logger.debug("discover_repos scan failed for root %s", root, exc_info=True)
if len(pairs) >= 500:
# Cap hit means the walk didn't cover the full roots; the collected
# set must not be treated as the complete authoritative universe.
authoritative = False
break
if pairs:
try:
pdb.record_discovered_repos(
conn, pairs, replace=authoritative, policy_key=_repo_discovery_policy_key(policy)
)
except Exception:
logger.debug("discover_repos cache write failed", exc_info=True)
authoritative = False
return authoritative
def _discover_repos_payload(
db, *, conn=None, backfill: bool = True, include_cached: bool = True
) -> list[dict]:
@@ -240,6 +240,20 @@ memory:
write_approval: false # false = write freely (default) | true = require approval
```
Setting **both** `memory_enabled` and `user_profile_enabled` to `false` turns the
built-in stores off completely: the `memory` tool is dropped from the schema and
its guidance block is dropped from the system prompt, so the model is never told
about a tool it cannot use. An external provider set via `memory.provider`
(Hindsight, Mem0, Honcho, …) is unaffected and keeps its own tools — use this
when you want a third-party memory backend *instead of* the built-in files.
Listing `memory` under `agent.disabled_toolsets` is the heavier switch: it hides
external provider tools too.
With only `memory_enabled: false` (user profile still on), the tool stays —
it backs the profile store — but the system prompt swaps the full memory
guidance for a narrower profile-only block, so the model is only instructed to
save user-profile facts and never steered at the disabled notes store.
## Controlling memory writes (`write_approval`)
By default the agent saves memory freely — including from the background